diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index cab1d34..6ccf377 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -242,6 +242,8 @@ 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. +- `$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. - `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2a0fe60..742c404 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -127,7 +127,11 @@ const LOOSE = new WeakSet() // Converted schemas with an `anyOf` of two branches, for `mergeNullable`. It // runs once the whole input is converted, since a cycle can meet a branch // before its conversion is done. -const twoBranchAnyOfs: Record[] = [] +const twoBranchAnyOfs: [schema: Record, input: object][] = [] + +// Input schemas whose `anyOf` a `$ref` kept as written points into. They keep +// it, so the `$ref` still resolves. +const anyOfTargets = 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. @@ -275,6 +279,12 @@ 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)) { + const holder = resolvePointer(ref.slice(0, at), ctx) + if (isRecord(holder)) { + anyOfTargets.add(holder) + } + } // 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. const out = { $ref: ref } @@ -661,7 +671,7 @@ function finishSchema( } } if (Array.isArray(out.anyOf) && out.anyOf.length === 2) { - twoBranchAnyOfs.push(out) + twoBranchAnyOfs.push([out, schema]) } if (loose) { LOOSE.add(out) @@ -788,10 +798,14 @@ function convertDocument(value: unknown, ctx: Context): unknown { function downgradeV30(root: unknown, convert: Convert): unknown { twoBranchAnyOfs.length = 0 + anyOfTargets.clear() const out = downgrade(root, convert) - for (const schema of twoBranchAnyOfs.splice(0)) { - mergeNullable(schema, root) + for (const [schema, input] of twoBranchAnyOfs.splice(0)) { + if (!anyOfTargets.has(input)) { + mergeNullable(schema, root) + } } + anyOfTargets.clear() return out } 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 c895db6..d449cc3 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -844,6 +844,40 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ properties: { a: { type: 'string', nullable: true } } }) }) + it('keeps an anyOf of a branch and null that a $ref points into, so the $ref still resolves', () => { + const address = { type: 'object', properties: { city: { type: 'string' } } } as const + expect( + downgradeSchemaV31ToV30({ + type: 'object', + properties: { + home: { anyOf: [address, { type: 'null' }] }, + work: { anyOf: [{ $ref: '#/properties/home/anyOf/0' }, { type: 'null' }] }, + }, + }), + ).toEqual({ + type: 'object', + properties: { + home: { anyOf: [address, { type: 'string', nullable: true, enum: [null] }] }, + work: { + oneOf: [ + { $ref: '#/properties/home/anyOf/0' }, + { type: 'string', nullable: true, enum: [null] }, + ], + }, + }, + }) + }) + + it('keeps an anyOf of a branch and null that a $ref inside it points into', () => { + const node = { + type: 'object', + properties: { children: { type: 'array', items: { $ref: '#/anyOf/0' } } }, + } as const + const out = downgradeSchemaV31ToV30({ anyOf: [node, { type: 'null' }] }) + expect(out).toEqual({ anyOf: [node, { type: 'string', nullable: true, enum: [null] }] }) + expect(() => JSON.stringify(out)).not.toThrow() + }) + it('leaves a $ref into $defs that does not resolve as written', () => { expect(downgradeSchemaV31ToV30({ $ref: '#/$defs/Missing' })).toEqual({ $ref: '#/$defs/Missing',