From cd76c2fa1312bd59782f39351564dc6f3a0213a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:28:28 +0000 Subject: [PATCH] fix(downgrader): keep a $ref beside only annotations bare in a tagged union in 3.0 A `oneOf` or `anyOf` branch that is a `$ref` beside a `description`, `title` or other annotation became `{ description, allOf: [{ $ref }] }` in 3.0, as every `$ref` with siblings does. Beside a `discriminator`, OpenAPI 3.0 maps values only to the `$ref`s among the branches, not to inline schemas, so the discriminator stopped describing the union, and oapi-codegen 2.8.1 stopped with "ambiguous discriminator.mapping" on the Anthropic and ElevenLabs API documents. Such a branch now stays the bare `$ref`, whose siblings 3.0 ignores, when the discriminator stays. Every other `$ref` with siblings keeps the `allOf`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7PJZWFiNWco7tWuntN --- packages/downgrader/README.md | 4 +- packages/downgrader/src/v3.1-to-v3.0.ts | 60 +++++++++++++------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 46 ++++++++++++++ 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index c9f1076..9079a6b 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -193,7 +193,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`, unless it holds a `$ref`, `allOf`, `anyOf`, `oneOf`, `properties`, `items` or `prefixItems`, as a JSON body marked with its media type does. Then the keywords are removed. | | An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | | 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 | 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. | +| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. 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. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it 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. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | @@ -269,7 +269,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - 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`. -- Kiota reads the `"null"` branch's `type: "string"` as a real type, so array items that may be null, and a union of several types with null, come out as strings. It also skips a `oneOf` or `anyOf` branch that is an `allOf` holding a `$ref`, as a `$ref` with siblings becomes, so a nullable `$ref` with a `description`, such as Zod's `.describe()` on a schema with its own id before `.nullable()`, comes out as an enum holding only Kiota's null sentinel, and a union of such branches as `UntypedNode` or as its other branches alone. Kiota reads 3.1 too, which keeps their types and the `$ref` beside its siblings. +- Kiota reads the `"null"` branch's `type: "string"` as a real type, so array items that may be null, and a union of several types with null, come out as strings. It also skips a `oneOf` or `anyOf` branch that is an `allOf` holding a `$ref`, as a `$ref` with siblings becomes, other than one with only annotations beside a `discriminator`, so a nullable `$ref` with a `description`, such as Zod's `.describe()` on a schema with its own id before `.nullable()`, comes out as an enum holding only Kiota's null sentinel, and a union of such branches as `UntypedNode` or as its other branches alone. Kiota reads 3.1 too, which keeps their types and the `$ref` beside its siblings. - Speakeasy, like Kiota, reads the `"null"` branch's `type: "string"` as a string, so a nullable `$ref` or enum also takes any string, and a nullable union of scalars with a string becomes `any`, since it then holds two strings. It also fails on a recursive component, reached from another schema, with a property of `type: "null"` alone, such as Zod's `z.null()`. Speakeasy reads 3.1 too, which keeps their types. - 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. - openapi-codegen 11.1's TypeScript generator types the `null` in the `"null"` branch's `enum` as `any`, so a union with null, such as a nullable `$ref`, becomes `any`. It reads 3.1's `{ type: "null" }`, but types `const` and `type` lists there as `void`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index d89fecb..c05aaaa 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -153,7 +153,8 @@ const ANNOTATION_KEYWORDS = new Set([ 'xml', ]) -const convertAnyOf = list(convertSchema) +const convertUnion = list(convertSchema) +const convertTaggedUnion = list(convertTaggedBranch) const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) const convertReference = refOr(clone) @@ -171,15 +172,15 @@ const SCHEMA_FIELDS = defineFields({ // 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. - anyOf: (item, ctx) => - Array.isArray(item) && item.some(isMediaTypeOnly) ? DROP : convertAnyOf(item, ctx), + anyOf: (item, ctx, schema) => + Array.isArray(item) && item.some(isMediaTypeOnly) ? DROP : convertBranches(item, ctx, schema), // An empty one rejects every value, which `finishSchema` keeps another way. enum: (item) => (Array.isArray(item) && item.length === 0 ? DROP : clone(item)), // `finishSchema` builds it from `prefixItems`. items: (item, ctx, schema) => schema.prefixItems === undefined ? convertSchema(item, ctx) : DROP, not: convertSchema, - oneOf: list(convertSchema), + oneOf: convertBranches, properties: map(convertSchema), // 3.0 requires at least one entry. required: (item) => (Array.isArray(item) && item.length === 0 ? DROP : clone(item)), @@ -309,6 +310,42 @@ function refOr(convert: Convert): Convert { return self } +// A discriminator picks only among named components, not the targets that +// replace `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union. +// Its `mapping` into them would dangle, which validators that follow it reject. +function keepsDiscriminator(schema: Record, ctx: Context): boolean { + return ( + schema.discriminator !== undefined && + ![schema.oneOf, schema.anyOf].some( + (branches) => + Array.isArray(branches) && + branches.some((branch) => { + const ref = getRef(branch) + return ref !== undefined && isInlined(ref, ctx) + }), + ) + ) +} + +// 3.0 maps a discriminator only to the `$ref`s among its branches, not to +// inline schemas, so a branch that is a `$ref` beside only annotations, as +// Anthropic and ElevenLabs describe each one, stays the bare `$ref`, whose +// siblings 3.0 ignores, rather than becoming an `allOf`, which oapi-codegen +// stops on there. +function convertBranches(item: unknown, ctx: Context, schema: Record): unknown { + return keepsDiscriminator(schema, ctx) ? convertTaggedUnion(item, ctx) : convertUnion(item, ctx) +} + +function convertTaggedBranch(value: unknown, ctx: Context): unknown { + const ref = getRef(value) + return ref !== undefined && + Object.keys(value as Record).every( + (key) => key === '$ref' || isAnnotation(key), + ) + ? convertSchemaRef(ref, ctx) + : convertSchema(value, ctx) +} + function convertSchemaRef(ref: string, ctx: Context): unknown { if (!isInlined(ref, ctx)) { for (const key of ['/anyOf/', '/oneOf/']) { @@ -1110,20 +1147,7 @@ function finishSchema( if (typeof schema.$ref === 'string') { out.allOf = [convertSchemaRef(schema.$ref, ctx), ...(Array.isArray(out.allOf) ? out.allOf : [])] } - // A discriminator picks only among named components, not the targets that - // replace `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union. - // Its `mapping` into them would dangle, which validators that follow it reject. - if ( - out.discriminator !== undefined && - [schema.oneOf, schema.anyOf].some( - (branches) => - Array.isArray(branches) && - branches.some((branch) => { - const ref = getRef(branch) - return ref !== undefined && isInlined(ref, ctx) - }), - ) - ) { + if (out.discriminator !== undefined && !keepsDiscriminator(schema, ctx)) { delete out.discriminator } if (schema.const !== 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 7176ea3..059404b 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1787,6 +1787,52 @@ describe('downgradeSchemaV31ToV30', () => { } }) + it('keeps a $ref beside only annotations bare in a union beside a discriminator', () => { + // As Anthropic describes each branch of a tagged union. 3.0 maps a + // discriminator only to the `$ref`s among the branches. + const text = '#/components/schemas/Text' + const image = '#/components/schemas/Image' + const discriminator = { propertyName: 'type', mapping: { text, image } } + for (const key of ['oneOf', 'anyOf'] as const) { + expect( + downgradeSchemaV31ToV30({ + [key]: [ + { + '$ref': text, + 'description': 'Text', + 'title': 'Text', + 'deprecated': true, + 'x-kind': 'a', + }, + { $ref: image, type: 'object' }, + ], + discriminator, + } as OpenAPIV3_1.SchemaObject), + ).toEqual({ + [key]: [{ $ref: text }, { type: 'object', allOf: [{ $ref: image }] }], + discriminator, + }) + } + // Without a discriminator, or beside one 3.0 removes, it is wrapped. + const branches = [{ $ref: text, description: 'Text' }, { $ref: image }] + expect(downgradeSchemaV31ToV30({ oneOf: branches })).toEqual({ + oneOf: [{ description: 'Text', allOf: [{ $ref: text }] }, { $ref: image }], + }) + expect( + downgradeSchemaV31ToV30({ + oneOf: [...branches, { $ref: '#/$defs/Dog' }], + discriminator, + $defs: { Dog: { type: 'object' } }, + } as OpenAPIV3_1.SchemaObject), + ).toEqual({ + oneOf: [ + { description: 'Text', allOf: [{ $ref: text }] }, + { $ref: image }, + { type: 'object' }, + ], + }) + }) + it('reads definitions, the draft-07 name of $defs, as $defs', () => { expect( downgradeSchemaV31ToV30({