From 00272bac0bca19e7c364bda42aca086ebc888490 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:51:03 +0000 Subject: [PATCH 01/15] fix(downgrader): drop a 3.2 discriminator that has a defaultMapping 3.1 has no default for a missing or unmapped discriminating value, so tools following the kept discriminator (Hey API types and Zod) pin the default schema's tag to its name and reject values 3.2 routes to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.2-to-v3.1.ts | 11 ++++++----- .../tests/__snapshots__/corpus.test.ts.snap | 7 ------- packages/downgrader/tests/v3.2-to-v3.1.test.ts | 12 ++++++++++-- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d01f2f4..d771b13 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -131,7 +131,7 @@ Both versions build on JSON Schema 2020-12, so everything not listed here passes | 3.2 | In 3.1 | | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | XML [`nodeType`][3.2-xml-node-type] | `"attribute"` becomes `attribute: true`, and `"element"` on an array becomes `wrapped: true`. Other values are removed. | -| Discriminator [`defaultMapping`][3.2-discriminator-default-mapping] | Removed. | +| Discriminator [`defaultMapping`][3.2-discriminator-default-mapping] | The whole `discriminator` is removed, since 3.1 has no default for a missing or unmapped value. | [3.2-xml-node-type]: https://spec.openapis.org/oas/v3.2.1.html#xml-node-type [3.2-discriminator-default-mapping]: https://spec.openapis.org/oas/v3.2.1.html#discriminator-default-mapping diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index d24752a..072513e 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -47,15 +47,16 @@ const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaTypeEntry) const convertServers = list(convertServer) -const DISCRIMINATOR_FIELDS = defineFields({ - defaultMapping: DROP, -}) - const SCHEMA_FIELDS = defineFields({ ...Object.fromEntries(SUBSCHEMA_KEYWORDS.map((key) => [key, convertSchema])), ...Object.fromEntries(SUBSCHEMA_MAP_KEYWORDS.map((key) => [key, map(convertSchema)])), ...Object.fromEntries(SUBSCHEMA_LIST_KEYWORDS.map((key) => [key, list(convertSchema)])), - discriminator: (item, ctx) => convertObject(item, ctx, DISCRIMINATOR_FIELDS), + // A `defaultMapping` sends a missing or unmapped value to its schema. 3.1 has + // no default, so tools such as Hey API pin a tag on that schema and reject + // those values; without the discriminator every value the union accepts + // stays accepted. + discriminator: (item) => + isRecord(item) && item.defaultMapping !== undefined ? DROP : clone(item), xml: convertXml, }) diff --git a/packages/downgrader/tests/__snapshots__/corpus.test.ts.snap b/packages/downgrader/tests/__snapshots__/corpus.test.ts.snap index 2440e6a..a5320ca 100644 --- a/packages/downgrader/tests/__snapshots__/corpus.test.ts.snap +++ b/packages/downgrader/tests/__snapshots__/corpus.test.ts.snap @@ -58,13 +58,6 @@ exports[`matches the snapshots of the 3.2 mega document > 3.1 1`] = ` "$ref": "#/components/schemas/Foo", }, ], - "discriminator": { - "mapping": { - "foo": "Foo", - }, - "propertyName": "type", - "x-extension": true, - }, "externalDocs": { "description": "More docs!", "url": "https://example.com/elsewhere.html", diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index 1825e6d..d98006e 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -657,12 +657,20 @@ describe('downgradeSchemaV32ToV31', () => { }, ) - it('drops discriminator defaultMapping', () => { + it('drops a discriminator with a defaultMapping and keeps one without', () => { expect( downgradeSchemaV32ToV31({ + oneOf: [{ $ref: 'Cat' }, { $ref: 'Dog' }], discriminator: { propertyName: 'kind', defaultMapping: 'Cat', mapping: { dog: 'Dog' } }, }), - ).toEqual({ discriminator: { propertyName: 'kind', mapping: { dog: 'Dog' } } }) + ).toStrictEqual({ oneOf: [{ $ref: 'Cat' }, { $ref: 'Dog' }] }) + expect( + downgradeSchemaV32ToV31({ + discriminator: { 'propertyName': 'kind', 'mapping': { dog: 'Dog' }, 'x-a': 1 }, + }), + ).toStrictEqual({ + discriminator: { 'propertyName': 'kind', 'mapping': { dog: 'Dog' }, 'x-a': 1 }, + }) }) it('passes everything else through, including properties named like keywords', () => { From b47582bd2382ef3251d7ec55d998007170bd65ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:32:02 +0000 Subject: [PATCH 02/15] fix(downgrader): drop extensions in a discriminator, which 3.0 allows none of 3.1 lets a Discriminator Object carry specification extensions, 3.0 does not, so an `x-` key there made the 3.0 output invalid. Microsoft.OpenApi's 3.0 reader reports it as an error, for example on the `x-discriminator-default` that SwaggerHub's NPP and APDS specs write. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 1 + packages/downgrader/src/v3.1-to-v3.0.ts | 12 ++++++++++++ packages/downgrader/tests/v3.1-to-v3.0.test.ts | 17 +++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 00b7cf5..44e79bd 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -204,6 +204,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. Ones that only identify, such as `$id`, leave it bare. A branch of a `oneOf` or `anyOf` beside a `discriminator` that stays, with only annotations beside its `$ref`, such as the `description` the Anthropic and ElevenLabs APIs write for each, becomes the bare `$ref` instead, since 3.0 maps a discriminator only to the `$ref`s among its branches, not to inline schemas, and oapi-codegen stops on the `allOf` there. So does a `$ref` with only annotations beside it to a schema whose `discriminator` stays, or to one whose `allOf` holds a `$ref` to such a schema, such as the `description` the OpenAI and Adyen APIs write beside a property holding a tagged union, or the `examples` the Anthropic API writes there, also beside a `type` the target holds too, as OpenAI writes for an input item, since 3.0 reads an `allOf` holding that `$ref` as one of the schemas the discriminator picks among. openapi-typescript then leaves out its tag, and Hey API pins the tag to the name of the schema around it when the target has no `oneOf`, as for Adyen's base with a `mapping` or the NPP API's GeoJSON point. In both cases the annotations beside the `$ref` are dropped, so a `readOnly`, `writeOnly`, `default` or `deprecated` there is lost, as for the `readOnly` the Cryptography Provider API writes beside one. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it, or into any `items` or `additionalProperties`, where the conversion can put another schema, is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | A [`discriminator`][3.0-schema-discriminator] over `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union | Removed. A discriminator picks only among named components, not the targets that replace those `$ref`s, and its `mapping` into them would dangle, which validators that follow it, such as openapi-schema-validator, reject. | +| Extensions in a [`discriminator`][3.0-schema-discriminator] | Removed. 3.0 allows none there. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | | [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` becomes `true`, as a schema or `false` there would reject the properties `patternProperties` allowed, and code generators read a missing one as closed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], [`contentSchema`][js-content-schema], [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], [`dependentRequired`][js-dependent-required], [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], [`propertyNames`][js-property-names], and [`unevaluatedItems`][js-unevaluated-items] | Removed. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 93a15f5..09d7969 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -179,6 +179,18 @@ const SCHEMA_FIELDS = defineFields({ additionalProperties: (item, ctx, schema) => schema.patternProperties === undefined ? convertAdditional(item, ctx) : DROP, allOf: list(convertSchema), + // 3.0 allows no extensions in a discriminator. + discriminator: (item) => { + const out = clone(item) + if (isRecord(out)) { + for (const key of Object.keys(out)) { + if (key.startsWith('x-')) { + delete out[key] + } + } + } + return out + }, // Zod writes a file with several media types as an `anyOf` of branches // that hold only `contentMediaType`, an annotation, so it matches every // value. Its branches would each become a binary string in 3.0. diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index d5ee210..cbe20cc 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1765,6 +1765,23 @@ describe('downgradeSchemaV31ToV30', () => { expect(downgradeSchemaV31ToV30({ examples: [] })).toStrictEqual({}) }) + it('drops the extensions of a discriminator, which 3.0 allows none of', () => { + const cat = '#/components/schemas/Cat' + const discriminator = { + 'propertyName': 'kind', + 'mapping': { cat }, + 'x-discriminator-default': 'cat', + } + expect(downgradeSchemaV31ToV30({ oneOf: [{ $ref: cat }], discriminator })).toStrictEqual({ + oneOf: [{ $ref: cat }], + discriminator: { propertyName: 'kind', mapping: { cat } }, + }) + expect(discriminator['x-discriminator-default']).toBe('cat') + expect(downgradeSchemaV31ToV30({ discriminator: 'kind' } as never)).toEqual({ + discriminator: 'kind', + }) + }) + it('turns tuples into arrays whose items match any item schema', () => { const prefixItems = [{ type: 'string' }, { type: 'integer' }] as const expect( From 83019e90baacde4f833ed4d07a58c3a5e7fa1d4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:27:05 +0000 Subject: [PATCH 03/15] docs(downgrader): note datamodel-code-generator makes a one-value enum tag with a default required Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 44e79bd..d3d8a29 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -275,7 +275,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - OpenAPI Generator's Rust client and its Spring server do not compile a required property that is a nullable `format: byte` string, which a nullable `contentEncoding: base64` string becomes, such as Zod's `z.base64().nullable()`. In the Rust client, an optional one can also break the optional `format: byte` properties of the models generated after it. In 7.26, its Rust server (`rust-axum`) does not compile such a property, required or optional, when it also has a `pattern`, as Zod writes one. Its Kotlin client with `serializationLibrary=kotlinx_serialization` on a JVM library reads any `format: byte` string, such as Zod's `z.base64()`, as a `ByteArray`, which kotlinx.serialization reads and writes as an array of numbers, so it rejects the base64 string. OpenAPI Generator reads 3.1 too, which keeps the string. - OpenAPI Generator's PHP client drops the namespace separators from the class of an array item that is an `allOf`, as a `$ref` with siblings becomes, writing `\OpenAPIClientModelUser[]`, so deserializing that array throws. In 7.26, its Rust server (`rust-server`) stops with an error on any array whose items are such an `allOf`, such as a `z.array()` of a Zod schema with its own id and a `.describe()`, so it writes no code. Its PHP client also writes no allowed values for the `enum` of an array item or map value that is `{ type: "null" }` alone, as Zod's `z.null()` writes there, so it rejects such an array or map unless it is empty. OpenAPI Generator reads 3.1 too, which keeps the `$ref` beside its siblings there and lets null through such an item or value. - OpenAPI Generator's Java client with the `native` or `jersey3` library coerces scalars while it reads a `oneOf`, and wants exactly one branch to match, so it rejects a value that two branches take once coerced, such as `1` or `"1"` against the `oneOf` that `string | number` becomes, or `true` against `string | boolean`. It also rejects null in a tuple member that may be null, as in `z.tuple([z.number(), z.string().nullable()])`, since that null sits in a branch of the `anyOf` that `items` becomes. Its Java clients and Spring server, and its C# client with the default `generichost` library, also write an enum that does not compile for `{ type: "null" }` alone, as Zod's `z.null()` writes for a property, an array item or a component, and its Python FastAPI server does for such a component. With the Java `native` or the C# `generichost` library, a union with a `contentEncoding: base64` string branch does not compile either, since they name the member for that branch, now `format: byte`, `getbyte[]` or `Byte[]`. OpenAPI Generator reads 3.1 too, which keeps the `type` list or `anyOf` and `{ type: "null" }`, leaves `prefixItems` untyped, and reads `contentEncoding` as a plain string. -- datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. Even then, it leaves null out of an array item or map value that is a nullable array or record, such as Zod's `z.array(z.array(z.string()).nullable())`. It reads 3.1 too, which keeps the `"null"` type. +- datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. Even then, it leaves null out of an array item or map value that is a nullable array or record, such as Zod's `z.array(z.array(z.string()).nullable())`. It reads 3.1 too, which keeps the `"null"` type. It also makes a discriminator's tag property required when the tag is a one-value `enum` with a `default`, as a `const` with a `default` becomes, such as Pydantic's `Literal["a"] = "a"` in a tagged union. From 3.1's `const` it keeps the `default`. - aiopenapi3 fails to load a 3.0 document that holds the `"null"` branch: it takes `null` out of a nullable `enum` and builds an empty `Literal` from what is left. aiopenapi3 reads 3.1 too, which keeps `{ type: "null" }`. - oapi-codegen 2.8 rejects, and the OpenAPI Generator C# client breaks on, a string enum with a `format` such as `uuid`, which a `const` with a `format` becomes, as ArkType writes for `string.uuid`. oapi-codegen also stops on a `oneOf` that holds the `"null"` branch beside a `discriminator` with a `mapping`, as typia writes a nullable tagged union, since it wants every branch other than `{ type: "null" }` there to be a `$ref`. It reads 3.1 too, which keeps `{ type: "null" }` there. - Validators that coerce a value to each branch's `type`, as Prism, express-openapi-validator and openapi-backend do for parameters, and Fastify by default for bodies too, match a value such as `1` or `"1"` to two branches of the `oneOf` a union of scalar types becomes, and reject it. So a query flag that takes `"true"`, `"false"`, `"0"`, `"1"` or a boolean, as GrowthBook writes, rejects `?flag=true`, since `"true"` matches the string and, coerced, the boolean. Fastify by default also wraps a value in an array, so it rejects a single value, and an array of one item, against the `oneOf` a union of a scalar type and an array becomes, as for `string | string[]`. It also coerces in place, so a branch of a `oneOf` can change the value that later branches and the handler see. A number in Effect's `Schema.Number`, a `oneOf` of a number and a string enum, arrives as a string, and null in a nullable enum, a nullable `$ref` to an enum, or a nullable union of literals becomes a value such as `""`, which the `"null"` branch rejects where 3.1's `{ type: "null" }` turns it back into null. Prism, express-openapi-validator, openapi-backend and fastify-openapi-glue read 3.1 too, which keeps the `type` list or the `anyOf`. From 31b2ec566d10649e33764ae04f5a65984d69280a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:34:20 +0000 Subject: [PATCH 04/15] docs(downgrader): name the datamodel-code-generator flag that keeps the tag default Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d3d8a29..dfadb27 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -275,7 +275,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - OpenAPI Generator's Rust client and its Spring server do not compile a required property that is a nullable `format: byte` string, which a nullable `contentEncoding: base64` string becomes, such as Zod's `z.base64().nullable()`. In the Rust client, an optional one can also break the optional `format: byte` properties of the models generated after it. In 7.26, its Rust server (`rust-axum`) does not compile such a property, required or optional, when it also has a `pattern`, as Zod writes one. Its Kotlin client with `serializationLibrary=kotlinx_serialization` on a JVM library reads any `format: byte` string, such as Zod's `z.base64()`, as a `ByteArray`, which kotlinx.serialization reads and writes as an array of numbers, so it rejects the base64 string. OpenAPI Generator reads 3.1 too, which keeps the string. - OpenAPI Generator's PHP client drops the namespace separators from the class of an array item that is an `allOf`, as a `$ref` with siblings becomes, writing `\OpenAPIClientModelUser[]`, so deserializing that array throws. In 7.26, its Rust server (`rust-server`) stops with an error on any array whose items are such an `allOf`, such as a `z.array()` of a Zod schema with its own id and a `.describe()`, so it writes no code. Its PHP client also writes no allowed values for the `enum` of an array item or map value that is `{ type: "null" }` alone, as Zod's `z.null()` writes there, so it rejects such an array or map unless it is empty. OpenAPI Generator reads 3.1 too, which keeps the `$ref` beside its siblings there and lets null through such an item or value. - OpenAPI Generator's Java client with the `native` or `jersey3` library coerces scalars while it reads a `oneOf`, and wants exactly one branch to match, so it rejects a value that two branches take once coerced, such as `1` or `"1"` against the `oneOf` that `string | number` becomes, or `true` against `string | boolean`. It also rejects null in a tuple member that may be null, as in `z.tuple([z.number(), z.string().nullable()])`, since that null sits in a branch of the `anyOf` that `items` becomes. Its Java clients and Spring server, and its C# client with the default `generichost` library, also write an enum that does not compile for `{ type: "null" }` alone, as Zod's `z.null()` writes for a property, an array item or a component, and its Python FastAPI server does for such a component. With the Java `native` or the C# `generichost` library, a union with a `contentEncoding: base64` string branch does not compile either, since they name the member for that branch, now `format: byte`, `getbyte[]` or `Byte[]`. OpenAPI Generator reads 3.1 too, which keeps the `type` list or `anyOf` and `{ type: "null" }`, leaves `prefixItems` untyped, and reads `contentEncoding` as a plain string. -- datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. Even then, it leaves null out of an array item or map value that is a nullable array or record, such as Zod's `z.array(z.array(z.string()).nullable())`. It reads 3.1 too, which keeps the `"null"` type. It also makes a discriminator's tag property required when the tag is a one-value `enum` with a `default`, as a `const` with a `default` becomes, such as Pydantic's `Literal["a"] = "a"` in a tagged union. From 3.1's `const` it keeps the `default`. +- datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. Even then, it leaves null out of an array item or map value that is a nullable array or record, such as Zod's `z.array(z.array(z.string()).nullable())`. It reads 3.1 too, which keeps the `"null"` type. It also makes a discriminator's tag property required when the tag is a one-value `enum` with a `default`, as a `const` with a `default` becomes, such as Pydantic's `Literal["a"] = "a"` in a tagged union. From 3.1's `const` it keeps the `default`, as it does with `--enum-field-as-literal one`. - aiopenapi3 fails to load a 3.0 document that holds the `"null"` branch: it takes `null` out of a nullable `enum` and builds an empty `Literal` from what is left. aiopenapi3 reads 3.1 too, which keeps `{ type: "null" }`. - oapi-codegen 2.8 rejects, and the OpenAPI Generator C# client breaks on, a string enum with a `format` such as `uuid`, which a `const` with a `format` becomes, as ArkType writes for `string.uuid`. oapi-codegen also stops on a `oneOf` that holds the `"null"` branch beside a `discriminator` with a `mapping`, as typia writes a nullable tagged union, since it wants every branch other than `{ type: "null" }` there to be a `$ref`. It reads 3.1 too, which keeps `{ type: "null" }` there. - Validators that coerce a value to each branch's `type`, as Prism, express-openapi-validator and openapi-backend do for parameters, and Fastify by default for bodies too, match a value such as `1` or `"1"` to two branches of the `oneOf` a union of scalar types becomes, and reject it. So a query flag that takes `"true"`, `"false"`, `"0"`, `"1"` or a boolean, as GrowthBook writes, rejects `?flag=true`, since `"true"` matches the string and, coerced, the boolean. Fastify by default also wraps a value in an array, so it rejects a single value, and an array of one item, against the `oneOf` a union of a scalar type and an array becomes, as for `string | string[]`. It also coerces in place, so a branch of a `oneOf` can change the value that later branches and the handler see. A number in Effect's `Schema.Number`, a `oneOf` of a number and a string enum, arrives as a string, and null in a nullable enum, a nullable `$ref` to an enum, or a nullable union of literals becomes a value such as `""`, which the `"null"` branch rejects where 3.1's `{ type: "null" }` turns it back into null. Prism, express-openapi-validator, openapi-backend and fastify-openapi-glue read 3.1 too, which keeps the `type` list or the `anyOf`. From d5fe0280fe8e8ca389fdba0d96d5a68bde6d0590 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:47:06 +0000 Subject: [PATCH 05/15] docs(downgrader): note Hey API's Zod and Valibot plugins pin a one-value enum tag Hey API pins each discriminator branch to its tag when the tag is a one-value enum, as a const becomes, so its runtime plugins reject a value that leaves out a tag with a default (Pydantic's Literal default). From 3.1's const it uses the branch as written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index dfadb27..9b261ee 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -286,7 +286,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - CreateAPI 0.2, a Swift generator that reads 3.0 only, by default stops with `Enum "…" has no values` on a string `enum` with no string in it, as the `"null"` branch is. So a nullable `$ref`, enum or union, such as Zod's `z.enum(["a", "b"]).nullable()`, and a property of `type: "null"` alone, such as Zod's `z.null()`, fail the whole run, or with `--allow-errors` drop the schema or property that holds them. - clientele 2.3 leaves out `enum` everywhere but in a component of its own, so it reads the `"null"` branch as a nullable string. A nullable `$ref`, to an enum or an object, and a nullable union of types other than strings then also take any string, and a property of `type: "null"` alone, such as Zod's `z.null()`, becomes an optional string. It reads 3.1 too, which keeps `{ type: "null" }`. - guardrail 1.0.0-M1, which types every inline 3.1 schema as untyped JSON, so reads 3.0 in practice, makes each inline string `enum` in a `oneOf`, the `"null"` branch among them, an empty case class in its circe models. That class takes any value and writes it back as `{}`, so a nullable `$ref` or union, such as Zod's `z.union([z.string(), z.number()]).nullable()`, takes any value and writes null back as `{}`. A nullable enum, such as Zod's `z.enum(["a", "b"]).nullable()`, does that for each of its own values too. -- Hey API's Zod and Valibot plugins read the `"null"` branch as any value, so a union with null, such as a nullable `$ref`, accepts every value. Hey API also types an array as its item when the item holds an `enum` beside an `anyOf`, as a `type` list with an `enum` becomes, such as Valibot's `picklist` of strings and numbers, so those plugins then reject the array. Its transformers plugin skips a union with the `"null"` branch, such as a nullable `$ref`, so the dates inside it stay strings, although its types say `Date`. Hey API reads 3.1 too, which keeps the `"null"` type and the `type` list. +- Hey API's Zod and Valibot plugins read the `"null"` branch as any value, so a union with null, such as a nullable `$ref`, accepts every value. Hey API also types an array as its item when the item holds an `enum` beside an `anyOf`, as a `type` list with an `enum` becomes, such as Valibot's `picklist` of strings and numbers, so those plugins then reject the array. Its transformers plugin skips a union with the `"null"` branch, such as a nullable `$ref`, so the dates inside it stay strings, although its types say `Date`. Its Zod and Valibot plugins pin each branch of a `discriminator` to its tag when the tag is a one-value `enum`, as a `const` becomes, so they reject a value that leaves out a tag that has a `default`, such as Pydantic's `Literal["a"] = "a"` in a tagged union. Hey API reads 3.1 too, which keeps the `"null"` type, the `type` list, and the `const` tag, which it leaves as the branch says. - dtsgenerator 3.19, and the `typegen` of openapi-client-axios, which builds on it, put every value of an `enum` beside an `anyOf` into each branch, the shape a `type` list with an `enum` becomes, such as Valibot's `picklist` of strings and numbers. They type `picklist(["a", 1])` as `("a" | "1") | (a | 1)`, whose bare `a` does not compile and leaves the property open to any value. Both read 3.1 too, which keeps the `type` list. - openapi-generator-plus 2.33's TypeScript clients read the `"null"` branch as an enum of the string `"null"`, so a nullable `$ref`, enum or union of scalars, which becomes a `oneOf` with that branch, takes that string in place of null in its type, as for Zod's `z.enum(["a", "b"]).nullable()`. Its parser also rejects `openapi: 3.0.4`, so set `3.0.3`. It reads 3.1 too, which keeps null, but stops on a `const` without a `type`, as Valibot and ArkType write a literal. - openapi-to-rust 0.23, like openapi-generator-plus, reads the `"null"` branch as an enum of the string `"null"`, since it takes `enum: [null]` as null only without a `type`. So a nullable `$ref`, enum or union, such as Zod's `z.enum(["a", "b"]).nullable()`, becomes an `Option` of an untagged union with that enum as one more member, which also takes the string `"null"`, in place of an `Option` of the type itself. It reads 3.1 too, which keeps `{ type: "null" }`. From 2e608378eeb28f1665542faaa2ba9bb39cabee4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:55:25 +0000 Subject: [PATCH 06/15] docs(downgrader): note validators that reject 1.0 for an integer under 3.0 rules Python's openapi-schema-validator and jsonschema-rs with draft 4 rules reject a number with a zero fraction for type integer, as 3.0.3 defined an integer; 3.0.4, which the output names, accepts it as 3.1 does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 7dc9a65..229ef6b 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -308,6 +308,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - progenitor, which reads 3.0 only, makes an `anyOf` with two branches that take strings a struct of optional members flattened into it, which takes only objects. It reads the `"null"` branch's `type: "string"` as such a branch, so a nullable union with a string branch takes only objects where it stays an `anyOf`: where a `$ref` points to it, or where two of its branches share a type. A tuple of two kinds of string also becomes such an `anyOf` in `items`. - Jane's 3.0 generator throws a `TypeError` on an `anyOf`, in a property, with an object branch that requires a property of `type: "null"` alone, such as Zod's `z.null()`, since it writes each value in that property's `enum` as a PHP string, and `null` is not one. Jane reads 3.1 too, with `jane-php/open-api-3-1`, which keeps the `"null"` type. - kin-openapi (and Tyk Gateway's request validation for its OpenAPI APIs, which uses a fork of it), OpenApiSpex, and Ruby's openapi_parser, which committee uses, reject null for an untyped schema such as the `{}` a nullable unknown becomes, although 3.0 lets it accept every value. 3.0 admits null by name only through `nullable`, which Redocly's recommended rules reject without a `type`. +- Python's openapi-schema-validator, which openapi-core uses, and jsonschema-rs with draft 4 rules reject a number with a zero fraction, such as `1.0`, for `type: "integer"`, as 3.0.3 defined an integer. 3.0.4, which the output names, counts it as one, as 3.1 does, and so do their 3.1 rules. - Microcks 1.15 turns `nullable` into a `null` type in its OpenAPI schema tests, but not for `type: "object"` or under `items`, `additionalProperties` or `not`. So a nullable object, such as Zod's `z.object({ a: z.string() }).nullable()`, rejects null there, and so does a nullable array item or map value, such as Zod's `z.array(z.string().nullable())`, or any nullable schema written inline inside one. Microcks reads 3.1 too, which keeps null. - MockServer 9.2 checks the schemas of an OpenAPI document, in the request matchers it builds from it and in its request and response checks, with draft 7 rules that ignore `nullable`. It turns 3.1's `type: [t, "null"]` into `nullable: true` first, so a plain nullable type rejects null in both versions, but 3.1's `{ type: "null" }` becomes an empty schema there, which takes any value, while 3.0's `"null"` branch takes none. So a nullable `$ref`, enum or union, such as Zod's `z.enum(["a", "b"]).nullable()`, rejects null only in 3.0, and a property of `type: "null"` alone, such as Zod's `z.null()`, rejects every value. - Amazon API Gateway's REST APIs, which import 2.0 and 3.0 only, check request bodies against JSON Schema draft 4 models and do not support `nullable`, so with request validation on, `nullable: true` lets no null through there, and the `"null"` branch takes no value at all. A property of `type: "null"` alone, such as Zod's `z.null()`, then fails every request that holds it. From 2a72f890e73c5f310c147e4b2a1e6ea00fdc7300 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:57:06 +0000 Subject: [PATCH 07/15] docs(downgrader): note Hey API drops a bare object anyOf branch beside readOnly Hey API 0.99 drops a `{ type: "object" }` branch with nothing else in it from an `anyOf` in a schema that holds a readOnly or writeOnly property, so its types, Zod and Valibot reject an object for a `type` list with "object" in 3.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 229ef6b..be2ddfb 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -287,7 +287,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - CreateAPI 0.2, a Swift generator that reads 3.0 only, by default stops with `Enum "…" has no values` on a string `enum` with no string in it, as the `"null"` branch is. So a nullable `$ref`, enum or union, such as Zod's `z.enum(["a", "b"]).nullable()`, and a property of `type: "null"` alone, such as Zod's `z.null()`, fail the whole run, or with `--allow-errors` drop the schema or property that holds them. - clientele 2.3 leaves out `enum` everywhere but in a component of its own, so it reads the `"null"` branch as a nullable string. A nullable `$ref`, to an enum or an object, and a nullable union of types other than strings then also take any string, and a property of `type: "null"` alone, such as Zod's `z.null()`, becomes an optional string. It reads 3.1 too, which keeps `{ type: "null" }`. - guardrail 1.0.0-M1, which types every inline 3.1 schema as untyped JSON, so reads 3.0 in practice, makes each inline string `enum` in a `oneOf`, the `"null"` branch among them, an empty case class in its circe models. That class takes any value and writes it back as `{}`, so a nullable `$ref` or union, such as Zod's `z.union([z.string(), z.number()]).nullable()`, takes any value and writes null back as `{}`. A nullable enum, such as Zod's `z.enum(["a", "b"]).nullable()`, does that for each of its own values too. -- Hey API's Zod and Valibot plugins read the `"null"` branch as any value, so a union with null, such as a nullable `$ref`, accepts every value. Hey API also types an array as its item when the item holds an `enum` beside an `anyOf`, as a `type` list with an `enum` becomes, such as Valibot's `picklist` of strings and numbers, so those plugins then reject the array. Its transformers plugin skips a union with the `"null"` branch, such as a nullable `$ref`, so the dates inside it stay strings, although its types say `Date`. Its Zod and Valibot plugins pin each branch of a `discriminator` to its tag when the tag is a one-value `enum`, as a `const` becomes, so they reject a value that leaves out a tag that has a `default`, such as Pydantic's `Literal["a"] = "a"` in a tagged union. Hey API reads 3.1 too, which keeps the `"null"` type, the `type` list, and the `const` tag, which it leaves as the branch says. +- Hey API's Zod and Valibot plugins read the `"null"` branch as any value, so a union with null, such as a nullable `$ref`, accepts every value. Hey API also types an array as its item when the item holds an `enum` beside an `anyOf`, as a `type` list with an `enum` becomes, such as Valibot's `picklist` of strings and numbers, so those plugins then reject the array. Hey API also drops a `{ type: "object" }` branch with nothing else in it from an `anyOf`, as a `type` list with `"object"` becomes, in a schema that holds a `readOnly` or `writeOnly` property, so its types and those plugins then reject an object there, such as Celigo's `encrypted` fields. Its transformers plugin skips a union with the `"null"` branch, such as a nullable `$ref`, so the dates inside it stay strings, although its types say `Date`. Its Zod and Valibot plugins pin each branch of a `discriminator` to its tag when the tag is a one-value `enum`, as a `const` becomes, so they reject a value that leaves out a tag that has a `default`, such as Pydantic's `Literal["a"] = "a"` in a tagged union. Hey API reads 3.1 too, which keeps the `"null"` type, the `type` list, and the `const` tag, which it leaves as the branch says. - dtsgenerator 3.19, and the `typegen` of openapi-client-axios, which builds on it, put every value of an `enum` beside an `anyOf` into each branch, the shape a `type` list with an `enum` becomes, such as Valibot's `picklist` of strings and numbers. They type `picklist(["a", 1])` as `("a" | "1") | (a | 1)`, whose bare `a` does not compile and leaves the property open to any value. Both read 3.1 too, which keeps the `type` list. - openapi-generator-plus 2.33's TypeScript clients read the `"null"` branch as an enum of the string `"null"`, so a nullable `$ref`, enum or union of scalars, which becomes a `oneOf` with that branch, takes that string in place of null in its type, as for Zod's `z.enum(["a", "b"]).nullable()`. Its parser also rejects `openapi: 3.0.4`, so set `3.0.3`. It reads 3.1 too, which keeps null, but stops on a `const` without a `type`, as Valibot and ArkType write a literal. - openapi-to-rust 0.23, like openapi-generator-plus, reads the `"null"` branch as an enum of the string `"null"`, since it takes `enum: [null]` as null only without a `type`. So a nullable `$ref`, enum or union, such as Zod's `z.enum(["a", "b"]).nullable()`, becomes an `Option` of an untagged union with that enum as one more member, which also takes the string `"null"`, in place of an `Option` of the type itself. It reads 3.1 too, which keeps `{ type: "null" }`. From 8ed8ba0ea06a5133ec778a62305bf12081d8307e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:22:24 +0000 Subject: [PATCH 08/15] fix(downgrader): keep a $ref to a component named items or additionalProperties A $ref into `items`, `additionalProperties`, `$defs` or `definitions` is replaced by its target, but the check read the whole pointer, so a component named one of those words, as Celigo names a recursive mapping row `items`, was inlined at every use and its recursion cut to {} one level down. The segment after `#/components//` is a name, so only the part after it is checked now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/src/v3.1-to-v3.0.ts | 15 ++++++++--- .../downgrader/tests/v3.1-to-v3.0.test.ts | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3d83809..3887a3e 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -293,17 +293,24 @@ const DOCUMENT_FIELDS = defineFields({ // or `additionalProperties`, which the conversion can replace with another // schema, is replaced by its target, unless it already dangles. So is one // that percent-encodes a character, which `find` decodes, as it can hide -// those segments, such as `#/%24defs/Pet`. +// those segments, such as `#/%24defs/Pet`. The segment after +// `#/components//` is a name, so a component named `items` is not one. function isInlined(ref: string, ctx: Context): boolean { + let keywords = ref + if (ref.startsWith('#/components/')) { + const name = ref.indexOf('/', 13) + 1 + const end = ref.indexOf('/', name) + keywords = name === 0 || end === -1 ? '' : ref.slice(end) + } return ( (ref.includes('%') || - ref.includes('/$defs/') || - ref.includes('/definitions/') || + keywords.includes('/$defs/') || + keywords.includes('/definitions/') || // The removed parts, spelled out, as a callback for each `$ref` costs // about 3% of the instructions of a conversion. ref.startsWith('#/webhooks/') || ref.startsWith('#/components/pathItems/') || - INTO_REPLACED.test(ref)) && + INTO_REPLACED.test(keywords)) && resolvePointer(ref, ctx) !== undefined ) } diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 5cdd311..6164684 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2932,6 +2932,33 @@ describe('downgradeSchemaV31ToV30', () => { expect(out?.Count).toEqual(ref('Bag/properties/itemsLeft')) }) + it('keeps a $ref to a component named like a keyword it replaces', () => { + // Celigo names a recursive mapping row `items`: replacing the $ref cut + // the recursion to {} one level down. + const ref = (path: string) => ({ $ref: `#/components/schemas/${path}` }) + const schemas = { + items: { type: 'object', properties: { rows: { type: 'array', items: ref('items') } } }, + additionalProperties: { type: 'string' }, + $defs: { type: 'object', properties: { a: { type: 'integer' } } }, + definitions: { type: 'object', properties: { b: { type: 'integer' } } }, + Map: { type: 'object', additionalProperties: ref('additionalProperties') }, + Defs: ref('$defs/properties/a'), + Definitions: ref('definitions/properties/b'), + // A keyword after the name still counts. + Row: ref('items/properties/rows/items'), + } as const + const out = convert({ + components: { schemas, responses: { items: { description: 'Rows' } } }, + paths: { + '/rows': { get: { responses: { 200: { $ref: '#/components/responses/items' } } } }, + }, + }) + expect(out.components?.schemas).toEqual({ ...schemas, Row: ref('items') }) + expect(out.paths?.['/rows']?.get?.responses?.[200]).toEqual({ + $ref: '#/components/responses/items', + }) + }) + it('replaces a percent-encoded $ref with its target', () => { // find decodes it, so it can point into $defs, a removed part, or items // that the conversion replaces, unseen. From ab51d0292c26a9b3333d1d8060cb55d4db5cd74b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 02:16:13 +0000 Subject: [PATCH 09/15] fix(downgrader): read a bare null in a type list as "null" YAML reads `type: [string, null]` with a JSON null, not the string "null". The 3.0 output dropped that entry, so it rejected null; TCGdex writes its nullable fields this way. Count it as "null" when checking a type list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/src/shared.ts | 6 +++- packages/downgrader/src/v3.1-to-v3.0.ts | 6 ++-- .../downgrader/tests/v3.1-to-v3.0.test.ts | 29 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 9325860..8dd2347 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -217,8 +217,12 @@ export function isNotExtension(key: string): boolean { return !key.startsWith('x-') } +// YAML reads `type: [string, null]` with a bare null, which counts as `"null"`. export function hasType(type: unknown, name: string): boolean { - return type === name || (Array.isArray(type) && type.includes(name)) + return ( + type === name || + (Array.isArray(type) && (type.includes(name) || (name === 'null' && type.includes(null)))) + ) } // Reads `$ref` before `isRecord`, as most values hold none. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3887a3e..2dc1c77 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -344,7 +344,9 @@ function keepsDiscriminator(schema: Record, ctx: Context): bool schema.anyOf !== undefined || schema.allOf !== undefined || (typeof schema.$ref !== 'string' && - !(Array.isArray(type) && type.filter((item) => item !== 'null').length > 1))) && + !( + Array.isArray(type) && type.filter((item) => item !== 'null' && item !== null).length > 1 + ))) && ![schema.oneOf, schema.anyOf].some( (branches) => Array.isArray(branches) && @@ -850,7 +852,7 @@ function convertType(out: Record, type: unknown): void { return } const types = (Array.isArray(type) ? type : [type]).filter((item) => typeof item === 'string') - const nullable = types.includes('null') + const nullable = hasType(type, 'null') const rest = types.filter((item) => item !== 'null') if (rest.length === 1) { if (nullable) { diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 6164684..40e7f31 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3946,6 +3946,35 @@ describe('unusual input', () => { expect(downgradeSchemaV31ToV30({ maximum: 5, exclusiveMaximum: 10 })).toEqual({ maximum: 5 }) }) + // YAML reads `type: [string, null]`, as TCGdex writes it, with a bare null. + it('reads a null in a type list as "null"', () => { + expect(downgradeSchemaV31ToV30({ type: ['string', null] } as never)).toEqual({ + type: 'string', + nullable: true, + }) + expect(downgradeSchemaV31ToV30({ type: [null, 'integer', 'string'] } as never)).toEqual({ + oneOf: [ + { type: 'integer' }, + { type: 'string' }, + { type: 'string', nullable: true, enum: [null] }, + ], + }) + expect( + downgradeSchemaV31ToV30({ + type: ['object', null], + discriminator: { propertyName: 'k' }, + } as never), + ).toEqual({ type: 'object', nullable: true, discriminator: { propertyName: 'k' } }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string' }, { type: ['integer', null] }], + } as never), + ).toEqual( + downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }, { type: ['integer', 'null'] }] }), + ) + expect(downgradeSchemaV31ToV30({ type: null } as never)).toEqual({}) + }) + it('keeps own __proto__ keys as keys', () => { const out = downgradeSchemaV31ToV30( JSON.parse( From 8a8755784a273d0e8efd7ec0d172fccad3bbe1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 02:17:04 +0000 Subject: [PATCH 10/15] test(downgrader): cover a type list with both null and "null" Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 40e7f31..8276c94 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3972,6 +3972,10 @@ describe('unusual input', () => { ).toEqual( downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }, { type: ['integer', 'null'] }] }), ) + expect(downgradeSchemaV31ToV30({ type: ['string', 'null', null] } as never)).toEqual({ + type: 'string', + nullable: true, + }) expect(downgradeSchemaV31ToV30({ type: null } as never)).toEqual({}) }) From 46529a0b37e941ad96acc9ea406a305a72d991c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 02:19:42 +0000 Subject: [PATCH 11/15] test(downgrader): pin a lone bare null type list to the "null" output Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 8276c94..113a9cd 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3976,6 +3976,9 @@ describe('unusual input', () => { type: 'string', nullable: true, }) + expect(downgradeSchemaV31ToV30({ type: [null] } as never)).toEqual( + downgradeSchemaV31ToV30({ type: ['null'] }), + ) expect(downgradeSchemaV31ToV30({ type: null } as never)).toEqual({}) }) From 34f539e535f6e874ef6c6fd9b091b2522661c139 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:13:27 +0000 Subject: [PATCH 12/15] fix(downgrader): replace a $ref into a key that becomes an extension NocoDB's 3.1 document groups schemas under one component and refers to them as `#/components/schemas/FieldOptions/SingleLineText`. 3.0 allows no such keys, so they became `x-` extensions and all 35 of those `$ref`s dangled. Replace a `$ref` through such a key, right under a component schema, with its converted target, as for `$defs`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 4 +-- packages/downgrader/src/v3.1-to-v3.0.ts | 12 ++++++- .../downgrader/tests/v3.1-to-v3.0.test.ts | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 8fc99f5..eb72867 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -213,7 +213,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | | An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | -| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | +| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. A `$ref` into one right under a component schema, as NocoDB writes for schemas grouped under one name, is replaced by its converted target. | | [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise, so a `$ref` beside it, as aide writes one, gets no `allOf`. | | A boolean `exclusiveMinimum` or `exclusiveMaximum`, the 3.0 form, as NestJS writes in 3.1 | Kept beside a numeric `minimum` or `maximum`, where it means in 3.0 what its author meant. Removed otherwise. | | [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] | Kept. 3.0 requires such a property in responses or in requests only, where 3.1 requires it everywhere, so the schema counts as having lost a restriction, and the property tells no `oneOf` branch apart. | @@ -266,7 +266,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, into a `not` removed over a schema that lost a restriction, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. - Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. Such a cut `$ref` tells no `oneOf` branch apart, and counts as taking null, wherever its copy is reused. - Each `$ref` into `$defs` repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written. -- Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, and array-form `items` passes through. Convert such schemas to 2020-12 first. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. +- Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, except right under a component schema, and array-form `items` passes through. Convert such schemas to 2020-12 first. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. - `patternProperties` that matches every name, as TypeBox emits for a record, gives `additionalProperties: true`, so its value schema is lost. - A record keyed by a union of literals, which Zod and Valibot write as an `anyOf` of `const` branches in `propertyNames`, loses its key names, so 3.0 allows any key. Key it by an enum instead, such as `z.enum` or `v.picklist`. - A record keyed by an enum repeats its value schema for each name once serialized, so a large enum, or such records nested in each other, make a large document: three levels of 20 names repeat the innermost schema 8,000 times. Register the value schema, as with Zod's `.meta({ id })`, so each name repeats only a `$ref`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2dc1c77..6c8baac 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -297,13 +297,23 @@ const DOCUMENT_FIELDS = defineFields({ // `#/components//` is a name, so a component named `items` is not one. function isInlined(ref: string, ctx: Context): boolean { let keywords = ref + let renamed = false if (ref.startsWith('#/components/')) { const name = ref.indexOf('/', 13) + 1 const end = ref.indexOf('/', name) keywords = name === 0 || end === -1 ? '' : ref.slice(end) + // A key no keyword names becomes an extension, as when NocoDB groups + // schemas under one component. + if (keywords !== '' && ref.startsWith('#/components/schemas/')) { + const next = keywords.indexOf('/', 1) + const key = next === -1 ? keywords.slice(1) : keywords.slice(1, next) + renamed = + !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) && !key.startsWith('x-') + } } return ( - (ref.includes('%') || + (renamed || + ref.includes('%') || keywords.includes('/$defs/') || keywords.includes('/definitions/') || // The removed parts, spelled out, as a callback for each `$ref` costs diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 23e5a29..0c0e3b9 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2959,6 +2959,39 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('replaces a $ref into a key that becomes an extension with its target', () => { + // NocoDB groups schemas under one component, whose keys 3.0 renames. + const ref = (path: string) => ({ $ref: `#/components/schemas/${path}` }) + const out = convert({ + components: { + schemas: { + FieldOptions: { + 'Text': { type: 'object', properties: { type: { const: 'Text' } } }, + 'Number': { type: ['number', 'null'] }, + 'x-note': { type: 'string' }, + 'prefixItems': [{ type: 'string' }], + }, + Field: { oneOf: [ref('FieldOptions/Text'), ref('FieldOptions/Number')] }, + Nested: ref('FieldOptions/Text/properties/type'), + Kept: ref('FieldOptions/x-note'), + Removed: ref('FieldOptions/prefixItems/0'), + }, + }, + }) + expect(out.components?.schemas).toMatchObject({ + FieldOptions: { 'x-Text': {}, 'x-Number': {} }, + Field: { + oneOf: [ + { type: 'object', properties: { type: { type: 'string', enum: ['Text'] } } }, + { type: 'number', nullable: true }, + ], + }, + Nested: { type: 'string', enum: ['Text'] }, + Kept: ref('FieldOptions/x-note'), + Removed: ref('FieldOptions/prefixItems/0'), + }) + }) + it('replaces a percent-encoded $ref with its target', () => { // find decodes it, so it can point into $defs, a removed part, or items // that the conversion replaces, unseen. From 4da192138d1cac61b1f332bf31e428f31437e796 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:26:20 +0000 Subject: [PATCH 13/15] test(downgrader): pin the discriminator drop over grouped-schema branches Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index eb72867..094c8b7 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -203,7 +203,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | An [`anyOf`][js-any-of] with branches that become equal, as oRPC 1.x writes a file with several media types, a string for each, and Effect a nullish union, with `{ type: "null" }` twice | A branch equal to the one before it is removed, as the `anyOf` then matches the same values, and a lone branch left takes the place of the `anyOf` when the schema around it holds only annotations that the branch lacks or holds with the same value, or nothing else beside a `$ref`, so a nullable one then merges as above. NSwag and oapi-codegen read the `anyOf` of equal binary strings as a generated class or a union, not as a file upload. Branches are equal when each keyword holds the same value, or the same object rather than a copy. A `oneOf` keeps them, since it rejects a value that two branches match. | | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. Ones that only identify, such as `$id`, leave it bare. A branch of a `oneOf` or `anyOf` beside a `discriminator` that stays, with only annotations beside its `$ref`, such as the `description` the Anthropic and ElevenLabs APIs write for each, becomes the bare `$ref` instead, since 3.0 maps a discriminator only to the `$ref`s among its branches, not to inline schemas, and oapi-codegen stops on the `allOf` there. So does a `$ref` with only annotations beside it to a schema whose `discriminator` stays, or to one whose `allOf` holds a `$ref` to such a schema, such as the `description` the OpenAI and Adyen APIs write beside a property holding a tagged union, or the `examples` the Anthropic API writes there, also beside a `type` the target holds too, as OpenAI writes for an input item, since 3.0 reads an `allOf` holding that `$ref` as one of the schemas the discriminator picks among. openapi-typescript then leaves out its tag, and Hey API pins the tag to the name of the schema around it when the target has no `oneOf`, as for Adyen's base with a `mapping` or the NPP API's GeoJSON point. In both cases the annotations beside the `$ref` are dropped, so a `readOnly`, `writeOnly`, `default` or `deprecated` there is lost, as for the `readOnly` the Cryptography Provider API writes beside one. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it, or into any `items` or `additionalProperties`, where the conversion can put another schema, is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | -| A [`discriminator`][3.0-schema-discriminator] over `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union | Removed. A discriminator picks only among named components, not the targets that replace those `$ref`s, and its `mapping` into them would dangle, which validators that follow it, such as openapi-schema-validator, reject. | +| A [`discriminator`][3.0-schema-discriminator] over `$ref`s the conversion replaces, such as into `$defs` as Pydantic and msgspec write a tagged union | Removed. A discriminator picks only among named components, not the targets that replace those `$ref`s, and its `mapping` into them would dangle, which validators that follow it, such as openapi-schema-validator, reject. | | Extensions in a [`discriminator`][3.0-schema-discriminator] | Removed. 3.0 allows none there. | | A [`discriminator`][3.0-schema-discriminator] beside a `$ref` or a `type` list, with no `oneOf`, `anyOf`, or `allOf`, as Pydantic writes for a single-member tagged union | Removed. It does nothing there, but 3.0 tools would apply it to the `allOf` or `anyOf` those become. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 0c0e3b9..eae773f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2971,13 +2971,21 @@ describe('downgradeSchemaV31ToV30', () => { 'x-note': { type: 'string' }, 'prefixItems': [{ type: 'string' }], }, - Field: { oneOf: [ref('FieldOptions/Text'), ref('FieldOptions/Number')] }, + Field: { + oneOf: [ref('FieldOptions/Text'), ref('FieldOptions/Number')], + discriminator: { + propertyName: 'type', + mapping: { Text: '#/components/schemas/FieldOptions/Text' }, + }, + }, Nested: ref('FieldOptions/Text/properties/type'), Kept: ref('FieldOptions/x-note'), Removed: ref('FieldOptions/prefixItems/0'), }, }, }) + // The discriminator goes, as its mapping would dangle. + expect(out.components?.schemas?.Field).not.toHaveProperty('discriminator') expect(out.components?.schemas).toMatchObject({ FieldOptions: { 'x-Text': {}, 'x-Number': {} }, Field: { From 2542f8dcc830301bf41a784f1be718b68a1057eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:35:11 +0000 Subject: [PATCH 14/15] fix(downgrader): replace a $ref into a schema root's key that becomes an extension downgradeSchemaV31ToV30 renames a root key that no keyword names, such as SchemaStore-style `CustomTypes`, to `x-CustomTypes`, but left a `$ref` such as `#/CustomTypes/Path` as written, so it pointed at nothing. #374 already replaces such refs under a component schema; the same rule now applies to the root of a converted schema. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 24 ++++++++++++----- .../downgrader/tests/v3.1-to-v3.0.test.ts | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index e206646..5bc2fa1 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -215,7 +215,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | | An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | -| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. A `$ref` into one right under a component schema, as NocoDB writes for schemas grouped under one name, is replaced by its converted target. | +| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. A `$ref` into one right under a component schema or a converted schema's root, as NocoDB writes for schemas grouped under one name, is replaced by its converted target. | | [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise, so a `$ref` beside it, as aide writes one, gets no `allOf`. | | A boolean `exclusiveMinimum` or `exclusiveMaximum`, the 3.0 form, as NestJS writes in 3.1 | Kept beside a numeric `minimum` or `maximum`, where it means in 3.0 what its author meant. Removed otherwise. | | [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] | Kept. 3.0 requires such a property in responses or in requests only, where 3.1 requires it everywhere, so the schema counts as having lost a restriction, and the property tells no `oneOf` branch apart. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 9a88085..9c5a00f 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -141,6 +141,9 @@ const refTargets = new Set() // holds it, also once the target it was cut at is no longer being inlined. const cutHolders = new Set() +// The input of `downgradeSchemaV31ToV30`, whose own keys `$ref`s can point into. +let schemaRoot: unknown + // Pairs of null and a `$ref` that became the `$ref` alone, with their branches. const collapsedPairs: [schema: Record, anyOf: unknown[]][] = [] @@ -300,16 +303,16 @@ function isInlined(ref: string, ctx: Context): boolean { // Extensions pass through unconverted, so a target kept in one, as Stoplight // bundles files into `x-ext`, is converted where a `$ref` uses it. let unconverted = ref.startsWith('#/x-') || ref.startsWith('#/components/x-') - if (ref.startsWith('#/components/')) { + if (ctx.root === schemaRoot) { + // A key of a schema root that no keyword names becomes an extension too. + unconverted ||= ref.startsWith('#/') && becomesExtension(ref.slice(1)) + } else if (ref.startsWith('#/components/')) { const name = ref.indexOf('/', 13) + 1 const end = ref.indexOf('/', name) keywords = name === 0 || end === -1 ? '' : ref.slice(end) - // A key no keyword names becomes an extension, as when NocoDB groups - // schemas under one component. + // As when NocoDB groups schemas under one component. if (keywords !== '' && ref.startsWith('#/components/schemas/')) { - const next = keywords.indexOf('/', 1) - const key = next === -1 ? keywords.slice(1) : keywords.slice(1, next) - unconverted = !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) + unconverted = becomesExtension(keywords) } } return ( @@ -328,6 +331,13 @@ function isInlined(ref: string, ctx: Context): boolean { const INTO_REPLACED = /\/(?:items|additionalProperties)(?:\/|$)/ +/** Whether the first key of `path`, which starts with `/`, is one no keyword names, so a schema renames it to an extension. */ +function becomesExtension(path: string): boolean { + const next = path.indexOf('/', 1) + const key = next === -1 ? path.slice(1) : path.slice(1, next) + return !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) +} + /** A 3.0 Reference Object holds only `$ref`. */ function refOr(convert: Convert): Convert { const self: Convert = (value, ctx) => { @@ -1774,6 +1784,7 @@ function leadsBack(schema: Record, ctx: Context): boolean { function downgradeV30(root: unknown, convert: Convert): unknown { try { const ctx = createContext(root) + schemaRoot = convert === convertSchema ? root : undefined const out = convert(root, ctx) for (const [schema, input, key] of nullableUnions) { if (!unionTargets.has(input) && (key === 'oneOf' || dropEqualBranches(schema))) { @@ -1807,6 +1818,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { cutHolders.clear() collapsedPairs.length = 0 LOOSE.clear() + schemaRoot = undefined } } diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index ddcbc3f..6ba32e0 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2611,6 +2611,33 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ type: 'object', properties: { b: { type: 'string', nullable: true } } }) }) + it('replaces a $ref into a root key that becomes an extension with its target', () => { + // SchemaStore-style schemas group definitions under keys of their own. + expect( + downgradeSchemaV31ToV30({ + type: 'object', + properties: { + image: { $ref: '#/CustomTypes/Path' }, + cat: { $ref: '#/components/schemas/Cat' }, + kept: { $ref: '#/properties/image' }, + self: { $ref: '#' }, + }, + CustomTypes: { Path: { type: ['string', 'null'] } }, + components: { schemas: { Cat: { const: 'cat' } } }, + } as OpenAPIV3_1.SchemaObject), + ).toEqual({ + 'type': 'object', + 'properties': { + image: { type: 'string', nullable: true }, + cat: { type: 'string', enum: ['cat'] }, + kept: { $ref: '#/properties/image' }, + self: { $ref: '#' }, + }, + 'x-CustomTypes': { Path: { type: ['string', 'null'] } }, + 'x-components': { schemas: { Cat: { const: 'cat' } } }, + }) + }) + it('decodes percent-encoding, ~1, and ~0 in a $ref, in that order', () => { expect( downgradeSchemaV31ToV30({ From ffcbb962e3f576d3e459531f66949d3991a8a4ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:41:40 +0000 Subject: [PATCH 15/15] docs(downgrader): name a converted schema's root in the draft-07 limitation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 5bc2fa1..7443a37 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -268,7 +268,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, into a `not` removed over a schema that lost a restriction, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. - Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. Such a cut `$ref` tells no `oneOf` branch apart, and counts as taking null, wherever its copy is reused. - Each `$ref` into `$defs` or an extension repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written. -- Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, except right under a component schema, and array-form `items` passes through. Convert such schemas to 2020-12 first. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. +- Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, except right under a component schema or a converted schema's root, and array-form `items` passes through. Convert such schemas to 2020-12 first. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. - `patternProperties` that matches every name, as TypeBox emits for a record, gives `additionalProperties: true`, so its value schema is lost. - A record keyed by a union of literals, which Zod and Valibot write as an `anyOf` of `const` branches in `propertyNames`, loses its key names, so 3.0 allows any key. Key it by an enum instead, such as `z.enum` or `v.picklist`. - A record keyed by an enum repeats its value schema for each name once serialized, so a large enum, or such records nested in each other, make a large document: three levels of 20 names repeat the innermost schema 8,000 times. Register the value schema, as with Zod's `.meta({ id })`, so each name repeats only a `$ref`.