diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index ce18c45..f77f47e 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -183,6 +183,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | | [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | | An [`anyOf`][js-any-of] of `{ type: "null" }` and one other branch, as Zod writes a nullable object, array, integer, enum, or `$ref`, 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 already has `nullable` or shares a keyword with the schema around it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. | +| 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, since null then matches one branch only. Otherwise the `oneOf` 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"`. | | 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. | @@ -245,7 +246,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - A `not` or `oneOf` inside a cycle of schemas, over a `$ref` back into that cycle to a schema that lost a restriction, can still reject values the original accepts. - `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. -- An `anyOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union. +- An `anyOf` or `oneOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union. - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, or into a `oneOf` that becomes `anyOf`, are left as written and dangle. - 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. - 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. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index b1a91ef..5cfdffc 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -124,14 +124,15 @@ const IN_PLACE_APPLICATORS = [ // Converted schemas that lost a restriction, directly or in a subschema. const LOOSE = new WeakSet() -// Converted schemas with an `anyOf` of several branches, for `mergeNullable`. -// It runs once the whole input is converted, since a cycle can meet a branch -// before its conversion is done. -const nullableAnyOfs: [schema: Record, input: object][] = [] +// Converted schemas with an `anyOf` of several branches, or a `oneOf` of two, +// for `mergeNullable`. It runs once the whole input is converted, since a +// cycle can meet a branch before its conversion is done. +const nullableUnions: [schema: Record, input: object, key: 'anyOf' | 'oneOf'][] = + [] -// Input schemas whose `anyOf` a `$ref` kept as written points into. They keep -// it, so the `$ref` still resolves. -const anyOfTargets = new Set() +// Input schemas whose `anyOf` or `oneOf` a `$ref` kept as written points +// into. They keep it, so the `$ref` still resolves. +const unionTargets = new Set() // A branch with these stays: null can still fail them beside `nullable: true`, // and a `nullable` already there was written 3.0-style. @@ -295,8 +296,10 @@ function refOr(convert: Convert): Convert { function convertSchemaRef(ref: string, ctx: Context): unknown { if (!isInlined(ref, ctx)) { - for (let at = ref.indexOf('/anyOf/'); at !== -1; at = ref.indexOf('/anyOf/', at + 1)) { - anyOfTargets.add(resolvePointer(ref.slice(0, at), ctx)) + for (const key of ['/anyOf/', '/oneOf/']) { + for (let at = ref.indexOf(key); at !== -1; at = ref.indexOf(key, at + 1)) { + unionTargets.add(resolvePointer(ref.slice(0, at), ctx)) + } } // A `$ref` kept as written loses what its target loses. Converting the // target here reuses the copy the components get, and stops at a cycle. @@ -570,9 +573,11 @@ function rejectsNull(schema: unknown): boolean { // writes it, when the schema around it has none of its keywords. A branch // that stays, such as a `$ref` or an `enum`, makes the pair a `oneOf` when it // rejects null, as null then matches one branch only. NSwag reads that -// `oneOf` as nullable, and the `anyOf` as an empty class. -function mergeNullable(out: Record, root: unknown): void { - const branches = out.anyOf as unknown[] +// `oneOf` as nullable, and the `anyOf` as an empty class. typia and Litestar +// write the pair as a `oneOf`, which means the same when the other branch +// takes `nullable`, and oapi-codegen reads it as a union. +function mergeNullable(out: Record, root: unknown, key: 'anyOf' | 'oneOf'): void { + const branches = out[key] as unknown[] // Effect writes null twice for a nullish value, once for `undefined`. const others = branches.filter((branch) => !isOnlyNullSchema(branch)) const [other] = others @@ -581,12 +586,12 @@ function mergeNullable(out: Record, root: unknown): void { } if ( typeof other.type === 'string' && - !NOT_NULLABLE_KEYWORDS.some((key) => Object.hasOwn(other, key)) && - !Object.keys(other).some((key) => Object.hasOwn(out, key)) + !NOT_NULLABLE_KEYWORDS.some((name) => Object.hasOwn(other, name)) && + !Object.keys(other).some((name) => Object.hasOwn(out, name)) ) { - delete out.anyOf + delete out[key] Object.assign(out, other, { nullable: true }) - } else if (out.oneOf === undefined) { + } else if (key === 'anyOf' && out.oneOf === undefined) { // A `$ref` kept as written rejects null when its target does. const ref = getRef(other) if (rejectsNull(ref === undefined ? other : find(root, ref))) { @@ -742,7 +747,11 @@ function finishSchema( } } if (Array.isArray(out.anyOf) && out.anyOf.length > 1) { - nullableAnyOfs.push([out, schema]) + nullableUnions.push([out, schema, 'anyOf']) + } + // A `oneOf` with null twice rejects null, so only a pair can merge. + if (Array.isArray(out.oneOf) && out.oneOf.length === 2) { + nullableUnions.push([out, schema, 'oneOf']) } if (loose) { LOOSE.add(out) @@ -870,16 +879,16 @@ function convertDocument(value: unknown, ctx: Context): unknown { function downgradeV30(root: unknown, convert: Convert): unknown { try { const out = downgrade(root, convert) - for (const [schema, input] of nullableAnyOfs) { - if (!anyOfTargets.has(input)) { - mergeNullable(schema, root) + for (const [schema, input, key] of nullableUnions) { + if (!unionTargets.has(input)) { + mergeNullable(schema, root, key) } } return out } finally { // Cleared even when conversion throws, so nothing keeps the input alive. - nullableAnyOfs.length = 0 - anyOfTargets.clear() + nullableUnions.length = 0 + unionTargets.clear() } } 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 81a8a7f..a1cde3c 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -563,6 +563,52 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('merges a oneOf of one type and null into that type with nullable, as typia and Litestar write it', () => { + const object: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + } + expect(downgradeSchemaV31ToV30({ oneOf: [{ type: 'null' }, { type: 'string' }] })).toEqual({ + type: 'string', + nullable: true, + }) + expect( + downgradeSchemaV31ToV30({ description: 'A', oneOf: [object, { type: 'null' }] }), + ).toEqual({ description: 'A', ...object, nullable: true }) + const onlyNull = { type: 'string', nullable: true, enum: [null] } + // Null twice matches two branches, so the oneOf rejects it. + expect( + downgradeSchemaV31ToV30({ + oneOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }], + }), + ).toEqual({ oneOf: [{ type: 'string' }, onlyNull, onlyNull] }) + // A branch that takes null too, or that nullable would not let null through, stays. + for (const branch of [ + { type: ['string', 'null'] }, + { type: 'string', enum: ['a'] }, + { $ref: '#/components/schemas/Pet' }, + ] as OpenAPIV3_1.SchemaObject[]) { + expect(downgradeSchemaV31ToV30({ oneOf: [branch, { type: 'null' }] })).toEqual({ + oneOf: [downgradeSchemaV31ToV30(branch), onlyNull], + }) + } + // Kept for a $ref that points into it. + expect( + downgradeSchemaV31ToV30({ + properties: { + a: { oneOf: [{ type: 'null' }, object] }, + b: { $ref: '#/properties/a/oneOf/1' }, + }, + }), + ).toEqual({ + properties: { + a: { oneOf: [onlyNull, object] }, + b: { $ref: '#/properties/a/oneOf/1' }, + }, + }) + }) + it('keeps an anyOf with null whose other branch null would not pass, or shares a key', () => { const onlyNull = { type: 'string', nullable: true, enum: [null] } // Null matches only the null branch here, so it becomes a oneOf.