diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d02adb3..e1930a1 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -185,7 +185,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | An [`anyOf`][js-any-of] of `{ type: "null" }` and one other branch, as Zod writes a nullable object, array, integer, enum, unknown, or `$ref`, Zod and Valibot a nullable nullish one with such a pair as the other branch, and Effect a nullish one with `{ type: "null" }` twice | That branch with `nullable: true` in place of the `anyOf` when it has a single `type`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that holds a `type` where the schema around it holds one, or another keyword that schema holds with another value, or holds `properties` or `additionalProperties` where that schema holds the other, since `additionalProperties` reads the `properties` beside it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, alone or with siblings such as the `description` Zod writes beside a described reference, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. A `$ref` to a schema whose `type` already holds `"null"`, as GitHub writes, takes the place of the `anyOf` when nothing else sits beside it, as 3.0 writes a nullable reference. A branch that accepts every value, such as `{}`, which Zod writes for a nullable unknown and Pydantic for `Optional[Any]`, removes the `anyOf`, since the pair then accepts every value too, and code generators read it as a union with a string or as a type they never define. Its annotations, such as the `description` Zod writes there for a described unknown, take its place, unless the schema around it has one of them with another value. A null branch whose other keywords repeat the schema around it, as ArkType writes the description of a described nullable value into every branch, counts as `{ type: "null" }`. | | A [`oneOf`][js-one-of] of `{ type: "null" }` and one other branch, as typia and Litestar write a nullable value | That branch with `nullable: true` in place of the `oneOf`, under the same conditions as the `anyOf` pair above but only with a single null branch and a branch without `nullable`, since null then matches one branch only. Otherwise the `oneOf` stays. | | An [`anyOf`][js-any-of] of scalar types, or of them and `"array"`, as ArkType, Valibot and Pydantic write a union of them and Effect a number, and a [`type`][js-type] list of them, as Zod writes it and ASP.NET Core a number that may come as a string | A `oneOf` in place of the `anyOf` when each branch has a scalar or `"array"` `type` of its own and is not `nullable`, they do not name both `"integer"` and `"number"`, and no `enum` or `oneOf` sits beside them, since no value then matches two branches, with null in it once where Effect writes it twice for a nullish number. Such a `oneOf` with nothing beside it but annotations, as a nullable union of them becomes, joins the branches around it, and its annotations, such as the `description` Zod writes there for a described union, move to the schema around it, unless that schema or another such branch has one of them with another value. NSwag reads that `oneOf` as the first branch, and the OpenAPI Generator TypeScript client as a union, but the `anyOf` as an empty class. A schema a `$ref` points to keeps the `anyOf`, since NSwag gives a property a type it never defines when an `allOf` holds a `$ref` to a `oneOf`, as a `$ref` with siblings becomes. | -| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. A union whose branches all become enums of one `type`, as OpenSearch writes a documented enum, gets that `type` beside it too, since NSwag otherwise reads it as an empty class or as a type it never defines. When those enums hold nothing else, as Zod, Valibot, TypeBox and GitHub write a union of literals, or only keywords the schema around them holds with the same value, as ArkType writes a described one, they become one `enum`, beside any null branch, since code generators read the union as a string or as a union of one-value types. A `oneOf` whose branches share a value stays. | +| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. A union whose branches all become enums of one `type`, as OpenSearch writes a documented enum, gets that `type` beside it too, since NSwag otherwise reads it as an empty class or as a type it never defines. When those enums hold nothing else, as Zod, Valibot, TypeBox and GitHub write a union of literals, or only keywords the schema around them holds with the same value, as ArkType writes a described one, they become one `enum`, beside any null branch, since code generators read the union as a string or as a union of one-value types. Beside branches of other types, as Zod, ArkType and Valibot write a union of string literals and `number`, and utoipa the unit variants of a Rust enum, the enums of each type become one, and a union of scalars then becomes a `oneOf`. A `oneOf` whose branches share a value stays. | | An [`enum`][js-enum] with no `type`, as ArkType writes a union of literals | The `type` its values share, when they share one, since code generators drop or leave untyped a bare `enum`, or read its integers as numbers. | | Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | | [`examples`][js-examples] | The first entry fills `example` when that is missing. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 4c1846a..3574f11 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -748,11 +748,11 @@ function mergeEnums(out: Record, key: 'anyOf' | 'oneOf'): void const branches = out[key] as unknown[] const enums = branches.filter((branch) => !isNullBranch(branch, out)) const type = enumUnionType(enums) - if ( - type === undefined || - (out.type !== undefined && out.type !== type) || - out.enum !== undefined - ) { + if ((out.type !== undefined && out.type !== type) || out.enum !== undefined) { + return + } + if (type === undefined) { + mergeEnumsByType(out, key) return } const values: unknown[] = [] @@ -786,6 +786,49 @@ function mergeEnums(out: Record, key: 'anyOf' | 'oneOf'): void } } +// Zod, ArkType and Valibot write a union of literals of several types, or of +// literals and other types, as `'a' | 'b' | number`, with a branch for each +// literal, and utoipa so writes the unit variants of a Rust enum beside its +// other variants. The enums of each type become one, so `scalarOneOf` can then +// make a `oneOf` of a union of scalars. +function mergeEnumsByType(out: Record, key: 'anyOf' | 'oneOf'): void { + const branches = out[key] as unknown[] + const merged = new Map }>() + const result: unknown[] = [] + for (const branch of branches) { + if ( + !isRecord(branch) || + typeof branch.type !== 'string' || + !Array.isArray(branch.enum) || + branch.nullable === true || + !Object.keys(branch).every( + (name) => name === 'type' || name === 'enum' || repeats(out, branch, name), + ) + ) { + result.push(branch) + continue + } + let group = merged.get(branch.type) + if (group === undefined) { + group = { enum: [], seen: new Set() } + merged.set(branch.type, group) + result.push({ type: branch.type, enum: group.enum }) + } + for (const value of branch.enum) { + const valueKey = jsonKey(value) + if (!group.seen.has(valueKey)) { + group.seen.add(valueKey) + group.enum.push(value) + } else if (key === 'oneOf') { + return + } + } + } + if (result.length < branches.length) { + out[key] = result + } +} + const ONE_OF_TYPES = new Set(['array', 'boolean', 'integer', 'number', 'string']) // Zod writes a union of scalars as a `type` list, which becomes an `anyOf`, 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 c03fc94..c204551 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1171,16 +1171,87 @@ describe('downgradeSchemaV31ToV30', () => { { type: 'string', enum: ['a', 'b'] }, ], }) + // Beside other types, as Zod writes `'a' | 'b' | number`, the enums of each + // type become one, and a union of scalars then becomes a `oneOf`. + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string', const: 'a' }, { type: 'string', const: 'b' }, { type: 'number' }], + }), + ).toEqual({ oneOf: [{ type: 'string', enum: ['a', 'b'] }, { type: 'number' }] }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ anyOf: [{ const: 'a' }, { const: 1 }, { const: 'b' }] }, { type: 'null' }], + }), + ).toEqual({ + oneOf: [ + { type: 'string', enum: ['a', 'b'] }, + { type: 'integer', enum: [1] }, + { type: 'string', nullable: true, enum: [null] }, + ], + }) + // utoipa writes the unit variants of a Rust enum beside its other variants. + expect( + downgradeSchemaV31ToV30({ + oneOf: [ + { type: 'string', enum: ['pending'] }, + { type: 'object', required: ['failed'] }, + { type: 'string', enum: ['done'] }, + ], + }), + ).toEqual({ + oneOf: [ + { type: 'string', enum: ['pending', 'done'] }, + { type: 'object', required: ['failed'] }, + ], + }) + // A branch with another keyword stays, unless the schema around it holds + // that keyword with the same value, as ArkType writes a described union. + expect( + downgradeSchemaV31ToV30({ + description: 'D', + anyOf: [ + { const: 'a', description: 'D' }, + { const: 1 }, + { const: 'b', description: 'D' }, + { const: 'c', title: 'C' }, + ], + }), + ).toEqual({ + description: 'D', + anyOf: [ + { type: 'string', enum: ['a', 'b'] }, + { type: 'integer', enum: [1] }, + { title: 'C', type: 'string', enum: ['c'] }, + ], + }) + expect( + downgradeSchemaV31ToV30({ anyOf: [{ const: 'a' }, { const: 1 }, { const: 'a' }] }), + ).toEqual({ + oneOf: [ + { type: 'string', enum: ['a'] }, + { type: 'integer', enum: [1] }, + ], + }) // A `oneOf` rejects a value two branches share, and an `enum` beside the // union, or another `type`, matches only some of its values. const kept: [OpenAPIV3_1.SchemaObject, string][] = [ [{ oneOf: [{ const: 'a' }, { enum: ['a', 'b'] }] }, 'oneOf'], [{ enum: ['a', 'c'], anyOf: [{ const: 'a' }, { const: 'b' }] }, 'anyOf'], + [{ enum: ['a', 1], anyOf: [{ const: 'a' }, { const: 1 }, { const: 'b' }] }, 'anyOf'], [{ type: 'number', anyOf: [{ const: 1 }, { const: 2 }] }, 'anyOf'], ] for (const [schema, key] of kept) { expect(downgradeSchemaV31ToV30(schema)).toHaveProperty(key) } + expect( + downgradeSchemaV31ToV30({ oneOf: [{ const: 'a' }, { const: 1 }, { const: 'a' }] }), + ).toEqual({ + oneOf: [ + { type: 'string', enum: ['a'] }, + { type: 'integer', enum: [1] }, + { type: 'string', enum: ['a'] }, + ], + }) // A `$ref` into the union still resolves. const doc = convert({ components: {