From 00272bac0bca19e7c364bda42aca086ebc888490 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:51:03 +0000 Subject: [PATCH 1/7] 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 d01f2f42..d771b13e 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 d24752a6..072513e3 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 2440e6a4..a5320ca6 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 1825e6dd..d98006ef 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 2/7] 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 00b7cf5a..44e79bd4 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 93a15f5d..09d79699 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 d5ee2100..cbe20cce 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 3/7] 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 44e79bd4..d3d8a294 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 4/7] 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 d3d8a294..dfadb273 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 5/7] 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 dfadb273..9b261eea 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 6/7] 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 7dc9a658..229ef6b4 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 7/7] 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 229ef6b4..be2ddfb2 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" }`.