Skip to content

fix(actions): send a request body when the template declares one#465

Merged
angel-manuel merged 1 commit into
devfrom
fix/request-body-content-type
Jul 20, 2026
Merged

fix(actions): send a request body when the template declares one#465
angel-manuel merged 1 commit into
devfrom
fix/request-body-content-type

Conversation

@angel-manuel

Copy link
Copy Markdown
Contributor

The bug

email.search on overslash-dev failed with the gateway's own rejection:

{"code":"bad_request","message":"invalid JSON request body: Expected request with `Content-Type: application/json`"}

Reproduced and confirmed: email.search with no arguments fails; email.search with {"query":"ALL"} returns the mailbox fine.

POST /email/search is the one action in email.yaml whose requestBody is 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" ⇒ "no Content-Type":

let body = if body_params.is_empty() { None } else { ... };
let mut headers = HashMap::new();
if body.is_some() {
    headers.insert("Content-Type".to_string(), "application/json".to_string());
}

overfwd is Axum, and its Json<T> extractor checks Content-Type before it ever reads the body — so the call died upstream before reaching the mailbox. send and get never 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-Type isn't caller-chosen metadata, it's a property of the body. The rule:

The body and its Content-Type are decided together, from the operation's declared requestBody. Declares one ⇒ send a body ({} when nothing resolves) and its declared media type. Declares none ⇒ send neither. Never one without the other.

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 declared content key at template load. #[serde(default)], so existing persisted templates deserialize unchanged.
  • resolve.rs derives both "takes a body" and "which Content-Type" from it.

Other headers stay orthogonal. They already have their own channel — in: header params (ParamLocation::Header, how Notion-Version is stamped) and securitySchemes injection. Those are chosen; Content-Type is derived. Keeping the mechanisms separate is what stops them contending, and an explicit Content-Type header param still overrides as an escape hatch.

Also

  • email.yaml: required: true on search, and default: INBOX / default: ALL promoted 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 a Content-Type the 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_resolves therefore 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:

assertion `left == right` failed: a declared requestBody must send Content-Type even when no field resolves

Verification

  • 386 core + 265 api lib tests pass.
  • Full API integration suite: 103 suites, 1163 tests, 0 failures (--test-threads=4).
  • cargo fmt, clippy, and vet --agentic (vs origin/dev) clean.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
overslash Ready Ready Preview, Comment Jul 20, 2026 3:17pm

Request Review

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

`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
angel-manuel force-pushed the fix/request-body-content-type branch from 9e5cbcc to 3eb95f5 Compare July 20, 2026 15:16
@angel-manuel
angel-manuel enabled auto-merge (squash) July 20, 2026 15:17
@angel-manuel
angel-manuel merged commit b48f94e into dev Jul 20, 2026
15 checks passed
@angel-manuel
angel-manuel deleted the fix/request-body-content-type branch July 20, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant