fix(actions): send a request body when the template declares one#465
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
angel-manuel
force-pushed
the
fix/request-body-content-type
branch
from
July 20, 2026 15:08
64f51e2 to
9e5cbcc
Compare
`email.search` failed with the gateway's own rejection:
invalid JSON request body: Expected request with `Content-Type: application/json`
`POST /email/search` is the one action in `email.yaml` whose `requestBody`
is unmarked and whose fields are all optional, so a zero-arg "search my
inbox" is a legitimate call. Routing inferred "caller supplied no body
params" => "no body" => "no Content-Type", and overfwd's Axum `Json<T>`
extractor checks the header before it ever reads the body, so the call
died upstream before reaching the mailbox.
Whether an operation takes a body is a static fact about the contract,
not a function of the arguments a caller happened to pass. Parse the
declared `requestBody` into `ServiceAction::request_body` at template
load, and have routing decide both "send a body" and "which
Content-Type" from it: declared => send one (`{}` when nothing resolves)
with its declared media type; not declared => send neither.
Content-Type is modelled on the body rather than as a header param
because it is derived from the payload, not chosen by the caller.
Template-chosen headers keep their own channel (`in: header` params and
`securitySchemes` injection), so the two mechanisms never contend; an
explicit `Content-Type` header param still overrides as an escape hatch.
Also promote `email.yaml`'s search defaults out of prose and into the
schema (`required: true`, `default: INBOX`, `default: ALL`) so the
fallbacks are part of the contract rather than something only the gateway
knows. Note that this alone fixes `email.search` — the routing change is
what protects every other service, so it is covered by a test whose body
is all-optional AND undefaulted, verified to fail without it.
Non-JSON media types are now recorded but still unsent; noted in
TECH_DEBT.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
angel-manuel
force-pushed
the
fix/request-body-content-type
branch
from
July 20, 2026 15:16
9e5cbcc to
3eb95f5
Compare
angel-manuel
enabled auto-merge (squash)
July 20, 2026 15:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
email.searchon overslash-dev failed with the gateway's own rejection:Reproduced and confirmed:
email.searchwith no arguments fails;email.searchwith{"query":"ALL"}returns the mailbox fine.POST /email/searchis the one action inemail.yamlwhoserequestBodyis unmarked and whose fields (folder,query,limit) are all optional — so a zero-arg "search my inbox" is a legitimate call. Routing inferred "caller supplied no body params" ⇒ "no body" ⇒ "noContent-Type":overfwd is Axum, and its
Json<T>extractor checksContent-Typebefore it ever reads the body — so the call died upstream before reaching the mailbox.sendandgetnever hit this because they have required fields. The error string exists nowhere in this repo; it's the gateway rejecting us.The fix
Not "always send
Content-Type" — that would be wrong.Content-Typeisn't caller-chosen metadata, it's a property of the body. The rule:Whether an operation takes a body is a static fact about the contract, not a function of what the caller passed. That confusion was the entire bug.
ServiceAction::request_body: Option<RequestBodySpec { content_type, required }>, parsed from the declaredcontentkey at template load.#[serde(default)], so existing persisted templates deserialize unchanged.resolve.rsderives both "takes a body" and "whichContent-Type" from it.Other headers stay orthogonal. They already have their own channel —
in: headerparams (ParamLocation::Header, howNotion-Versionis stamped) andsecuritySchemesinjection. Those are chosen;Content-Typeis derived. Keeping the mechanisms separate is what stops them contending, and an explicitContent-Typeheader param still overrides as an escape hatch.Also
email.yaml:required: trueon search, anddefault: INBOX/default: ALLpromoted out of prose into the schema.TECH_DEBT.md: non-JSON media types are now recorded but still unsent. Previously such a body would have been silently re-serialised as JSON under aContent-Typethe template never asked for; recording the real type at least makes the mismatch legible rather than wrong-on-the-wire.A note on the tests
My first regression test was vacuous — it passed against the old Rust code, because the new YAML defaults populate the body on their own. So the YAML change alone fixes
email.search; the routing change is what protects every other service.declared_request_body_is_sent_even_when_no_field_resolvestherefore uses a synthetic template whose body is all-optional and undefaulted, so nothing can fill it. Verified it genuinely fails without the fix, on exactly the missing header:Verification
--test-threads=4).cargo fmt, clippy, andvet --agentic(vsorigin/dev) clean.🤖 Generated with Claude Code