fix: preserve OpenAPI 3.1 null branches, schema refs after dedup, and Crystal webhooks - #24499
Conversation
An OpenAPI 3.1 property written as `anyOf: [{type: string}, {type: null}]`
was emitted as a non-nullable Crystal type. When such a property was also
listed in `required`, the generated model was mandatory and non-nullable,
so deserialising a response carrying `null` raised at runtime.
The null branch was not lost in shared code: the normalizer collapses the
composition and sets `nullable`, so `isNullable` reaches the property. No
Crystal template read it — the `?` suffix was tied to optionalVars alone.
- partial_model_generic.mustache: suffix required properties with `?` when
nullable, in both the property declaration and the constructor, so
`required` and `nullable` stay independent axes; pass the nilable flag to
the validates macro when the property is optional or nullable
- CrystalClientCodegen.java: add x-cr-has-required-non-nullable, true when a
model still has a required non-nullable property
- model_test.mustache: gate the required-field enforcement spec on that
extension — JSON::Serializable accepts a missing key for a nilable field,
so a model whose required properties are all nullable parses `{}`
- CrystalClientCodegenTest.java: cover nullable+required, nullable+optional,
the type-as-array form and a multi-member union that must stay a union
- 3_1/crystal/nullable-composition.yaml: fixture for the above
- samples/client/others/crystal-qdrant: regenerate
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deduplicateComponents() removes a structural duplicate of a titled schema and rewrites every $ref to it. A discriminator mapping value is a schema reference too, but it is not stored in a $ref field, so rewriteSchemaRefs() never touched it: the mapping kept naming a schema that had just been removed. No model was generated for that name, and the generated polymorphic dispatch referenced an undefined type. Observed on a public OpenAPI 3.1 spec with 928 schemas: 104 "Failed to lookup the schema" errors covering 38 distinct names, each of them a discriminator target that ended up undefined in the generated client. The client still type-checked, because a language that only analyses called methods never reaches an uncalled union dispatch. - InlineModelResolver.java: add rewriteDiscriminatorMapping(), called for every schema visited by rewriteSchemaRefs(). Handles both forms the spec allows for a mapping value, a full reference and a bare schema name, and preserves the form each entry was written in - InlineModelResolverTest.java: cover both forms, plus a control asserting an unrelated mapping entry is left untouched Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewriting $refs after deduplicateComponents() removes a schema was incomplete in two ways, so a reference to the removed schema could survive and name a component that no longer exists. rewriteSchemaRefs() walked only properties, items, allOf/anyOf/oneOf, not and additionalProperties. Every other JSON Schema 2020-12 sub-schema container was left untouched. The rewrite loop visited components/schemas, paths and webhooks only, and inside a path it skipped response headers and the headers carried by a media type's encoding object. A reusable response, parameter, request body or header was never visited at all. - InlineModelResolver.java: walk patternProperties, dependentSchemas, prefixItems, if/then/else, contains, propertyNames, unevaluatedItems, unevaluatedProperties, additionalItems and contentSchema; extract rewriteContentRefs/rewriteHeaderRefs/rewriteParameterRefs/ rewriteApiResponseRefs so content, headers and encoding headers are covered wherever they appear; extend the rewrite to components/responses, parameters, requestBodies, headers, callbacks and pathItems - InlineModelResolverTest.java: one test per gap, each carrying a control assertion on an already-covered carrier Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenAPI 3.1 declares webhooks in a top-level `webhooks` object, which the generator routes through postProcessWebhooksWithModels. CrystalClientCodegen only overrode postProcessOperationsWithModels, so none of the Crystal vendor extensions the api template reads were computed for a webhook. The result was api files that are not valid Crystal — `class` with no name, `def create(event : )` with no parameter type — plus a spec file whose require was the empty string. The shard entrypoint did not load them either, so they were dead as well as broken. On a public 3.1 spec declaring 43 webhooks that is 43 invalid source files and 43 uncompilable specs. - CrystalClientCodegen.java: extract the per-api-group post-processing into processApiGroup() and call it from both the operations and the webhooks hook, so a webhook group gets its class name, its qualified parameter and return types, its examples and its spec helper path - shard_name.mustache: require the webhook api files from the shard entrypoint, without which their classes are undefined at use site - CrystalClientCodegenTest.java + 3_1/crystal/webhooks.yaml: assert the generated class is named, its body parameter typed and module-qualified, the entrypoint requires it and its spec requires a real path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 12 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="modules/openapi-generator/src/main/resources/crystal/partial_model_generic.mustache">
<violation number="1" location="modules/openapi-generator/src/main/resources/crystal/partial_model_generic.mustache:29">
P2: Required nullable fields can disappear from serialized JSON: the new `T?` declaration allows `nil`, while the existing `emit_null: false` annotation suppresses that field when nil. This produces JSON that violates the OpenAPI `required` contract; required nullable properties need null emission (while optional nullable properties may continue omitting nulls).</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| {{/description}} | ||
| @[JSON::Field(key: "{{{baseName}}}", emit_null: false)] | ||
| property {{{name}}} : {{{dataType}}} | ||
| property {{{name}}} : {{{dataType}}}{{#isNullable}}?{{/isNullable}} |
There was a problem hiding this comment.
P2: Required nullable fields can disappear from serialized JSON: the new T? declaration allows nil, while the existing emit_null: false annotation suppresses that field when nil. This produces JSON that violates the OpenAPI required contract; required nullable properties need null emission (while optional nullable properties may continue omitting nulls).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/crystal/partial_model_generic.mustache, line 29:
<comment>Required nullable fields can disappear from serialized JSON: the new `T?` declaration allows `nil`, while the existing `emit_null: false` annotation suppresses that field when nil. This produces JSON that violates the OpenAPI `required` contract; required nullable properties need null emission (while optional nullable properties may continue omitting nulls).</comment>
<file context>
@@ -26,7 +26,7 @@
{{/description}}
@[JSON::Field(key: "{{{baseName}}}", emit_null: false)]
- property {{{name}}} : {{{dataType}}}
+ property {{{name}}} : {{{dataType}}}{{#isNullable}}?{{/isNullable}}
{{/vendorExtensions.x-cr-inherited}}
</file context>
There was a problem hiding this comment.
This produces JSON that violates the OpenAPI required contract
Ask Anthropic to respect the OpenAPI spec : https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-f89e311096c42998ada5ab5580a2a3b0519265f1c005af625be7a0d11ad49d3c.yml
|
thanks for the PR cc @OpenAPITools/generator-core-team |
Four fixes found while generating a Crystal client from a public OpenAPI 3.1 spec (928 schemas, 43 webhooks). Two are Crystal-specific, two are in
InlineModelResolverand affect every generator.Each commit is independent and carries its own regression test.
1.
fix(crystal): keep the null branch of 3.1 nullable compositionsJSON Schema 2020-12 spells "T or null" as
anyOf: [{type: string}, {type: null}]. The null branch is not lost in shared code — the normalizer collapses the composition and setsnullable, soisNullablereaches the property. No Crystal template read it: the?suffix was tied tooptionalVarsalone, so a property that was bothrequiredand nullable was emitted as a non-nullableTand raised at runtime on a response carryingnull.requiredandnullableare now independent axes. A required nullable property stays a positional constructor argument and gains?.Side effect handled in the same commit:
JSON::Serializableaccepts a missing key for a nilable field, so a model whose required properties are all nullable now parses{}. The generated "required-field enforcement" spec would fail on such a model, so it is emitted only when a required non-nullable property remains.2.
fix(InlineModelResolver): rewrite discriminator mappings on dedupdeduplicateComponents()removes a structural duplicate of a titled schema and rewrites every$refto it. A discriminator mapping value is a schema reference too, but it is not stored in a$reffield, sorewriteSchemaRefs()never touched it — the mapping kept naming a schema that had just been removed. No model was generated for that name, and the generated polymorphic dispatch referenced an undefined type.On the spec above: 104
Failed to lookup the schemaerrors covering 38 distinct names, each an undefined discriminator target in the generated client. The client still type-checked, because a language that only analyses called methods never reaches an uncalled union dispatch — it only fails once user code touches one.Both forms the specification allows for a mapping value are rewritten (full reference and bare schema name), each keeping the form it was written in.
3.
fix(InlineModelResolver): rewrite refs in every schema-bearing carrierSame root cause, wider. The rewrite was incomplete in two ways:
rewriteSchemaRefs()walked onlyproperties,items,allOf/anyOf/oneOf,notandadditionalProperties. Ten other JSON Schema 2020-12 sub-schema containers were never visited:patternProperties,dependentSchemas,prefixItems,if/then/else,contains,propertyNames,unevaluatedItems,unevaluatedProperties,additionalItems,contentSchema.components/schemas,pathsandwebhooksonly, and inside a path it skipped response headers and the headers carried by a media type'sencodingobject. A reusable response, parameter, request body, header, callback or path item was never visited at all.These are demonstrated by test rather than observed in the wild — the spec above only exercised
discriminator.mapping. Of the two groups, the component containers (components/responses,components/parameters) look the likeliest to bite in practice.4.
fix(crystal): generate valid api classes for 3.1 webhooksWebhooks are routed through
postProcessWebhooksWithModels, whichCrystalClientCodegendid not override, so none of the vendor extensions its api template reads were computed. The result was api files that are not valid Crystal —classwith no name,def create(event : )with no parameter type — plus a spec file whoserequirewas the empty string, and norequireof them from the shard entrypoint. On the spec above that is 43 invalid source files and 43 uncompilable specs.The per-api-group post-processing is now shared between the operations and webhooks paths.
Verification
Beyond the unit tests, the resulting client was checked end to end with Crystal 1.20.3, since type-checking alone does not prove any of this — the original nullable bug type-checks cleanly and only fails at runtime:
nullin each nullable field of the primary response typePR checklist
cc @cyangle @wing328 — the last two commits are generator-agnostic (
InlineModelResolver), so they may deserve a wider look than the Crystal ones. Happy to split this into two pull requests if you prefer.Summary by cubic
Fixes OpenAPI 3.1 handling across the generator: preserves nullable branches in Crystal models, rewrites deduplicated schema refs everywhere (including discriminator mappings), and generates valid Crystal API classes for 3.1 webhooks.
T?in properties, constructors, and validations; tests only enforce missing-field errors when a non-nullable required field exists.webhookswith the same vendor extensions aspaths; webhook API classes are named, parameters are typed and qualified, specs require the correct helper, and the shard entrypoint requires webhook APIs.discriminator.mappingvalues on dedup for both full$refand bare-name forms, preserving the original form.$refs in all schema-bearing carriers and containers (e.g.,patternProperties,dependentSchemas,prefixItems,if/then/else,contentSchema; componentsresponses/parameters/requestBodies/headers/callbacks/pathItems; path response headers and encoding headers).Written for commit 095f02b. Summary will update on new commits.