Skip to content

fix(cli): emit explicit null for required nullable properties in generated examples - #17565

Open
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1787941427-required-nullable-examples
Open

fix(cli): emit explicit null for required nullable properties in generated examples#17565
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1787941427-required-nullable-examples

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

Description

Linear ticket: Refs

Auto-generated (Fern-synthesized) examples omitted properties that are required and nullable, so generated wire tests could send a body missing a key the serializer requires (JsonError: Missing required key "webhook" once noSerdeLayer: false). Root cause: both example generators conflated "nullable" with "optional".

  1. OpenAPI IR parser — ExampleTypeFactory.getAllRequiredProperties skipped any property whose resolved schema was nullable, so it was never treated as required:
if (resolvedSchema.type !== "optional" && resolvedSchema.type !== "nullable") {
    requiredProperties[property.key] = property.schema;
}

Optional properties are always wrapped in optional (an optional nullable property is optional<nullable<T>>), so excluding nullable was only ever wrong. Now only optional is excluded.

  1. Shared IR example generation — a nullable property with no value produced jsonExample: undefined, which disappears from the serialized JSON example. The nullable branch of generateContainerExample now emits jsonExample: null under skipOptionalProperties, so the key is present with an explicit null.

Scoped to properties: generateEmptyContainerExample (the failure fallback in generateTypeReferenceExample and the depth-limit path) still returns jsonExample: undefined, and generateEndpointExample's reference-request-body case keeps a whole nullable<T> request body without a jsonExample, so generators that key off "no body" (e.g. Java's respect-optional-request-body) keep omitting the body instead of sending a literal null. The IR snapshots for reference request bodies are byte-identical to main.

Optional-property omission is unchanged: optional<nullable<T>> is still omitted when optionals are skipped.

Response examples used the same code paths and are covered by the new tests.

Secondary note (not addressed here): the failing generated wire test contradicts the generated types but still ran, because vitest transpiles wire tests with esbuild and does not type-check them. Worth considering whether generated tests should be type-checked in CI.

Changes Made

  • ExampleTypeFactory: nullable no longer implies optional when computing required properties.
  • generateContainerExample: a nullable property serializes to an explicit null instead of being dropped; the empty-container fallback is unchanged.
  • generateEndpointExample: a whole nullable reference request body stays omitted.
  • Extended the required-nullable test definition (Fern + OpenAPI) with a required nullable request property; regenerated IR snapshots and the ts-sdk, python-sdk and go-sdk required-nullable seed output.
  • Added a CLI changelog entry.
  • Updated README.md generator (if applicable)

Before (IR for a required nullable webhook):

{ "processor_token": "processor_token" }

After:

{ "processor_token": "processor_token", "webhook": null }

Testing

  • Unit tests added/updated — ExampleTypeFactory.test.ts (request emits null, optional nullable still omitted, response includes the property) and generateTypeReferenceExample.test.ts.
  • Manual testing completed — minimal OpenAPI repro through the built CLI (fern ir) before/after; seed tests pass for ts-sdk, python-sdk and go-sdk on required-nullable, nullable-request-body, nullable-optional and nullable, with an empty generated-source diff for every fixture except required-nullable; pnpm compile, biome lint/format, and the ir-generator-tests / ir-migrations / v3-importer / openapi-ir-to-fern suites pass. Java seed could not be run locally (Maven Central returns HTTP 429 for the generator's gradle build).

Link to Devin session: https://app.devin.ai/sessions/ec7249febead4032bda20ab29448797a
Open in Devin Desktop: https://app.devin.ai/desktop/session/ec7249febead4032bda20ab29448797a?variant=devin


Devin Review

…rated examples

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.

AI Review Summary

The fix is small and correct in spirit: nullable is no longer conflated with optional in the OpenAPI example factory, and empty nullable containers now serialize as explicit null. Snapshots line up with the described behavior. One behavioral concern worth calling out: jsonExample: null for nullable containers now applies to all empty nullable containers, including nullable request bodies (see nullable-request-body.json / java-nullable-named-request-types.json), which changes generated wire tests from "no body" to "body is literal null" — confirm that's intended for all generators, not just TS.

  • 🟡 1 warning(s)

To request another review, comment /ai-review on this pull request.

Comment on lines +564 to +565
},
"jsonExample": null

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 the case I'd double-check: the change to generateEmptyContainerExample also affects whole nullable request bodies, not just properties. Previously the example body had no jsonExample, now it's literal null, which for some generators means sending null as the HTTP body rather than omitting it. Worth verifying seed output for a non-TS generator (java/python) before merging, since only ts-sdk/required-nullable was regenerated per the description.

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.

Investigated — you were right that whole nullable request bodies were affected, though the null came from a different caller than the review summary assumed. Fixed in 9f6c340 by scoping the change; the request-body snapshots are now identical to main.

Where it actually came from. generateEmptyContainerExample has two callers: the nullable case of generateContainerExample under skipOptionalProperties, and the failure fallback in generateTypeReferenceExample. The jsonExample: null on /optional-request-body/path_param came from the first one: generateEndpointExample's case "reference" calls generateTypeReferenceExample with the body type itself (nullable<PlainObject>, currentDepth: 0, skipOptionalProperties: skipOptionalRequestProperties), so a top-level nullable body hit exactly the branch I had changed. Reverting only the fallback left the snapshot at jsonExample: null — that's how I confirmed the attribution.

What changed:

  • generateEmptyContainerExample's nullable case is back to jsonExample: undefined (no change vs. main for the failure fallback or the depth-limit path).
  • The explicit null now lives inline in the nullable branch of generateContainerExample, i.e. only on the property path.
  • generateEndpointExample's case "reference" short-circuits a whole nullable request body under skipOptionalRequestProperties back to the previous "no jsonExample" example, so a nullable<T> body stays omitted rather than becoming a literal null body.

Evidence: every request-body example reverted. After regenerating with vitest -u, these files are byte-identical to main again:

--- a/.../ir-generator-tests/.../test-definitions/nullable-request-body.json
+++ b/.../ir-generator-tests/.../test-definitions/nullable-request-body.json
@@ -561,8 +561,7 @@
                                          "inline": null
                                      }
                                  }
-                                    },
-                                    "jsonExample": null
+                                    }
                              },

…and the same removal in java-nullable-named-request-types.json, v3-sdks/primitive-array.json and v3-sdks/primitive-array-complex.json (all three of those were "request": { "jsonExample": null, "shape": { "container": ... } }, i.e. reference bodies). Full check:

$ git diff --merge-base origin/main -- .../ir-generator-tests .../v3-importer-tests | grep -B30 '^+.*"jsonExample": null' | grep -c '"type": "reference"'
0

Every remaining added jsonExample: null in this PR is an inlined-request-body property or an object property (content-type.json: "jsonExample": { "requiredField": "requiredField", "nullableString": null }, nullable-optional.json, required-nullable.json), which is the intended fix.

Cross-language seed evidence. Regenerated python-sdk and go-sdk for nullable-request-body, nullable-optional and nullable, plus ts-sdk/nullable-request-body: the generated-source diff is empty for all of them (the only churn was .fern/metadata.json invokedBy: ci → manual and poetry.lock ordering from running seed locally, which is not committed). So no generator's body handling changes — including the Java respect-optional-request-body path you cited, which keys off exactly this "no body" example.

seed/python-sdk/required-nullable and seed/go-sdk/required-nullable were stale (that fixture gained a required-nullable property in this PR), so they are regenerated and committed here; their only diffs are the intended new required_nullable_text property in snippets/reference/types.

Java seed could not be regenerated on this machine: ./gradlew can't resolve its build plugins because Maven Central answers 429 Too Many Requests (Could not GET '.../guava-32.0.1-jre.pom'. Received status code 429), so fernapi/fern-java-sdk:local never builds. CI's java seed job covers it, and since the java nullable IR snapshots are now identical to main, the Java generator sees no input change.

ts-sdk/required-nullable's wire test still asserts the original fix (required_nullable_text present in the request body), so the property-level behavior this PR is about is unchanged.

@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: 1 flag

Not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

devin-ai-integration Bot and others added 2 commits August 28, 2026 18:33
… examples

Co-Authored-By: bot_apk <apk@cognition.ai>
@github-actions

github-actions Bot commented Aug 28, 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-31T04:07:00Z).

Fixture main PR Delta
docs 261.5s (n=5) 232.5s (35 versions) -29.0s (-11.1%)

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-31T04:07:00Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-31 16:44 UTC

@github-actions

github-actions Bot commented Aug 28, 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-31T04:07:00Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 84s (n=5) 116s (n=5) 78s -6s (-7.1%)
go-sdk square 120s (n=5) 291s (n=5) 148s +28s (+23.3%)
java-sdk square 238s (n=5) 315s (n=5) 213s -25s (-10.5%)
php-sdk square 74s (n=5) N/A 62s -12s (-16.2%)
python-sdk square 138s (n=5) 241s (n=5) 130s -8s (-5.8%)
ruby-sdk-v2 square 90s (n=5) 136s (n=5) 106s +16s (+17.8%)
rust-sdk square 232s (n=5) 221s (n=5) 168s -64s (-27.6%)
swift-sdk square 76s (n=5) 464s (n=5) 76s +0s (+0.0%)
ts-sdk square 161s (n=5) 178s (n=5) 122s -39s (-24.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-31T04:07:00Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-31 16:45 UTC

…xamples

Scopes the explicit null to nullable properties; a reference request body typed nullable<T> keeps its previous behavior of having no jsonExample.

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Review — no breaking changes found, with one thing worth confirming.

The semantic change is real but corrective: required nullable<T> properties were previously treated as optional in autogenerated examples and dropped entirely, producing examples/wire tests that omitted a key the serialization layer requires.

  • ExampleTypeFactory.ts: excluding only optional (not nullable) from required-property collection is right — nullability is about the value, requiredness about presence.
  • generateContainerExample.ts: under skipOptionalProperties, nullable now yields ExampleContainer.nullable({ nullable: undefined }) with jsonExample: null. That is the correct IR encoding for "present with value null" (as opposed to optional with optional: undefined, which means absent), so shape and jsonExample stay consistent for consumers that read either.
  • generateEndpointExample.ts: keeping a whole nullable request body omitted rather than sending literal null is the right call and avoids an actual behavior break for existing endpoints.
  • Optional-nullable stays omitted; changelog present under packages/cli/cli/changes/unreleased/.

Blast radius note (not a blocker): skipOptionalRequestProperties defaults to true in injectAutogeneratedExamples unless includeOptionalRequestPropertyExamples is set, so every customer with a required nullable field will see new "field": null keys in autogenerated docs examples and regenerated wire tests. Seed coverage for the new fixture goes through the non-skip path (the field gets a real value), so the explicit-null path is only exercised by the unit tests and IR snapshots (nullable-optional.json, content-type.json, inferred-auth-implicit.json, which do show "...": null). Worth a sanity check that at least one language's generated wire test built from a skip-optional example (arg rendered from the shape, expected body from jsonExample) still round-trips — i.e. the SDK actually serializes the explicit null rather than dropping it. The Go output already looks correct here: the additive field plus SetRequiredNullableText sets the explicit-field bitmask.

Generated Go/Python/TS changes are additive. Approving.

Written by Devin

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