Skip to content

feat(go): respect an omittable request body behind respectOptionalRequestBody - #17378

Open
devin-ai-integration[bot] wants to merge 2 commits into
feat/ir-request-body-requiredfrom
devin/1786483073-go-optional-request-body
Open

feat(go): respect an omittable request body behind respectOptionalRequestBody#17378
devin-ai-integration[bot] wants to merge 2 commits into
feat/ir-request-body-requiredfrom
devin/1786483073-go-optional-request-body

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

Description

Refs #17360

Brings the Go SDK generator to parity with Java (#17372), Python (#17370), TypeScript (#17368), and C# (#17373) on IR request-body omittability, stacked on feat/ir-request-body-required.

New Go config flag respectOptionalRequestBody (camelCase, matching the rest of baseGoCustomConfigSchema). Off by default. When it is on and the IR says required: false on a referenced request body, a call that supplies no body sends neither a body nor a Content-Type header — including the wrapper-present/Body-nil case, which is exactly the shape that sent the JSON literal null to Payabli's refund endpoint.

Two halves, because the header half is what Python initially missed:

  1. Body — the request wrapper marshals to its body alone, so passing a wrapper with a nil Body straight to the caller marshals as null. raw_client.go now unpacks it first:
var requestBody interface{}
if request != nil && request.Body != nil {
    requestBody = request
}
// ... Request: requestBody, BodyIsOptional: true
  1. Content-Type — the caller sets the header unconditionally while building the request, so it is dropped again once the built request turns out to have no body:
if params.BodyIsOptional && req.Body == nil {
    req.Header.Del(contentTypeHeader)
}

Both caller pieces (the CallParams.BodyIsOptional field and the check) come from placeholders in internal/caller.go_ that collapse to nothing when the flag is off, so an SDK that has not opted in gets byte-identical output — regenerating exhaustive, optional, file-upload, and pagination produced no diff.

Only a referenced body whose Go type can be compared to nil is affected (mayOmitRequestBody): an absent IR required still means required, and a body that cannot express its own absence keeps being sent.

Changes Made

  • respectOptionalRequestBody added to baseGoCustomConfigSchema
  • mayOmitRequestBody — the single predicate for "the caller may leave this body out" (opt-in + requestBody.type === "reference" + required === false + nilable Go type), consumed by both the wrapper and the caller
  • WrappedEndpointRequest unpacks the wrapper's body into a local so a nil Body becomes no body at all; Caller passes BodyIsOptional: true
  • internal/caller.go_ + GoProject — flag-gated CallParams.BodyIsOptional and the Content-Type removal
  • Dynamic snippets read bodyRequired and pass nil for an omitted body (go.TypeInstantiation.nop(), so no dangling argument delimiter) — same shape as the TS/C# implementations
  • @fern-fern/ir-sdk67.21.0 (go-v2 base/model/sdk) and @fern-api/dynamic-ir-sdk67.21.0 (go-v2 dynamic-snippets); required/bodyRequired only exist there. Both are unpublished until feat(ir): model request-body omittability separately from its type #17360 merges, so install/compile is red until then and pnpm-lock.yaml cannot be updated yet.
  • New go-optional-request-body fixture: a $ref'd WateringRequest shared by four endpoints (optional with a path param, optional bare body, required baseline, optional alongside a header). Shared so the importer keeps it a reference — a single-use $ref gets inlined and drops required, which would make the flag silently inert. fern ir on the fixture shows "type": "reference", "required": false for the three optional endpoints and no required for prunePlant
  • Changelog under generators/go/sdk/changes/unreleased/

Testing

  • Unit tests added/updated — generators/go-v2/dynamic-snippets/src/__test__/OptionalRequestBody.test.ts (106 dynamic-snippet tests pass, snapshots unchanged)
  • Manual testing completed — wire behaviour of the generated SDK against a local httptest server echoing back Content-Type/body, both fixture variants:
==== respect-optional-request-body
wrapper, Body nil                Content-Type=<absent>            Content-Length=0   body=""
wrapper, Body set                Content-Type=[application/json]  Content-Length=18  body="{\"milliliters\":60}"
bare body, nil                   Content-Type=<absent>            Content-Length=0   body=""
bare body, set                   Content-Type=[application/json]  Content-Length=18  body="{\"milliliters\":60}"
required body, set               Content-Type=[application/json]  Content-Length=18  body="{\"milliliters\":60}"
required body, Body nil          Content-Type=[application/json]  Content-Length=4   body="null"
wrapper with header, Body nil    Content-Type=<absent>            Content-Length=0   body=""

==== no-custom-config (flag off)
wrapper, Body nil                Content-Type=[application/json]  Content-Length=4   body="null"
wrapper, Body set                Content-Type=[application/json]  Content-Length=18  body="{\"milliliters\":60}"
bare body, nil                   Content-Type=[application/json]  Content-Length=0   body=""
bare body, set                   Content-Type=[application/json]  Content-Length=18  body="{\"milliliters\":60}"
required body, set               Content-Type=[application/json]  Content-Length=18  body="{\"milliliters\":60}"
required body, Body nil          Content-Type=[application/json]  Content-Length=4   body="null"
wrapper with header, Body nil    Content-Type=[application/json]  Content-Length=4   body="null"

A required body is untouched in both columns, and every explicit body is identical across them.

Also run: pnpm seed test --generator go-sdk --fixture go-optional-request-body (2/2), the four regression fixtures above (7/7, no diff), pnpm turbo run compile --filter @fern-api/go-sdk --filter @fern-api/go-dynamic-snippets, pnpm format.

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


Open in Devin Review

…uestBody

Co-Authored-By: bot_apk <apk@cognition.ai>
@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 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +943 to +958
private callOmitsRequestBody({
request,
snippet
}: {
request: FernIr.dynamic.BodyRequest;
snippet: FernIr.dynamic.EndpointSnippetRequest;
}): boolean {
if (this.context.customConfig?.respectOptionalRequestBody !== true) {
return false;
}
if (request.bodyRequired !== false) {
return false;
}
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.

🟡 Generated code examples can pass "nothing" where the client requires a real value, so the example does not compile

The snippet generator decides to render an omitted body (callOmitsRequestBody at generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts:943-958) without checking that the body's Go type can actually hold "nothing", unlike the client generator which does check, so for such bodies the example passes a value the client method cannot accept.

Impact: Users copying the generated example for such an endpoint get code that fails to build.

Divergence between the snippet predicate and the SDK predicate on nilability

generators/go-v2/sdk/src/utils/mayOmitRequestBody.ts:26 gates the SDK-side behaviour on goTypeMapper.convert(...).isNilable(), so a referenced body whose Go type is a value type (e.g. a string/float64 primitive body, or a named alias, which GoTypeMapper.convertNamed at generators/go-v2/base/src/context/GoTypeMapper.ts:159-171 maps to a non-pointer Type.reference) keeps being sent and the generated parameter stays a value type (ReferencedEndpointRequest.getRequestParameterType).

callOmitsRequestBody only checks respectOptionalRequestBody plus request.bodyRequired === false, so for the same endpoint the snippet takes the nop branch at generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts:242-254 and emits nil as the positional argument — cannot use nil as string value in Go.

The fix is to mirror the nilability check on the snippet side (the dynamic snippet context has its own type mapper), or to restrict omission to body shapes whose Go type is a pointer/slice/map/any.

Prompt for agents
In generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts, callOmitsRequestBody decides to render `nil` for an omitted request body based only on the respectOptionalRequestBody flag and request.bodyRequired === false. The SDK generator's equivalent predicate (generators/go-v2/sdk/src/utils/mayOmitRequestBody.ts) additionally requires the body's Go type to be nilable (pointer/slice/map/any), because a value-typed body parameter (e.g. a primitive string/number body, or a named alias which GoTypeMapper.convertNamed maps to a bare reference rather than a pointer) cannot accept nil. When the two predicates disagree, the generated snippet passes nil to a value-typed parameter and does not compile. Align the snippet-side predicate with the SDK-side one by determining nilability of the body type reference (via the dynamic snippets type mapper / context) before treating the body as omittable.
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.

Good catch — fixed in 3522697. callOmitsRequestBody now mirrors the SDK-side nilability requirement via bodyParameterIsNilable: a typeReference body goes through context.dynamicTypeMapper.convert(...).isNilable() (bytes is always nilable, since it is either []byte or an io.Reader), so a value-typed body such as a bare string keeps rendering a value instead of nil.

Added a test that retypes the fixture's optional bulkRefund body as a STRING and asserts the snippet still emits request := "re_1234".

Co-Authored-By: bot_apk <apk@cognition.ai>
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.

0 participants