Skip to content

feat(rust): respect an omittable request body behind respectOptionalRequestBody - #17400

Open
devin-ai-integration[bot] wants to merge 11 commits into
mainfrom
devin/1786649284-rust-optional-request-body
Open

feat(rust): respect an omittable request body behind respectOptionalRequestBody#17400
devin-ai-integration[bot] wants to merge 11 commits into
mainfrom
devin/1786649284-rust-optional-request-body

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

Rust's turn at the opt-in from #17386's IR change: an endpoint whose request body the API does not
mark as required can now take that body as an Option, so a caller who passes None sends neither a
body nor a Content-Type. Reading the IR field is gated on a new respectOptionalRequestBody
config, so every existing Rust SDK keeps the signatures it has today.

Only a body the caller passes on its own is eligible:

respectOptionalRequestBody === true &&
requestBody?.type === "reference" &&
requestBody.required === false &&
endpoint.queryParameters.length === 0 &&
endpoint.sdkRequest?.shape.type === "justRequestBody"

A body the IR folds into a wrapper request stays required — the wrapper is what the caller passes,
and the dynamic IR carries no omittability for it, so a snippet could not agree with the signature.

Generated shape with the flag on:

pub async fn bulk_refund(
    &self,
    request: Option<&RefundRequest>,
    options: Option<RequestOptions>,
) -> Result<(), ApiError>
client.bulk_refund(None, None).await;                 // no body, no Content-Type
client.bulk_refund(Some(&RefundRequest { .. }), None).await;

The body argument is serialized as request.map(serde_json::to_value).transpose()?, so None
reaches execute_request as None instead of Some(json!({})).

bodyRequired is also carried from the SDK IR into the dynamic IR's justRequestBody request, so
the snippet generator — which never sees the SDK IR — can tell an omittable body from a required one.

This also moves the Rust generators onto IR v67 (@fern-fern/ir-sdk / @fern-api/dynamic-ir-sdk
67.21.0), which is where required and bodyRequired live, and registers the first Rust generator
versions that consume v67 in the v67→v66 migration.

Depends on #17386 (merged into this branch); its example-preservation change is what makes a bodyless
example reach the generator at all.

Changes Made

  • respectOptionalRequestBody (default false) in BaseRustConfigSchema, plus a
    mayOmitRequestBody helper shared by the client, reference, and snippet paths
  • SubClientGenerator: Option<&T> parameter and conditional body serialization
  • ReferenceConfigAssembler: request: Option<T> in reference.md signatures
  • EndpointSnippetGenerator: None for an example that omits the body, Some(&body) otherwise
  • DynamicSnippetsConverter: propagate bodyRequired for justRequestBody requests
  • Rust generators on IR v67 (incl. the OAuth clientCredentials narrowing and defaultValue the new
    IR types require), seed irVersion: v67, and Rust entries in the v67→v66 migration
  • New respect-optional-request-body test definition, generated with and without the flag
  • Updated README.md generator (if applicable)

Testing

  • Unit tests added/updated (existing rust-sdk suites pass; fixture pins both configs)
  • Manual testing completed

pnpm seed test --generator rust-sdk --fixture respect-optional-request-body → 2/2. With the flag,
bulk_refund takes Option<&RefundRequest> and example5 is client.bulk_refund(None, None); without
it, the same example renders &RefundRequest { ..Default::default() } and the signature is unchanged.
cargo build, cargo fmt --check, cargo clippy pass on the fixture output, and the generated
snippets compile as examples against the crate.

Generated a real customer config (Payabli, POST /v2/MoneyIn/refund/{transId}, requestBody.required: false) with the flag on:

pub async fn refundv_2(
    &self,
    trans_id: &str,
    request: Option<&RefundV2Request>,
    options: Option<RequestOptions>,
) -> Result<V2TransactionResponseWrapper, ApiError>

with client.money_in.refundv_2(&"10-3ffa27df-...".to_string(), None, None) in the bodyless snippet,
doc comment, and reference.md, and the bodyful split-refund snippets still passing the request. The
crate and that snippet both cargo build.

Link to Devin session: https://app.devin.ai/sessions/c55e670b263542b288507a66f8898dac


Open in Devin Review

willkendall01 and others added 9 commits August 12, 2026 21:07
…uire

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Each generator now decides what to do with an example that sends no body: TypeScript and C# skip it because their generated request type requires the body, and Java's snippets build an empty body for the required wrapper field.

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

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

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>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@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.

Reviewed the changes — everything looks good. No issues found.

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

Copy link
Copy Markdown
Contributor Author

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 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +623 to +626
private callOmitsRequestBody({ snippet }: { snippet: FernIr.dynamic.EndpointSnippetRequest }): boolean {
const value = snippet.requestBody;
return value == null || (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Examples that send an empty request body are rendered as sending no body at all

An example whose request body is an empty object is treated as having no body at all (callOmitsRequestBody at generators/rust/dynamic-snippets/src/EndpointSnippetGenerator.ts:623-626), so the generated Rust snippet passes None and the call sends no body and no content type instead of an empty body.
Impact: Snippets, docs and wire tests for such examples show a call that skips the body entirely, misrepresenting what the API expects to receive.

Why an explicitly empty example body is indistinguishable from an absent one

When respectOptionalRequestBody is on and the dynamic request reports bodyRequired === false with a typeReference body, getMethodArgsForBodyRequest (generators/rust/dynamic-snippets/src/EndpointSnippetGenerator.ts:590-597) pushes rust.Expression.raw("None") whenever callOmitsRequestBody returns true. That helper returns true not only for snippet.requestBody == null but also for any non-array object with zero keys, i.e. {}.

An example that explicitly supplies {} for a body type whose properties are all optional (a legitimate, representable example) is therefore rendered as client.method(None, None) rather than client.method(Some(&Body { .. }), None). Restricting the check to value == null would keep an explicit empty body distinct from an omitted one.

Suggested change
private callOmitsRequestBody({ snippet }: { snippet: FernIr.dynamic.EndpointSnippetRequest }): boolean {
const value = snippet.requestBody;
return value == null || (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
}
private callOmitsRequestBody({ snippet }: { snippet: FernIr.dynamic.EndpointSnippetRequest }): boolean {
return snippet.requestBody == null;
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional, and consistent across the languages that already opted in: the OpenAPI importer spells an example that omits the body as an empty object as well as as an absent one, so treating only null as "omitted" would make the bodyless example render Some(&Body { .. }) while the SDK signature says the body may be left out — which is the exact defect I fixed in TS/C#/Go earlier in this series. Go's callOmitsRequestBody applies the same null-or-empty-object check (generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts:960) and pins it with a test that asserts both undefined and {} produce nil (generators/go-v2/dynamic-snippets/src/__test__/OptionalRequestBody.test.ts:64).

A body type whose properties are all optional and whose example is explicitly {} is indistinguishable from an omitted one at this layer, agreed — but {} and no body are wire-equivalent only in the second case, so the tradeoff was resolved in favor of the omission the flag exists to express. Not changing here; if we want to distinguish them it should change in all four generators at once, driven by the importer keeping the distinction.

willkendall01 and others added 2 commits August 13, 2026 20:24
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>
@github-actions

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-13T04:34:37Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 98s (n=5) N/A 89s -9s (-9.2%)
go-sdk square 145s (n=5) 299s (n=5) 135s -10s (-6.9%)
java-sdk square 221s (n=5) 286s (n=5) 181s -40s (-18.1%)
php-sdk square 80s (n=5) N/A 65s -15s (-18.8%)
python-sdk square 152s (n=5) 256s (n=5) 130s -22s (-14.5%)
ruby-sdk-v2 square 106s (n=5) 147s (n=5) 78s -28s (-26.4%)
rust-sdk square 228s (n=5) 216s (n=5) 158s -70s (-30.7%)
swift-sdk square 78s (n=5) 447s (n=5) 56s -22s (-28.2%)
ts-sdk square 184s (n=5) 189s (n=5) 152s -32s (-17.4%)

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-13T04:34:37Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-13 22:03 UTC

@github-actions

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-13T04:34:37Z).

Fixture main PR Delta
docs 262.9s (n=5) 260.4s (35 versions) -2.5s (-1.0%)

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-13T04:34:37Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-13 22:03 UTC

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