Skip to content

fix: preserve OpenAPI 3.1 null branches, schema refs after dedup, and Crystal webhooks - #24499

Open
n-rodriguez wants to merge 4 commits into
OpenAPITools:masterfrom
n-rodriguez:fix/openapi-31-nullable-webhooks-ref-rewriting
Open

fix: preserve OpenAPI 3.1 null branches, schema refs after dedup, and Crystal webhooks#24499
n-rodriguez wants to merge 4 commits into
OpenAPITools:masterfrom
n-rodriguez:fix/openapi-31-nullable-webhooks-ref-rewriting

Conversation

@n-rodriguez

@n-rodriguez n-rodriguez commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 InlineModelResolver and affect every generator.

Each commit is independent and carries its own regression test.

1. fix(crystal): keep the null branch of 3.1 nullable compositions

JSON 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 sets nullable, so isNullable reaches the property. No Crystal template read it: the ? suffix was tied to optionalVars alone, so a property that was both required and nullable was emitted as a non-nullable T and raised at runtime on a response carrying null.

required and nullable are now independent axes. A required nullable property stays a positional constructor argument and gains ?.

Side effect handled in the same commit: JSON::Serializable accepts 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 dedup

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.

On the spec above: 104 Failed to lookup the schema errors 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 carrier

Same root cause, wider. The rewrite was incomplete in two ways:

  • rewriteSchemaRefs() walked only properties, items, allOf/anyOf/oneOf, not and additionalProperties. Ten other JSON Schema 2020-12 sub-schema containers were never visited: patternProperties, dependentSchemas, prefixItems, if/then/else, contains, propertyNames, unevaluatedItems, unevaluatedProperties, additionalItems, contentSchema.
  • 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, 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 webhooks

Webhooks are routed through postProcessWebhooksWithModels, which CrystalClientCodegen did not override, so none of the vendor extensions its api template reads were computed. 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, and no require of 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:

  • generated spec suite: 1153 examples, 0 failures
  • every one of the 154 union dispatch paths forced through the type-checker (they are never reached by generated code, so the compiler ignores them by default)
  • every operation body type-checked
  • 580 discriminator variants fed a valid payload at runtime, asserting each returns the mapped class: 580/580 conform
  • deserialisation of null in each nullable field of the primary response type

PR checklist

  • Read the contribution guidelines.
  • Run the following to build the project and update samples:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    Commit all changed files.
  • If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.

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.

  • Bug Fixes
    • Crystal: treat required + nullable as T? in properties, constructors, and validations; tests only enforce missing-field errors when a non-nullable required field exists.
    • Crystal: process 3.1 webhooks with the same vendor extensions as paths; webhook API classes are named, parameters are typed and qualified, specs require the correct helper, and the shard entrypoint requires webhook APIs.
    • InlineModelResolver: rewrite discriminator.mapping values on dedup for both full $ref and bare-name forms, preserving the original form.
    • InlineModelResolver: rewrite $refs in all schema-bearing carriers and containers (e.g., patternProperties, dependentSchemas, prefixItems, if/then/else, contentSchema; components responses/parameters/requestBodies/headers/callbacks/pathItems; path response headers and encoding headers).

Written for commit 095f02b. Summary will update on new commits.

Review in cubic

n-rodriguez and others added 4 commits July 27, 2026 07:54
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>

@cubic-dev-ai cubic-dev-ai 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.

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}}

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.

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>

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.

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

@wing328

wing328 commented Jul 28, 2026

Copy link
Copy Markdown
Member

thanks for the PR

cc @OpenAPITools/generator-core-team

@wing328 wing328 added Issue: Bug Client: Crystal Inline Schema Handling Schema contains a complex schema in items/additionalProperties/allOf/oneOf/anyOf labels Jul 28, 2026
@wing328 wing328 added this to the 7.25.0 milestone Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Client: Crystal Inline Schema Handling Schema contains a complex schema in items/additionalProperties/allOf/oneOf/anyOf Issue: Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants