Skip to content

feat(ir): model request-body omittability separately from its type - #17360

Open
fern-support wants to merge 14 commits into
mainfrom
feat/ir-request-body-required
Open

feat(ir): model request-body omittability separately from its type#17360
fern-support wants to merge 14 commits into
mainfrom
feat/ir-request-body-required

Conversation

@fern-support

@fern-support fern-support commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Description

Refs #17348, #17356

respect-optional-request-body expressed "the caller may omit this body" by wrapping requestBodyType in optional<T>. Every generator branches on the container shape of the request body to pick a calling convention, so the wrapper changed far more than optionality:

generator required body optional<T> body
Python flattened into kwargs a single request= object
Java alpha(String, Body) alpha(String, Optional<Body>)
Rust &Body &Option<Body>
C# Body request Body? request with no default

Each of those is a breaking change to an existing caller, and they were found one at a time because four generators independently re-derive "is this body omittable" from the type shape.

They are two different facts. optional<T> says the body's value may be null. What the setting means is that the call may omit the body, in which case the request carries neither content nor a Content-Type header. A body that is sent is always a T, never an optional of one.

Scope

The importer now always carries omittability into the IR. A request body that OpenAPI does not mark required: true is described as omittable, the body type is no longer wrapped in optional<Foo>, and the spec-level respect-optional-request-body setting is deprecated and inert (it warns on fern check). The on/off switch moves into each SDK generator's own configuration, so a generator that has not adopted required emits exactly what it did before.

An earlier revision left the importer emitting optional<Foo> and deferred the flip. That ordering produced non-compiling Java: the flag went inert for generators that had not adopted required, so the example said "no body" while the wrapper's staged builder still required one, leaving RefundBody.builder().build() with no build() to call. Carrying omittability in the IR and gating on per-generator config avoids that split.

Changes Made

  • HttpRequestBodyReference gains an optional required. Absent means required, so a generator that does not read it sees the same type it always has and emits the same output.
  • IR VERSION67.21.0 (minor, additive optional field) + changelog entry. No IR migration needed — ir-migrations is keyed per major.
  • HttpReferencedRequestBodySchema gains optional, which maps to required: false. This is deliberately distinct from body: optional<Foo>, which keeps its existing meaning.
  • convertReferenceHttpRequestBody maps optional: truerequired: false; example conversion and the valid-example-endpoint-call rule both accept an example that omits request for such a body.
  • dynamic.BodyRequest gains an optional bodyRequired, mirroring the field above. A snippet generator only ever sees the dynamic IR and cannot infer omittability from body, since an absent body means the endpoint has no body at all rather than that the caller may skip one.
  • Three other construction sites now set required explicitly: openapi-to-ir (the --from-openapi path, 4 call sites) and protoc-gen-fern. Neither threads requestBody.required, so both pass undefined and behave exactly as before. In openapi-to-ir this is a deliberate gate, not missing wiring — this.required is available there and already drives the bytes body; threading it into the reference bodies would hand optional-body semantics to every --from-openapi user without the opt-in.
  • Regenerated IR SDK, fern-definition schema, the four JSON schemas, and snapshots.

A note on naming

The concept changes both its name and its default across the stack, which is worth knowing when reading the diff:

layer key absent means
OpenAPI spec required optional (spec default is false)
Fern definition optional required
Fern IR required required
dynamic IR bodyRequired required

The definition layer says optional to match the vocabulary it already uses (optional<T>, and the inline body's existing optional). The IR says required deliberately: a field named optional sitting beside a requestBodyType that may itself be optional<Foo> is the exact collision this PR exists to break apart.

The default flip is the one to watch. Omitting the key in OpenAPI means optional; omitting the IR field means required. That is forced — the IR field has to default to the old behaviour so generators that ignore it are unaffected — but it means a naive required: requestBody.required in the importer would silently make every OpenAPI body optional. Hence the gate described above.

optional reaches the IR only for referenced bodies

optional: true on a referenced body maps to required: false and will eventually change generated output. On an inline body the same key is read by example validation and then dropped; it never reaches a generator. An inline body has no single named type to hang omittability off, which is the reason, but it is an implementation constraint leaking into the definition language — same key, same spelling, different reach. Worth resolving before more people write it.

Effect on users

change
Not using respect-optional-request-body none, unless the spec has a request body that is not marked required: true — those now reach the IR as omittable
Using respect-optional-request-body the setting is deprecated and inert, and warns. The opt-in moves into each generator's config: block, per language
Every IR consumer request-body references gain required, and an omittable body is no longer wrapped in optional<T>

The one behavioural difference is strictly more permissive: object parsing defaults to unrecognizedObjectKeys: "fail", so body: { type: Foo, optional: true } previously failed both arms of the undiscriminated union and errored in fern check. It now parses.

Per-generator adoption is the point: the generator version becomes the opt-in, so no new config flag is needed and no customer gets a calling-convention change they did not ask for.

Testing

  • pnpm compile clean across the monorepo. The ripple surfaced in three stages — ir-generator, then openapi-to-ir, then protoc-gen-fern — each only visible once the previous was fixed.
  • No seed/ changes, so no generator's output moved — including for the respect-optional-request-body fixture.
  • A fixture with a hand-written body: optional<Foo> regenerates byte-identical.
  • New coverage for the added path (745c813):
    • optionalReferencedBody in the omit-request-body IR fixture asserts the contract that motivates the change — required is false while requestBodyType stays named, where optional<T> leaves required unset and wraps the type in a container.
    • valid-example-endpoint-call endpoint i is endpoint h plus optional: true: the omitted-request example that violates on h passes on i, while a present request is still validated against the body type.

Follow-ups

  • Generators adopt required one at a time: TypeScript feat(typescript): let the caller omit an optional request body #17368, Python feat(python): let the caller omit an optional request body #17370, Java feat(java): let the caller omit an optional request body #17372, C# feat(csharp): let the caller omit an optional request body #17373. Go, Ruby, PHP and Rust are not covered yet.
  • Each adopting generator needs its IR dependency bumped to 67.21.0, which is only publishable once this merges. A stale pin fails differently per language, so this is easy to misread as "CI is red until the IR lands":
    • @fern-api/dynamic-ir-sdkjava-v2 and typescript-v2 at 66.1.0, csharp at 67.12.0. Fails to compile: TS2339: Property 'bodyRequired' does not exist on type 'BodyRequest'.
    • fern_fern_ir_v67 = 67.15.0 in generators/python/pyproject.toml. Fails at generation time, not build time: AttributeError: 'HttpRequestBodyReference' object has no attribute 'required'.
    • Java additionally needs com.fern.fern:irV67:67.21.0 published to Maven.
  • Snippet generators read bodyRequired (per-generator, alongside the SDK signature change).
  • API Reference docs need a fern-platform change, not an FDR contract change. The Content-Type rendered on a bodyless example comes from toSnippetHttpRequest in packages/fdr-sdk/src/api-definition/snippets/SnippetHttpRequest.ts, which sets the header from endpoint.requests[0].contentType without checking whether the example sends a body. Gating it there is sufficient and needs no isOptional on FDR's RegisterJsonRequestBody. The docs SDK snippets are likewise rendered by fern-platform's own @fern-api/snippets workspace package, so no generator PR in this repo can change them.

Open in Devin Review

@fern-support fern-support self-assigned this Aug 8, 2026

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

Adds an optional flag to referenced request bodies in the Fern definition and a corresponding required field on HttpRequestBodyReference in the IR, so omittability is modelled separately from optional<T>. The core change is small (buildEndpoint + schema + IR version bump) with a large snapshot/seed footprint. Main concerns: a hardcoded schemaIdsToExclude: [] in the new early return, a changelog that claims "no-op" while seed output for C#/TS clearly changes, and four required: undefined placeholders where the OpenAPI requestBody.required is already in hand.

  • 🟡 2 warning(s)
  • 🔵 2 suggestion(s)

Comment on lines +729 to +732
return {
schemaIdsToExclude: [],
value: canCollapse ? { body: requestValue.body } : requestValue
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

This new early return hardcodes schemaIdsToExclude: [], whereas the fall-through return just below computes it. If the optional-body path can ever reach a request whose schema should be excluded (e.g. an inlined/aliased body schema), those schema ids will now leak back into the generated types. Prefer setting requestValue.body and letting control fall through to the existing return (adjusting only the canCollapse branch), rather than duplicating the return with an empty exclusion list.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this one — the leak cannot happen, because the fall-through return also hardcodes schemaIdsToExclude: []; neither branch computes it. The referenced-body path never excludes schema ids (only the inlined path below does, via maybeSchemaId).

That said the duplicated return was worth removing, so I collapsed both into a single return with the collapse decision expressed inline.

Comment on lines +7 to +10
This is a no-op for generated SDKs today: the body type generators see is unchanged, so output
is identical until a generator opts into reading the new field. It removes the need for each
generator to unwrap `optional<T>` before deciding how to shape the request parameter, which is
what made enabling the setting change calling conventions in some languages.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

"This is a no-op for generated SDKs today" isn't accurate — the seed diffs in this same PR show C# (ISeedApiClient.cs, SeedApiClient.cs, removed Example6.cs, removed mock-server test) and, per the description, TypeScript (request?: Bodyrequest: Body) changing shape for users of respect-optional-request-body. That's a user-visible narrowing for anyone already on the setting. Please state that explicitly in the changelog so consumers aren't surprised.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — the changelog overclaimed. Rewritten to separate the two populations: definitions without the setting are byte-identical, and definitions with it do change shape. It now names which languages move and in which direction, including that Python/Java/Rust return to their non-setting shape while TypeScript and C# lose the optional parameter until those generators opt in.

Comment on lines +162 to +163
// the direct OpenAPI->IR path does not thread requestBody.required yet
required: undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

The OpenAPI requestBody.required is available on this converter, so the four required: undefined placeholders are dropping information that's already in hand. If threading it is out of scope for this PR, mark them // TODO(<issue>) so they're greppable; otherwise a reader will assume undefined is intentional semantics rather than a gap.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TODO(#17360) at all four sites. Threading requestBody.required through the direct OpenAPI→IR path is genuinely out of scope here — this PR is about the IR model and the fern-definition path — but you are right that a bare undefined reads as intentional semantics rather than a gap.

Comment on lines +13 to +15
*
* This is read by example validation only: it is not carried into the IR, so it
* does not make the request parameter optional in a generated SDK.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

Worth calling out the asymmetry explicitly: optional on the inline body is example-validation-only, while the identically named field on HttpReferencedRequestBodySchema now maps to IR required: false. Same key, two different behaviours depending on which body form is used — a sentence pointing at HttpReferencedRequestBodySchema here would save the next reader a bisect.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. The inline field's docs now point at HttpReferencedRequestBodySchema and explain why they differ: an inline body has no single type to mark omittable, so its optional stays validation-only while the referenced one reaches the IR as required: false.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-10T04:30:32Z).

Fixture main PR Delta
docs 260.2s (n=5) 296.4s (35 versions) +36.2s (+13.9%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-08-10T04:30:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-11 02:40 UTC

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-10T04:30:32Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 90s (n=5) N/A 62s -28s (-31.1%)
go-sdk square 145s (n=5) 303s (n=5) 125s -20s (-13.8%)
java-sdk square 233s (n=5) 281s (n=5) 208s -25s (-10.7%)
php-sdk square 80s (n=5) N/A 54s -26s (-32.5%)
python-sdk square 151s (n=5) 256s (n=5) 140s -11s (-7.3%)
ruby-sdk-v2 square 109s (n=5) 140s (n=5) 106s -3s (-2.8%)
rust-sdk square 228s (n=5) 236s (n=5) 217s -11s (-4.8%)
swift-sdk square 78s (n=5) 453s (n=5) 54s -24s (-30.8%)
ts-sdk square 176s (n=5) 189s (n=5) 121s -55s (-31.2%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-08-10T04:30:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-11 02:40 UTC

@fern-support
fern-support force-pushed the feat/ir-request-body-required branch from ed86ca9 to 8cdd3e2 Compare August 8, 2026 19:17
willkendall01 and others added 9 commits August 10, 2026 20:39
respect-optional-request-body expressed "the caller may omit this body" by wrapping
requestBodyType in optional<T>. Every generator branches on the container shape to pick a
calling convention, so the wrapper changed far more than optionality: Python stopped
flattening the body into kwargs, Java swapped Body for Optional<Body> in its overloads,
Rust went from &Body to &Option<Body>, and C# produced a nullable parameter with no default.

Those are different facts. optional<T> says the value may be null; this says the call may
omit the body, in which case the request carries neither content nor a Content-Type header.
A body that is sent is always a T.

HttpRequestBodyReference gains an optional `required`. Absent means required, so a generator
that does not read it sees the same type it always has and emits the same output.

Co-Authored-By: Claude <noreply@anthropic.com>
Python, Java and Rust now see a plain body type and fall back to their normal calling
conventions, which is the breakage this fixes. TypeScript loses the optional parameter
until it opts into reading `required`; restoring it is a widening.

Co-Authored-By: Claude <noreply@anthropic.com>
Moving omittability off the type broke the two places that still inferred it from the
type, so an example with no request body was silently dropped: convertTypeReferenceExample
threw on the undefined example and the throw was swallowed by the catch in
convertHttpService, and fern check reported a fatal "requires a request body".

Both now treat `optional: true` on a referenced body the way they already treat it on an
inline one. The bodyless example is back in the IR, and the Go snippet no longer sends a
body it was never given.

Also addresses review: changelog no longer claims the change is a no-op for generated SDKs,
the four unwired `required` placeholders are marked TODO, the inline/referenced asymmetry on
`optional` is documented, and the duplicated return in buildEndpoint is collapsed.

Co-Authored-By: Claude <noreply@anthropic.com>
The importer keeps emitting optional<Foo> under respect-optional-request-body. Switching it
to the new marker made the flag inert for generators that have not adopted `required` yet,
which produced non-compiling Java: the example says "no body" while the wrapper's staged
builder requires one, so RefundBody.builder().build() has no build() to call.

PR 1 now only adds the model — the IR field, the fern-definition marker, the mapping, and
example handling that honours it. Generators adopt `required` one at a time while the
importer still emits the old spelling; a later CLI change flips the importer once enough
have landed. Seed output and importer snapshots are byte-identical to main.

Co-Authored-By: Claude <noreply@anthropic.com>
dependencies.test.ts asserts the byte length of the serialised IR, which grows by 226 bytes
now that request body references carry `required`. I reverted this twice while isolating
unrelated ETE churn: my filter looked for `"required": null` in the diff, and a length
change is a bare number, so it read as noise.

Co-Authored-By: Claude <noreply@anthropic.com>
Nothing exercised the new `optional: true` path: every regenerated fixture in the PR
shows `"required": null`, so the mapping to `required: false`, the example handling,
and the validator branch were all untested.

- ir-generator: add an `optionalReferencedBody` endpoint to the omit-request-body
  fixture and assert the contract that motivates the change — `required` is false
  while `requestBodyType` stays `named`, where `optional<T>` leaves `required` unset
  and wraps the type in a container.
- validator: endpoint `i` is endpoint `h` plus `optional: true`, so the omitted-request
  example that violates on `h` passes on `i`, while a present request is still checked
  against the body type.

Co-Authored-By: Claude <noreply@anthropic.com>
A snippet generator only ever sees the dynamic IR, so `required` on
`HttpRequestBodyReference` is invisible to it. It also cannot infer omittability from
`BodyRequest.body`: an absent `body` means the endpoint has no body at all, which is a
different fact from "the caller may skip one". Without its own copy, snippets would keep
rendering a body argument for a call that omits it once generators adopt `required`.

- `dynamic.BodyRequest` gains an optional `bodyRequired`, folded into the same 67.21.0
  minor bump. Absent means required, so snippet output is unchanged until a generator
  reads it.
- `DynamicSnippetsConverter` carries it over from the SDK IR's reference body.
- New `optionalRequestBodyDynamic` test pins both directions against the shared fixture.

Also corrects two comments this change made misleading:

- `RequestBodyConverter` claimed the OpenAPI `requestBody.required` "is not read yet" at
  four copy-pasted sites. It is read — `this.required` drives the bytes body. The real
  reason the reference paths leave it unset is that threading it would hand optional-body
  semantics to every `--from-openapi` user, bypassing the `respect-optional-request-body`
  opt-in. Replaced with one named constant carrying that explanation.
- The gRPC comment now says absent means required, so it no longer reads as a mismatch
  with `required: undefined`.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The OpenAPI importer no longer wraps a non-required referenced body in optional<Foo>. It
describes the body as its own type with `optional: true`, which the IR carries as
`required: false`, so the IR says what the spec says regardless of any setting.

`respect-optional-request-body` is now a no-op that warns: reading omittability is opt-in per
SDK generator, through that generator's own configuration, so a language adopts it when it is
ready instead of every language flipping at once.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot force-pushed the feat/ir-request-body-required branch from a49dd77 to da52bfd Compare August 10, 2026 21:17
willkendall01 and others added 3 commits August 10, 2026 21:26
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…etting

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

willkendall01 and others added 2 commits August 10, 2026 23:59
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…loops

The csharp-grpc-proto fixtures already run 55-63s on CI runners against a 60s budget, on main as well as this branch.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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.

3 participants