diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 091a5d4..be27712 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -3,7 +3,7 @@ export const DROP: unique symbol = Symbol('drop') // The document root as a `$ref`, and the base outside any schema with an `$id`. const ROOT = '#' -const PLACEHOLDERS = new WeakSet() +const REPLACED = new Map>() export interface Context { readonly resolve: (ref: string) => unknown @@ -178,10 +178,12 @@ export function hasType(type: unknown, name: string): boolean { return type === name || (Array.isArray(type) && type.includes(name)) } -export function placeholder(): Record { - const out = {} - PLACEHOLDERS.add(out) - return out +// Sets a value that stands in for whatever the source holds under `key` +// instead of converting it. A `$ref` into that source location then dangles, +// so it is inlined rather than kept pointing at the stand-in. +export function replace(target: Record, key: string, value: unknown): void { + REPLACED.set(key, (REPLACED.get(key) ?? new WeakSet()).add(target)) + setOwn(target, key, value) } export function allOfItems(allOf: unknown): unknown[] { @@ -964,12 +966,12 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | to = undefined } from = child(from, token) - to = child(to, token) + to = REPLACED.get(token)?.has(to as object) ? undefined : child(to, token) if (from === undefined) { return false } } - return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) + return to === undefined } export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown { diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index c39b4b4..080a45b 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -27,10 +27,10 @@ import { list, map, mergeRef, - placeholder, rebasedRef, refOr, removedPrefixes, + replace, resourceOf, setOwn, } from './shared' @@ -269,7 +269,7 @@ function convertType(out: Record, type: unknown): boolean { if (rest.length === 1) { out.type = rest[0] if (nullable) { - out.nullable = true + replace(out, 'nullable', true) } } else if (rest.length > 1) { @@ -292,7 +292,7 @@ function convertType(out: Record, type: unknown): boolean { out.enum = [null] } else { - out.not = {} + replace(out, 'not', {}) } return false } @@ -313,7 +313,7 @@ function finishSchema(out: Record, schema: Record, schema: Record { }) }) + // `const`, a null-only `type`, and `"null"` in `type` write new values over + // `enum`, `not`, and `nullable`. A `$ref` to the original keyword must get + // the original schema, not the value that replaced it. + it.each([ + [ + 'a not that a null-only type replaces', + { not: { $ref: '#/properties/a/not' }, properties: { a: { enum: ['x'], not: { type: 'string' }, type: 'null' } } }, + { not: { type: 'string' }, properties: { a: { enum: ['x'], not: {} } } }, + ], + [ + 'an enum that const replaces', + { properties: { t: { const: { type: 'integer' }, enum: [{ type: 'string' }] }, x: { $ref: '#/properties/t/enum/0' } } }, + { properties: { t: { enum: [{ type: 'integer' }] }, x: { type: 'string' } } }, + ], + [ + 'an enum that a null const replaces and a null-only type narrows', + { properties: { t: { const: null, enum: [false], type: 'null' }, x: { $ref: '#/properties/t/enum/0' } } }, + { properties: { t: { enum: [null] }, x: { not: {} } } }, + ], + [ + 'a 3.0 nullable that null in type replaces', + { not: { $ref: '#/properties/a/nullable' }, properties: { a: { nullable: false, type: ['string', 'null'] } } }, + { not: { not: {} }, properties: { a: { nullable: true, type: 'string' } } }, + ], + ])('inlines a $ref to %s, rather than pointing it at the replacement', (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected) + }) + // A standalone schema has no document around it, so pointers into // `components` or `webhooks` cannot be checked and stay as written. it('leaves references and mapping entries that point outside the schema as written', () => { diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts index 5c2799f..5363a16 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts @@ -51,6 +51,24 @@ it('keeps examples as they are, since 3.0 examples have the same fields', () => }) }) +// The conversion replaces this `not` with `not: {}` (see +// schema/references.test.ts), so the `$ref` to it gets the original. +it('inlines a schema $ref to a keyword the conversion replaced', () => { + expect(convertSpec({ + components: { + schemas: { + A: { enum: ['x'], not: { type: 'string' }, type: 'null' }, + B: { not: { $ref: '#/components/schemas/A/not' } }, + }, + }, + }).components).toEqual({ + schemas: { + A: { enum: ['x'], not: {} }, + B: { not: { type: 'string' } }, + }, + }) +}) + it('clones a malformed components value unchanged', () => { expect(convertSpec({ components: 'junk' }).components).toBe('junk') })