From 84e325a797012e478d5bb37687b7407ba6fd55d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:11:56 +0000 Subject: [PATCH 1/2] test(downgrader): rewrite checks as end-to-end tests under tests/ Every test now goes through the public entry points of @openapi-spec/downgrader instead of the internal engine in src/shared.ts. The unit tests in src/ are gone; the behaviors they pinned down (cloning, cycles and sharing, __proto__ keys, alias chains, pointer decoding) are expressed through real documents and schemas. Layout: - tests/v3.2-to-v3.1/{schema,spec}/ and tests/v3.1-to-v3.0/{schema,spec}/, one file per topic - tests/chained.test.ts for the documented 3.2 -> 3.1 -> 3.0 composition Tricky behaviors carry a comment explaining why the output looks the way it does, with links to the OpenAPI 3.0.4/3.1.2/3.2.0 specs, JSON Schema 2020-12, and the official upgrade guides. The e2e suite alone keeps coverage at 100% of statements, branches, functions, and lines, so no source changes were needed. Snapshots of the official examples are byte-identical to the previous ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018z4VMNTUoJPMp24y74NBMo --- packages/downgrader/src/shared.test.ts | 754 ----- packages/downgrader/src/v3.1-to-v3.0.test.ts | 2576 ----------------- packages/downgrader/src/v3.2-to-v3.1.test.ts | 2169 -------------- .../tests/__snapshots__/chained.test.ts.snap | 141 + .../tests/__snapshots__/e2e.test.ts.snap | 953 ------ packages/downgrader/tests/chained.test.ts | 122 + packages/downgrader/tests/corpus.test.ts | 220 -- packages/downgrader/tests/e2e.test.ts | 708 ----- packages/downgrader/tests/helpers.ts | 51 +- .../v3.1-to-v3.0/schema/annotations.test.ts | 90 + .../schema/enum-const-required.test.ts | 66 + .../tests/v3.1-to-v3.0/schema/input.test.ts | 127 + .../v3.1-to-v3.0/schema/loosening.test.ts | 79 + .../schema/numeric-bounds.test.ts | 36 + .../v3.1-to-v3.0/schema/references.test.ts | 94 + .../schema/removed-keywords.test.ts | 73 + .../v3.1-to-v3.0/schema/subschemas.test.ts | 54 + .../tests/v3.1-to-v3.0/schema/type.test.ts | 142 + .../spec/__snapshots__/corpus.test.ts.snap | 398 +++ .../v3.1-to-v3.0/spec/components.test.ts | 72 + .../tests/v3.1-to-v3.0/spec/corpus.test.ts | 309 ++ .../tests/v3.1-to-v3.0/spec/document.test.ts | 109 + .../v3.1-to-v3.0/spec/form-bodies.test.ts | 191 ++ .../tests/v3.1-to-v3.0/spec/helpers.ts | 29 + .../v3.1-to-v3.0/spec/input-graph.test.ts | 138 + .../spec/links-and-mappings.test.ts | 141 + .../v3.1-to-v3.0/spec/operations.test.ts | 122 + .../v3.1-to-v3.0/spec/path-items.test.ts | 184 ++ .../tests/v3.1-to-v3.0/spec/recursion.test.ts | 148 + .../spec/reference-objects.test.ts | 75 + .../v3.1-to-v3.0/spec/removed-parts.test.ts | 505 ++++ .../tests/v3.1-to-v3.0/spec/security.test.ts | 140 + .../tests/v3.2-to-v3.1/schema/input.test.ts | 104 + .../v3.2-to-v3.1/schema/keywords.test.ts | 76 + .../v3.2-to-v3.1/schema/references.test.ts | 37 + .../v3.2-to-v3.1/schema/subschemas.test.ts | 42 + .../spec/__snapshots__/corpus.test.ts.snap | 182 ++ .../v3.2-to-v3.1/spec/components.test.ts | 44 + .../spec/content-references.test.ts | 294 ++ .../tests/v3.2-to-v3.1/spec/corpus.test.ts | 273 ++ .../tests/v3.2-to-v3.1/spec/document.test.ts | 70 + .../tests/v3.2-to-v3.1/spec/examples.test.ts | 30 + .../tests/v3.2-to-v3.1/spec/helpers.ts | 34 + .../v3.2-to-v3.1/spec/input-graph.test.ts | 199 ++ .../v3.2-to-v3.1/spec/media-types.test.ts | 86 + .../v3.2-to-v3.1/spec/parameters.test.ts | 179 ++ .../v3.2-to-v3.1/spec/path-items.test.ts | 122 + .../v3.2-to-v3.1/spec/removed-parts.test.ts | 469 +++ .../tests/v3.2-to-v3.1/spec/responses.test.ts | 56 + .../spec/schema-identifiers.test.ts | 123 + .../spec/security-schemes.test.ts | 58 + .../spec/servers-and-tags.test.ts | 55 + 52 files changed, 6164 insertions(+), 7385 deletions(-) delete mode 100644 packages/downgrader/src/shared.test.ts delete mode 100644 packages/downgrader/src/v3.1-to-v3.0.test.ts delete mode 100644 packages/downgrader/src/v3.2-to-v3.1.test.ts create mode 100644 packages/downgrader/tests/__snapshots__/chained.test.ts.snap delete mode 100644 packages/downgrader/tests/__snapshots__/e2e.test.ts.snap create mode 100644 packages/downgrader/tests/chained.test.ts delete mode 100644 packages/downgrader/tests/corpus.test.ts delete mode 100644 packages/downgrader/tests/e2e.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts deleted file mode 100644 index 937ecc5..0000000 --- a/packages/downgrader/src/shared.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -import type { Context } from './shared' - -import { dig } from '../tests/helpers' -import { - allOfItems, - child, - clone, - convertMappingRef, - convertObject, - defineFields, - downgrade, - DROP, - getRef, - inline, - isRecord, - list, - map, - refOr, - removedPrefixes, - resolve, - setOwn, -} from './shared' - -function identity(value: T): T { - return value -} - -function createContext(root: unknown = {}): Context { - return { - aliasEnd: () => undefined, - converting: [], - copies: new Map(), - dangles: () => false, - identified: new Set(), - inlined: new Map(), - inlining: new Set(), - isRemovedPart: () => false, - markDangling: () => {}, - removals: new Map(), - resolve: ref => resolve(root, ref), - seen: new Map(), - } -} - -const NODE_FIELDS = defineFields({ - name: () => 'converted', - self: convertNode, -}) - -function convertNode(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, NODE_FIELDS) -} - -const convertItemRef = refOr(convertItem) - -const ITEM_FIELDS = defineFields({ - next: convertItemRef, - secret: DROP, -}) - -const DOCUMENT_FIELDS = defineFields({ - items: list(convertItemRef), - links: map(convertMappingRef), - named: map(convertItemRef, key => !key.startsWith('x-')), - removed: DROP, -}) - -function convertItem(value: unknown, ctx: Context): unknown { - return isRecord(value) && value.drop === true ? DROP : convertObject(value, ctx, ITEM_FIELDS) -} - -function convertDocument(value: unknown, removed?: string[], fields = DOCUMENT_FIELDS): { out: any, passes: number } { - let passes = 0 - const out = downgrade(value, (item, ctx) => { - passes += 1 - return convertObject(item, ctx, fields) - }, removed) - return { out, passes } -} - -describe('isRecord', () => { - it('returns true for plain object literals', () => { - expect(isRecord({})).toBe(true) - expect(isRecord({ a: 1 })).toBe(true) - }) - - it('returns true for objects with a null prototype', () => { - expect(isRecord(Object.create(null))).toBe(true) - }) - - it('returns false for null', () => { - expect(isRecord(null)).toBe(false) - }) - - it('returns false for arrays', () => { - expect(isRecord([])).toBe(false) - expect(isRecord([1, 2])).toBe(false) - }) - - it('returns false for primitives', () => { - expect(isRecord('text')).toBe(false) - expect(isRecord(42)).toBe(false) - expect(isRecord(true)).toBe(false) - expect(isRecord(Symbol('s'))).toBe(false) - expect(isRecord(10n)).toBe(false) - }) - - it('returns false for class instances', () => { - expect(isRecord(new Date())).toBe(false) - expect(isRecord(new Map())).toBe(false) - }) -}) - -describe('clone', () => { - it('deep-copies nested plain objects and arrays without sharing references', () => { - const input = { - list: [{ deep: { value: 1 } }, [2, 3]], - nested: { inner: { leaf: 'x' } }, - } - const copy = clone(input) as typeof input - expect(copy).toEqual(input) - expect(copy).not.toBe(input) - expect(copy.list).not.toBe(input.list) - expect(copy.list[0]).not.toBe(input.list[0]) - expect(copy.list[1]).not.toBe(input.list[1]) - expect(copy.nested).not.toBe(input.nested) - expect(copy.nested.inner).not.toBe(input.nested.inner) - }) - - it('keeps functions and class instances by reference', () => { - const date = new Date() - const values = new Map() - const copy = clone({ date, fn: identity, values }) as Record - expect(copy.fn).toBe(identity) - expect(copy.date).toBe(date) - expect(copy.values).toBe(values) - }) - - it('returns primitives as-is', () => { - expect(clone(1)).toBe(1) - expect(clone('a')).toBe('a') - expect(clone(null)).toBe(null) - expect(clone(true)).toBe(true) - }) - - it('copies a hostile __proto__ own key as a plain own data property without prototype pollution', () => { - const input: unknown = JSON.parse('{"__proto__": {"polluted": true}}') - const copy = clone(input) as object - expect(Object.getOwnPropertyNames(copy)).toContain('__proto__') - expect(Object.getOwnPropertyDescriptor(copy, '__proto__')?.value).toEqual({ polluted: true }) - expect(Object.getPrototypeOf(copy)).toBe(Object.prototype) - expect('polluted' in {}).toBe(false) - }) - - it('preserves key order', () => { - const input: Record = {} - input.zebra = 1 - input.apple = 2 - input.mango = 3 - expect(Object.keys(clone(input) as object)).toEqual(['zebra', 'apple', 'mango']) - }) - - it('preserves object cycles instead of recursing forever', () => { - const inner: Record = {} - const node: Record = { child: inner, name: 'root' } - inner.parent = node - const copy = clone(node) as Record - expect(copy).not.toBe(node) - expect(copy.name).toBe('root') - expect(dig(copy, 'child', 'parent')).toBe(copy) - }) - - it('preserves array cycles', () => { - const items: unknown[] = [1] - items.push(items) - const copy = clone(items) as unknown[] - expect(copy).not.toBe(items) - expect(copy[0]).toBe(1) - expect(copy[1]).toBe(copy) - }) - - it('clones shared references once', () => { - const shared = { a: 1 } - const copy = clone({ x: shared, y: shared }) as Record - expect(copy.x).toEqual({ a: 1 }) - expect(copy.x).not.toBe(shared) - expect(copy.x).toBe(copy.y) - }) - - it('returns a fresh copy on every call', () => { - const shared = { a: 1 } - expect(clone(shared)).not.toBe(clone(shared)) - }) -}) - -describe('convertObject', () => { - it('routes listed fields through their converters and deep-clones the rest', () => { - const extra = { deep: true } - const result = convertObject({ a: 1, b: 2, extra }, createContext(), defineFields({ a: item => [item], b: () => 'converted' })) as Record - expect(result).toEqual({ a: [1], b: 'converted', extra: { deep: true } }) - expect(result.extra).not.toBe(extra) - }) - - it('removes fields mapped to DROP and fields whose converter returns DROP', () => { - const result = convertObject({ gone: 1, kept: 2, maybe: 3 }, createContext(), defineFields({ gone: DROP, maybe: item => (item === 3 ? DROP : item) })) - expect(result).toEqual({ kept: 2 }) - }) - - it('passes the whole source record to converters and to finish', () => { - const source = { flag: true, value: 1 } - const result = convertObject( - source, - createContext(), - defineFields({ value: (item, _ctx, record) => (record.flag ? item : DROP) }), - (out, record) => ({ ...out, sameSource: record === source }), - ) - expect(result).toEqual({ flag: true, sameSource: true, value: 1 }) - }) - - it('lets finish replace the whole result', () => { - expect(convertObject({ a: 1 }, createContext(), defineFields({}), () => DROP)).toBe(DROP) - }) - - it('preserves key order', () => { - const input: Record = {} - input.zebra = 1 - input.apple = 2 - input.mango = 3 - const result = convertObject(input, createContext(), defineFields({ apple: identity })) as Record - expect(Object.keys(result)).toEqual(['zebra', 'apple', 'mango']) - }) - - it('deep-clones non-object input without consulting the table', () => { - const convert = vi.fn(identity) - const items = [{ a: 1 }] - const result = convertObject(items, createContext(), defineFields({ a: convert })) - expect(result).toEqual(items) - expect(result).not.toBe(items) - expect(convertObject('text', createContext(), defineFields({ a: convert }))).toBe('text') - expect(convertObject(null, createContext(), defineFields({ a: convert }))).toBe(null) - expect(convert).not.toHaveBeenCalled() - }) - - it('does not look up table entries through the prototype chain', () => { - const input: unknown = JSON.parse('{"constructor": 1, "toString": 2, "__proto__": {"polluted": true}}') - const result = convertObject(input, createContext(), defineFields({})) as object - expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) - expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(2) - expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toEqual({ polluted: true }) - expect(Object.getPrototypeOf(result)).toBe(Object.prototype) - expect('polluted' in {}).toBe(false) - }) - - it('points a cyclic reference at the converted ancestor when re-entered for the same object', () => { - const node: Record = { name: 'root' } - node.self = node - const result = convertNode(node, createContext()) as Record - expect(result.name).toBe('converted') - expect(result.self).toBe(result) - expect(node.self).toBe(node) - }) - - it('converts a cycle that closes several levels down', () => { - const grandchild: Record = { name: 'grandchild' } - const inner: Record = { name: 'child', self: grandchild } - const root: Record = { name: 'root', self: inner } - grandchild.self = inner - const result = convertNode(root, createContext()) as Record - const convertedChild = result.self as Record - const convertedGrandchild = convertedChild.self as Record - expect(convertedChild.name).toBe('converted') - expect(convertedGrandchild.name).toBe('converted') - expect(convertedGrandchild.self).toBe(convertedChild) - expect(inner.self).toBe(grandchild) - }) - - it('releases the cycle guard once a conversion finishes', () => { - const node: Record = { name: 'root' } - node.self = node - const first = convertNode(node, createContext()) as Record - const second = convertNode(node, createContext()) as Record - expect(second).not.toBe(first) - expect(second.self).toBe(second) - }) - - it('converts a shared reference once per call and reuses the result', () => { - const shared = { name: 'x' } - const fields = defineFields({ name: () => 'converted' }) - const convert = (item: unknown, ctx: Context): unknown => convertObject(item, ctx, fields) - const result = convertObject({ a: shared, b: shared }, createContext(), defineFields({ a: convert, b: convert })) - expect(result).toEqual({ a: { name: 'converted' }, b: { name: 'converted' } }) - expect(dig(result, 'b')).toBe(dig(result, 'a')) - }) - - it('clones a shared reference once per call', () => { - const shared = { deep: true } - const result = convertObject({ a: shared, b: [shared] }, createContext(), defineFields({})) - expect(result).toEqual({ a: { deep: true }, b: [{ deep: true }] }) - expect(dig(result, 'b', '0')).toBe(dig(result, 'a')) - expect(dig(result, 'a')).not.toBe(shared) - }) - - it('reuses a finished result only for the same field table', () => { - const shared = { name: 'x' } - const fields = defineFields({ name: () => 'converted' }) - const wrap = (out: Record): unknown => ({ wrapped: out }) - const result = convertObject({ a: shared, b: shared, c: shared }, createContext(), defineFields({ - a: (item, ctx) => convertObject(item, ctx, fields, wrap), - b: (item, ctx) => convertObject(item, ctx, fields, wrap), - c: (item, ctx) => convertObject(item, ctx, defineFields({})), - })) - expect(result).toEqual({ - a: { wrapped: { name: 'converted' } }, - b: { wrapped: { name: 'converted' } }, - c: { name: 'x' }, - }) - expect(dig(result, 'b')).toBe(dig(result, 'a')) - }) - - it('remembers the finished result, including DROP, for objects seen again', () => { - const ctx = createContext() - const fields = defineFields({}) - const finish = vi.fn(() => DROP) - const input = { a: 1 } - expect(convertObject(input, ctx, fields, finish)).toBe(DROP) - expect(convertObject(input, ctx, fields, finish)).toBe(DROP) - expect(finish).toHaveBeenCalledTimes(1) - }) - - it('returns fresh results on every call', () => { - const shared = { name: 'x' } - const fields = defineFields({ name: () => 'converted' }) - expect(convertObject(shared, createContext(), fields)).not.toBe(convertObject(shared, createContext(), fields)) - expect(dig(convertObject({ a: shared }, createContext(), defineFields({})), 'a')).not.toBe(dig(convertObject({ a: shared }, createContext(), defineFields({})), 'a')) - }) - - it('releases the cycle guard when a converter throws', () => { - const value = { a: 1 } - expect(() => convertObject(value, createContext(), defineFields({ - a: () => { - throw new Error('boom') - }, - }))).toThrow('boom') - expect(convertObject(value, createContext(), defineFields({ a: () => 2 }))).toEqual({ a: 2 }) - }) - - it('forgets reused results and clones when a converter throws', () => { - const shared = { name: 'x' } - const convert = vi.fn(() => 'converted') - const fields = defineFields({ name: convert }) - let copy: unknown - expect(() => convertObject({ a: shared }, createContext(), defineFields({ - a: (item, ctx) => { - convertObject(item, ctx, fields) - copy = clone(item, ctx) - throw new Error('boom') - }, - }))).toThrow('boom') - const result = convertObject({ a: shared, b: shared }, createContext(), defineFields({ a: (item, ctx) => convertObject(item, ctx, fields) })) - expect(convert).toHaveBeenCalledTimes(2) - expect(dig(result, 'b')).not.toBe(copy) - }) - - it('cuts an object still being converted when it is reached from another context', () => { - const source = { child: 'x' } - const node: Record = { name: 'root' } - node.self = node - const ctx = createContext({ node }) - const result = convertObject(source, ctx, defineFields({ - child: (_item, c) => ({ back: convertObject(source, c, defineFields({})), node: inline('#/node', c, convertNode) }), - })) - expect(dig(result, 'child', 'back')).toBe(DROP) - expect(dig(result, 'child', 'node', 'self')).toBe(dig(result, 'child', 'node')) - }) - - it('converts again, outside an inlined target, an object that was cut inside it', () => { - const LINK_FIELDS = defineFields({ - cut: (_item, c) => inline('#/child', c, convertLink), - self: convertLink, - }) - function convertLink(value: unknown, c: Context): unknown { - return convertObject(value, c, LINK_FIELDS) - } - const node: Record = { cut: 'x' } - const inner = { self: node } - node.self = inner - const result = convertLink(node, createContext({ child: inner })) - expect(dig(result, 'cut')).toEqual({}) - expect(dig(result, 'self', 'self')).toBe(result) - }) - - it('tracks only source records whose conversion is still in progress', () => { - const inner = { a: 1 } - const source = { child: inner } - const ctx = createContext() - const flags: boolean[] = [] - convertObject(source, ctx, defineFields({ - child: (item, c) => { - flags.push(c.converting.includes(source), c.converting.includes(item)) - return item - }, - })) - expect(flags).toEqual([true, false]) - expect(ctx.converting).toEqual([]) - }) -}) - -describe('map', () => { - it('applies the converter to the entries isEntry selects and clones the others', () => { - const keys: string[] = [] - const result = map(item => (item as number) * 10, (key) => { - keys.push(key) - return key !== 'raw' - })({ a: 1, b: 2, raw: 3 }, createContext()) - expect(result).toEqual({ a: 10, b: 20, raw: 3 }) - expect(keys).toEqual(['a', 'b', 'raw']) - }) - - it('passes each entry key to the converter', () => { - expect(map((item, _ctx, key) => `${key}=${item}`)({ a: 1, b: 2 }, createContext())).toEqual({ a: 'a=1', b: 'b=2' }) - }) - - it('leaves out entries whose converter returns DROP', () => { - expect(map(item => (item === 2 ? DROP : item))({ a: 1, b: 2, c: 3 }, createContext())).toEqual({ a: 1, c: 3 }) - }) - - it('preserves key order', () => { - const input: Record = {} - input.zebra = 1 - input.apple = 2 - expect(Object.keys(map(identity)(input, createContext()) as object)).toEqual(['zebra', 'apple']) - }) - - it('deep-clones non-object input unchanged without calling the converter', () => { - const convert = vi.fn(identity) - const items = [{ nested: true }] - const result = map(convert)(items, createContext()) - expect(result).toEqual(items) - expect(result).not.toBe(items) - expect(map(convert)('text', createContext())).toBe('text') - expect(map(convert)(null, createContext())).toBe(null) - expect(convert).not.toHaveBeenCalled() - }) -}) - -describe('list', () => { - it('applies the converter to every element', () => { - expect(list(item => (item as number) + 1)([1, 2, 3], createContext())).toEqual([2, 3, 4]) - }) - - it('leaves out elements whose converter returns DROP', () => { - expect(list(item => (item === 2 ? DROP : item))([1, 2, 3], createContext())).toEqual([1, 3]) - }) - - it('deep-clones non-array input unchanged without calling the converter', () => { - const convert = vi.fn(identity) - const record = { nested: { deep: true } } - const result = list(convert)(record, createContext()) - expect(result).toEqual(record) - expect(result).not.toBe(record) - expect(list(convert)(7, createContext())).toBe(7) - expect(list(convert)(undefined, createContext())).toBe(undefined) - expect(convert).not.toHaveBeenCalled() - }) -}) - -describe('getRef', () => { - it('returns the $ref string of a reference-shaped object', () => { - expect(getRef({ $ref: '#/components/schemas/Pet' })).toBe('#/components/schemas/Pet') - }) - - it('returns undefined for non-objects', () => { - expect(getRef(null)).toBeUndefined() - expect(getRef('#/ref')).toBeUndefined() - expect(getRef(42)).toBeUndefined() - expect(getRef([{ $ref: '#/x' }])).toBeUndefined() - }) - - it('returns undefined when $ref is missing or not a string', () => { - expect(getRef({})).toBeUndefined() - expect(getRef({ ref: '#/x' })).toBeUndefined() - expect(getRef({ $ref: 42 })).toBeUndefined() - expect(getRef({ $ref: { nested: true } })).toBeUndefined() - expect(getRef({ $ref: null })).toBeUndefined() - }) -}) - -describe('child', () => { - it('reads own record keys, including __proto__', () => { - expect(child({ a: 1 }, 'a')).toBe(1) - expect(child(JSON.parse('{"__proto__": 2}'), '__proto__')).toBe(2) - }) - - it('reads canonical array indices only', () => { - const items = ['a', 'b'] - expect(child(items, '1')).toBe('b') - expect(child(items, '2')).toBeUndefined() - expect(child(items, '01')).toBeUndefined() - expect(child(items, '-')).toBeUndefined() - expect(child(items, 'length')).toBeUndefined() - // eslint-disable-next-line no-sparse-arrays - expect(child([, 'b'], '0')).toBeUndefined() - }) - - it('does not read inherited members or step into primitives', () => { - expect(child({}, 'hasOwnProperty')).toBeUndefined() - expect(child('text', 'length')).toBeUndefined() - expect(child(null, 'a')).toBeUndefined() - }) -}) - -describe('resolve', () => { - const root = { 'a': [{ 'b/c': 1 }], '': { empty: true }, 'a~b': 2, 'components': { schemas: { Pet: 3 } }, 'paths': { '/pets/{id}': 4 }, '~1': 5 } - - it('resolves a local pointer against the root, unescaping ~1 and ~0', () => { - expect(resolve(root, '#/a/0/b~1c')).toBe(1) - expect(resolve(root, '#/components/schemas/Pet')).toBe(3) - expect(resolve(root, '#/paths/~1pets~1{id}')).toBe(4) - expect(resolve(root, '#/a~0b')).toBe(2) - expect(resolve(root, '#/~01')).toBe(5) - }) - - it('percent-decodes the fragment before splitting it', () => { - expect(resolve(root, '#/paths/~1pets~1%7Bid%7D')).toBe(4) - expect(resolve({ a: { b: 6 } }, '#/a%2Fb')).toBe(6) - }) - - it('resolves the whole-document pointer and the empty key', () => { - expect(resolve(root, '#')).toBe(root) - expect(resolve(root, '#/')).toEqual({ empty: true }) - }) - - it('returns undefined for unresolvable, non-local, anchor, and malformed pointers', () => { - expect(resolve(root, '#/a/1')).toBeUndefined() - expect(resolve(root, '#/x/y/z')).toBeUndefined() - expect(resolve(root, 'other.json#/a')).toBeUndefined() - expect(resolve(root, '#anchor')).toBeUndefined() - expect(resolve(root, '#/%E0%A4%A')).toBeUndefined() - }) -}) - -describe('setOwn', () => { - it('defines an enumerable, writable, configurable own property', () => { - const target: Record = {} - setOwn(target, 'name', 'value') - expect(Object.getOwnPropertyDescriptor(target, 'name')).toEqual({ configurable: true, enumerable: true, value: 'value', writable: true }) - }) - - it('shadows Object.prototype members with own data properties', () => { - const target: Record = {} - setOwn(target, 'constructor', 1) - setOwn(target, 'hasOwnProperty', 2) - expect(Object.getOwnPropertyDescriptor(target, 'constructor')?.value).toBe(1) - expect(Object.getOwnPropertyDescriptor(target, 'hasOwnProperty')?.value).toBe(2) - expect(Object.getPrototypeOf(target)).toBe(Object.prototype) - }) - - it('redefines a key already present on the target', () => { - const target: Record = {} - setOwn(target, 'name', 'first') - setOwn(target, 'name', 'second') - expect(target).toEqual({ name: 'second' }) - }) - - it('sets a __proto__ key as a plain own property without prototype pollution', () => { - const target: Record = {} - setOwn(target, '__proto__', { polluted: true }) - const descriptor = Object.getOwnPropertyDescriptor(target, '__proto__') - expect(descriptor?.value).toEqual({ polluted: true }) - expect(descriptor?.enumerable).toBe(true) - expect(Object.getPrototypeOf(target)).toBe(Object.prototype) - expect('polluted' in {}).toBe(false) - }) -}) - -describe('allOfItems', () => { - it('returns allOf entries, nesting a malformed allOf instead of discarding it', () => { - const entries = [{ type: 'string' }] - expect(allOfItems(entries)).toBe(entries) - expect(allOfItems(undefined)).toEqual([]) - expect(allOfItems('junk')).toEqual([{ allOf: 'junk' }]) - }) -}) - -describe('removedPrefixes', () => { - it('lists pointer prefixes of the fields each table drops', () => { - expect(removedPrefixes({ - '': defineFields({ kept: clone, removed: DROP }), - '/nested': defineFields({ gone: DROP }), - })).toEqual(['#/removed/', '#/nested/gone/']) - }) -}) - -describe('downgrade', () => { - it('keeps references that still resolve and inlines references into removed parts', () => { - expect(convertDocument({ - items: [{ $ref: '#/named/a' }, { $ref: '#/removed/b' }], - named: { a: { value: 'a' } }, - removed: { b: { value: 'b' } }, - }).out).toEqual({ - items: [{ $ref: '#/named/a' }, { value: 'b' }], - named: { a: { value: 'a' } }, - }) - }) - - it('inlines a reference into a shifted list entry that still exists', () => { - expect(convertDocument({ - items: [{ drop: true }, { value: 'one' }, { value: 'two' }], - named: { a: { $ref: '#/items/1' } }, - }).out).toEqual({ - items: [{ value: 'one' }, { value: 'two' }], - named: { a: { value: 'one' } }, - }) - }) - - it('inlines references into dropped fields and shifted list entries', () => { - expect(convertDocument({ - items: [{ drop: true }, { secret: { value: 's' }, value: 'kept' }], - named: { a: { $ref: '#/items/1' }, b: { $ref: '#/items/1/secret' } }, - }).out).toEqual({ - items: [{ value: 'kept' }], - named: { a: { value: 'kept' }, b: { value: 's' } }, - }) - }) - - it('inlines a reference whose target is itself an external reference', () => { - expect(convertDocument({ - items: [{ $ref: '#/removed/a' }], - removed: { a: { $ref: 'other.json#/a' } }, - }).out).toEqual({ items: [{ $ref: 'other.json#/a' }] }) - }) - - it('leaves missing, external, anchor, and malformed references as written', () => { - const items = [{ $ref: '#/missing' }, { $ref: 'other.json#/a' }, { $ref: '#anchor' }, { $ref: '#/%E0%A4%A' }] - expect(convertDocument({ items }).out).toEqual({ items }) - }) - - it('follows reference chains through removed parts', () => { - expect(convertDocument({ - items: [{ $ref: '#/removed/a' }], - removed: { a: { $ref: '#/removed/b' }, b: { value: 'b' } }, - }).out).toEqual({ items: [{ value: 'b' }] }) - }) - - it('removes references to a removed target through a chain of aliases in two passes', () => { - const named = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`a${index + 1}`, { $ref: `#/named/a${index}` }])) - const { out, passes } = convertDocument({ items: [{ $ref: '#/named/a20' }, { value: 'kept' }], named: { ...named, a0: { drop: true } } }) - expect(out).toEqual({ items: [{ value: 'kept' }], named: {} }) - expect(passes).toBe(2) - }) - - it('re-runs when a reference kept earlier in a pass turns out to target a removed alias', () => { - const { out } = convertDocument({ - links: { alias: '#/named/alias' }, - named: { alias: { $ref: '#/named/gone' }, gone: { drop: true } }, - items: [{ $ref: '#/named/alias' }], - }) - expect(out).toEqual({ links: {}, named: {}, items: [] }) - }) - - it('follows long alias chains without deep recursion', () => { - const named: Record = {} - for (let index = 5000; index > 0; index -= 1) { - named[`a${index}`] = { $ref: `#/named/a${index - 1}` } - } - named.a0 = { drop: true } - const { out, passes } = convertDocument({ items: [{ $ref: '#/named/a5000' }], named }) - expect(out).toEqual({ items: [], named: {} }) - expect(passes).toBe(2) - }) - - it('keeps an alias whose target is only cut by a cycle', () => { - const { out } = convertDocument({ - items: [{ $ref: '#/removed/target' }], - named: { alias: { $ref: '#/removed/target' } }, - removed: { target: { next: { $ref: '#/named/alias' }, value: 't' } }, - }) - expect(out).toEqual({ - items: [{ next: { $ref: '#/named/alias' }, value: 't' }], - named: { alias: { next: { $ref: '#/named/alias' }, value: 't' } }, - }) - }) - - it('keeps a loop of aliases as written, even in a removed part', () => { - const items = [{ $ref: '#/named/a' }, { $ref: '#/removed/a' }] - const named = { a: { $ref: '#/named/b' }, b: { $ref: '#/named/a' } } - const removed = { a: { $ref: '#/removed/b' }, b: { $ref: '#/removed/a' } } - expect(convertDocument({ items, named, removed }, ['#/removed/']).out).toEqual({ items, named }) - }) - - it('removes references to removed targets, cascading through aliases', () => { - expect(convertDocument({ - items: [{ $ref: '#/named/alias' }, { $ref: '#/named/gone' }, { value: 'kept' }], - named: { alias: { $ref: '#/named/gone' }, gone: { drop: true }, other: { $ref: '#/items/2' } }, - }).out).toEqual({ - items: [{ value: 'kept' }], - named: { other: { value: 'kept' } }, - }) - }) - - it('cuts a reference cycle at its first repeat, however the target is spelled', () => { - const removed = { a: { next: { $ref: '#/removed/b' }, value: 'a' }, b: { next: { $ref: '#/removed/%61' }, value: 'b' } } - expect(convertDocument({ items: [{ $ref: '#/removed/a' }], removed }).out).toEqual({ items: [{ next: { value: 'b' }, value: 'a' }] }) - }) - - it('converts a target inlined from several places once, however it is spelled', () => { - const { out } = convertDocument({ - items: [{ $ref: '#/removed/a' }, { $ref: '#/removed/a' }, { $ref: '#/removed/%61' }], - removed: { a: { value: 'a' } }, - }) - expect(out.items[0]).toBe(out.items[1]) - expect(out.items[0]).toBe(out.items[2]) - }) - - it('converts a target again for a later place when its conversion identified a value', () => { - const fields = defineFields({ - items: list(refOr((value, ctx) => { - const repeated = ctx.identified.has(value) - ctx.identified.add(value) - return { repeated } - })), - }) - const { out } = convertDocument({ items: [{ $ref: '#/removed/a' }, { $ref: '#/removed/a' }, { $ref: '#/removed/a' }], removed: { a: {} } }, ['#/removed/'], fields) - expect(out.items).toEqual([{ repeated: false }, { repeated: true }, { repeated: true }]) - expect(out.items[2]).toBe(out.items[1]) - }) - - it('resolves references nested in inlined targets without a pass per level', () => { - const removed = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`r${index}`, { next: { $ref: `#/removed/r${index + 1}` } }])) - const { out, passes } = convertDocument({ items: [{ $ref: '#/removed/r0' }], removed: { ...removed, r20: { value: 'end' } } }) - expect(JSON.stringify(out)).toContain('"end"') - expect(JSON.stringify(out)).not.toContain('$ref') - expect(passes).toBe(2) - }) - - it('treats references under removed prefixes as dangling from the first pass', () => { - const doc = { items: [{ $ref: '#/removed/a' }, { $ref: '#/removed/missing' }], removed: { a: { value: 'a' } } } - const { out, passes } = convertDocument(doc, ['#/removed/']) - expect(out).toEqual({ items: [{ value: 'a' }, { $ref: '#/removed/missing' }] }) - expect(passes).toBe(1) - }) - - it('preserves cycles and sharing of the input graph without mutating it', () => { - const shared: Record = { value: 'shared' } - shared.next = shared - const input = { items: [shared], named: { 'a': shared, 'x-raw': { $ref: '#/removed/a' } }, removed: { a: {} } } - const before = structuredClone(input) - const { out } = convertDocument(input) - expect(out.items[0]?.next).toBe(out.items[0]) - expect(out.named.a).toBe(out.items[0]) - expect(out.named['x-raw']).toEqual({ $ref: '#/removed/a' }) - expect(input).toEqual(before) - }) -}) diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts deleted file mode 100644 index 1111460..0000000 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ /dev/null @@ -1,2576 +0,0 @@ -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' - -import { dig } from '../tests/helpers' -import { downgradeSchemaV31ToV30, downgradeSpecV31ToV30 } from './v3.1-to-v3.0' - -const info = { title: 't', version: '1' } - -const base = { info, openapi: '3.1.0', paths: {} } -const converted = { info, openapi: '3.0.4', paths: {} } - -function convertSpec(fields: Record) { - return downgradeSpecV31ToV30({ ...base, ...fields } as any) -} - -function convertPathItem(pathItem: unknown): unknown { - return dig(convertSpec({ paths: { '/a': pathItem } }), 'paths', '/a') -} - -function convertComponent(kind: string, value: unknown): unknown { - return dig( - convertSpec({ components: { [kind]: { X: value } } }), - 'components', - kind, - 'X', - ) -} - -function convertSchema(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} - -describe('downgradeSpecV31ToV30', () => { - describe('document', () => { - it('rewrites the openapi version to 3.0.4', () => { - expect( - downgradeSpecV31ToV30({ info, openapi: '3.1.1', paths: {} }), - ).toEqual(converted) - }) - - it('adds openapi 3.0.4 and an empty paths object when they are missing', () => { - expect(downgradeSpecV31ToV30({ info } as any)).toEqual(converted) - }) - - it('removes jsonSchemaDialect and webhooks without leaving traces', () => { - const result = convertSpec({ - jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', - webhooks: { newPet: { post: { summary: 's' } } }, - }) - expect(result).toEqual(converted) - expect(result).not.toHaveProperty('x-webhooks') - }) - - it('preserves unknown top-level keys and extensions', () => { - expect(convertSpec({ 'future': { a: 1 }, 'x-root': true })).toEqual({ - ...converted, - 'future': { a: 1 }, - 'x-root': true, - }) - }) - - it('clones non-object input unchanged', () => { - expect(downgradeSpecV31ToV30(null as any)).toBeNull() - expect(downgradeSpecV31ToV30(42 as any)).toBe(42) - expect(downgradeSpecV31ToV30('spec' as any)).toBe('spec') - const list = [1, { a: 1 }] - const result = downgradeSpecV31ToV30(list as any) - expect(result).toEqual(list) - expect(result).not.toBe(list) - }) - }) - - describe('info', () => { - it('removes summary and license.identifier and keeps the other fields', () => { - expect( - convertSpec({ - info: { - license: { - identifier: 'MIT', - name: 'MIT', - url: 'https://opensource.org/license/mit', - }, - summary: 'short', - title: 't', - version: '1', - }, - }).info, - ).toEqual({ - license: { name: 'MIT', url: 'https://opensource.org/license/mit' }, - title: 't', - version: '1', - }) - }) - - it('clones malformed info and license values unchanged', () => { - expect(convertSpec({ info: 42 }).info).toBe(42) - expect( - convertSpec({ info: { license: 'MIT', title: 't', version: '1' } }).info, - ).toEqual({ - license: 'MIT', - title: 't', - version: '1', - }) - }) - }) - - describe('paths', () => { - it('converts path items and clones non-path keys', () => { - expect( - convertSpec({ - paths: { - '/a': { get: { summary: 's' } }, - 'x-note': { get: { summary: 's' } }, - }, - }).paths, - ).toEqual({ - '/a': { - get: { responses: { default: { description: '' } }, summary: 's' }, - }, - 'x-note': { get: { summary: 's' } }, - }) - }) - - it('leaves a path item $ref that points outside components.pathItems untouched, string or not', () => { - expect( - convertPathItem({ $ref: '#/paths/~1other', summary: 's' }), - ).toEqual({ $ref: '#/paths/~1other', summary: 's' }) - expect( - convertPathItem({ $ref: 'https://example.com/paths.json#/a' }), - ).toEqual({ $ref: 'https://example.com/paths.json#/a' }) - expect(convertPathItem({ $ref: 42 })).toEqual({ $ref: 42 }) - }) - - it('clones malformed paths, path items, operations, and nested objects unchanged', () => { - expect(convertSpec({ paths: 'junk' }).paths).toBe('junk') - const paths = { - '/a': { - get: { requestBody: 42, responses: { 200: 'junk', 201: { description: 'ok', links: 'junk' } } }, - parameters: [42], - }, - '/b': { - post: { - requestBody: { - content: { - 'application/json': 'junk', - 'multipart/form-data': { encoding: { field: 'junk' } }, - }, - }, - responses: {}, - }, - }, - '/c': { get: 'junk' }, - '/junk': 'junk', - } - expect(convertSpec({ paths }).paths).toEqual(paths) - }) - }) - - describe('components.pathItems inlining', () => { - const reusable = { - get: { responses: { 200: { description: 'ok' } } }, - parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], - summary: 'Reusable', - } - const inlined = { - get: { responses: { 200: { description: 'ok' } } }, - parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], - summary: 'Reusable', - } - - function convertWithPathItems(paths: unknown, pathItems: unknown, extra: Record = {}) { - return convertSpec({ components: { pathItems, ...extra }, paths }) - } - - it('inlines the converted entry and lets the referencing fields win', () => { - const result = convertWithPathItems( - { - '/a': { $ref: '#/components/pathItems/Reusable' }, - '/b': { - $ref: '#/components/pathItems/Reusable', - description: 'own', - summary: 'Own summary', - }, - }, - { Reusable: reusable }, - ) - expect(result.components).toEqual({}) - expect(result.paths).toEqual({ - '/a': inlined, - '/b': { ...inlined, description: 'own', summary: 'Own summary' }, - }) - }) - - it('follows chains of path item references', () => { - expect( - convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Alias', summary: 'Own' } }, - { - Alias: { $ref: '#/components/pathItems/Reusable', description: 'alias' }, - Reusable: reusable, - }, - ).paths, - ).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) - }) - - it('inlines an entry that references an external file', () => { - expect( - convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/External', summary: 'Own' } }, - { External: { $ref: './paths/a.yaml' } }, - ).paths, - ).toEqual({ '/a': { $ref: './paths/a.yaml', summary: 'Own' } }) - }) - - it('inlines references inside callbacks', () => { - expect( - convertWithPathItems( - { - '/a': { - post: { - callbacks: { - onEvent: { '{$request.body#/url}': { $ref: '#/components/pathItems/Reusable' } }, - }, - responses: {}, - }, - }, - }, - { Reusable: reusable }, - ).paths, - ).toEqual({ - '/a': { - post: { - callbacks: { onEvent: { '{$request.body#/url}': inlined } }, - responses: {}, - }, - }, - }) - }) - - it.each([ - ['an unknown entry', '#/components/pathItems/Missing', { Reusable: reusable }], - ['a nested pointer', '#/components/pathItems/Reusable/get', { Reusable: reusable }], - ['an empty name', '#/components/pathItems/', { Reusable: reusable }], - ['a malformed entry', '#/components/pathItems/Junk', { Junk: 42 }], - ['a prototype member', '#/components/pathItems/hasOwnProperty', {}], - ['a malformed pathItems map', '#/components/pathItems/Reusable', 'junk'], - ])('leaves a reference to %s untouched', (_name, ref, pathItems) => { - expect( - convertWithPathItems({ '/a': { $ref: ref, summary: 's' } }, pathItems).paths, - ).toEqual({ '/a': { $ref: ref, summary: 's' } }) - }) - - it('leaves a reference untouched when components.pathItems is missing', () => { - expect( - convertPathItem({ $ref: '#/components/pathItems/Reusable' }), - ).toEqual({ $ref: '#/components/pathItems/Reusable' }) - }) - - it('leaves a reference chain that loops without reaching a path item as written', () => { - expect( - convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, - { - Ping: { $ref: '#/components/pathItems/Pong', description: 'ping' }, - Pong: { $ref: '#/components/pathItems/Ping' }, - }, - ).paths, - ).toEqual({ '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }) - }) - - it('cuts a path item that reaches itself through its callbacks down to its own fields', () => { - const result = convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Self' } }, - { - Self: { - post: { - callbacks: { - loop: { - bare: { $ref: '#/components/pathItems/Self' }, - own: { $ref: '#/components/pathItems/Self', summary: 'own' }, - }, - }, - responses: {}, - }, - }, - }, - ) - expect(result.paths).toEqual({ - '/a': { - post: { - callbacks: { loop: { bare: {}, own: { summary: 'own' } } }, - responses: {}, - }, - }, - }) - }) - - it('applies mutualTLS removal inside inlined path items', () => { - const result = convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Secured' } }, - { Secured: { get: { responses: {}, security: [{ mtls: [] }, { api: ['r'] }] } } }, - { securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } } }, - ) - expect(result.paths).toEqual({ - '/a': { get: { responses: {}, security: [{ api: [] }] } }, - }) - }) - }) - - describe('references into webhooks and components.pathItems', () => { - const removedPointer = /#\/(?:webhooks|components\/pathItems)/ - const schemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' - const hook = { - post: { - operationId: 'newPetHook', - parameters: [{ description: 'orig', in: 'header', name: 'X-Hook', schema: { type: ['string', 'null'] } }], - requestBody: { - content: { - 'application/json': { - schema: { properties: { name: { type: 'string' } }, type: 'object' }, - }, - }, - }, - responses: { 200: { description: 'ok' } }, - }, - } - const hookParameter = { description: 'orig', in: 'header', name: 'X-Hook', schema: { nullable: true, type: 'string' } } - const item = { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], - } - - it('inlines the reported references without mutating the input', () => { - const input = { - ...base, - components: { - pathItems: { Item: item }, - schemas: { Pet: { $ref: schemaPointer } }, - }, - paths: { - '/a': { - get: { - parameters: [ - { $ref: '#/webhooks/newPet/post/parameters/0' }, - { $ref: '#/components/pathItems/Item/parameters/0' }, - ], - responses: { - 200: { $ref: '#/webhooks/newPet/post/responses/200' }, - 201: { - description: 'created', - links: { - l1: { operationRef: '#/webhooks/newPet/post' }, - l2: { operationRef: '#/components/pathItems/Item/get' }, - }, - }, - }, - }, - }, - '/b': { $ref: '#/webhooks/newPet' }, - }, - webhooks: { newPet: hook }, - } - const before = structuredClone(input) - const result = downgradeSpecV31ToV30(input as any) - expect(result.components).toEqual({ - schemas: { Pet: { properties: { name: { type: 'string' } }, type: 'object' } }, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - parameters: [hookParameter, { in: 'query', name: 'q', schema: { enum: ['x'] } }], - responses: { - 200: { description: 'ok' }, - 201: { description: 'created', links: {} }, - }, - }, - }, - '/b': { post: { ...hook.post, parameters: [hookParameter] } }, - }) - expect(JSON.stringify(result)).not.toMatch(removedPointer) - expect(input).toEqual(before) - }) - - it('converts each inlined target for its position, in every component map', () => { - const pointer = (path: string) => `#/webhooks/full/post/${path}` - const response = { - content: { 'application/json': { examples: { e: { value: 1 } } } }, - description: 'ok', - headers: { H: { schema: { const: 1 } } }, - } - const result = convertSpec({ - components: { - callbacks: { C: { $ref: pointer('callbacks/cb') } }, - examples: { E: { $ref: pointer('responses/200/content/application~1json/examples/e') } }, - headers: { H: { $ref: pointer('responses/200/headers/H') } }, - parameters: { P: { $ref: pointer('parameters/0') } }, - requestBodies: { B: { $ref: pointer('requestBody') } }, - responses: { R: { $ref: pointer('responses/200') } }, - securitySchemes: { S: { $ref: pointer('x-scheme') } }, - }, - webhooks: { - full: { - post: { - 'callbacks': { cb: { '{$url}': { get: {} } } }, - 'parameters': [{ in: 'path', name: 'id' }], - 'requestBody': { - content: { 'application/json': { schema: { type: ['string', 'null'] } } }, - }, - 'responses': { 200: response }, - 'x-scheme': { in: 'header', name: 'k', type: 'apiKey' }, - }, - }, - }, - }) - expect(result.components).toEqual({ - callbacks: { C: { '{$url}': { get: { responses: { default: { description: '' } } } } } }, - examples: { E: { value: 1 } }, - headers: { H: { schema: { enum: [1] } } }, - parameters: { P: { in: 'path', name: 'id', required: true } }, - requestBodies: { - B: { content: { 'application/json': { schema: { nullable: true, type: 'string' } } } }, - }, - responses: { R: { ...response, headers: { H: { schema: { enum: [1] } } } } }, - securitySchemes: { S: { in: 'header', name: 'k', type: 'apiKey' } }, - }) - }) - - it('follows chains through the removed parts and keeps the reference where a chain leaves them', () => { - const result = convertSpec({ - components: { - parameters: { Shared: { in: 'query', name: 'shared' } }, - pathItems: { Deep: { parameters: [{ in: 'query', name: 'deep' }] } }, - schemas: { - Exit: { $ref: '#/webhooks/chain/post/requestBody/content/application~1json/schema' }, - Name: { type: 'string' }, - }, - }, - paths: { - '/a': { - post: { - parameters: [ - { $ref: '#/webhooks/chain/post/parameters/0' }, - { $ref: '#/webhooks/chain/post/parameters/1', description: 'dropped' }, - ], - responses: {}, - }, - }, - '/b': { $ref: '#/webhooks/alias', description: 'own' }, - }, - webhooks: { - alias: { $ref: '#/paths/~1a', summary: 'alias' }, - chain: { - post: { - parameters: [ - { $ref: '#/components/pathItems/Deep/parameters/0' }, - { $ref: '#/components/parameters/Shared' }, - ], - requestBody: { - content: { 'application/json': { schema: { $ref: '#/components/schemas/Name' } } }, - }, - }, - }, - }, - }) - expect(result.components?.schemas?.Exit).toEqual({ $ref: '#/components/schemas/Name' }) - expect(result.paths).toEqual({ - '/a': { - post: { - parameters: [{ in: 'query', name: 'deep' }, { $ref: '#/components/parameters/Shared' }], - responses: {}, - }, - }, - '/b': { $ref: '#/paths/~1a', description: 'own', summary: 'alias' }, - }) - }) - - it('follows long chains without growing the stack', () => { - const webhooks: Record = { w10000: { get: { responses: {} } } } - for (let index = 0; index < 10_000; index++) { - webhooks[`w${index}`] = { $ref: `#/webhooks/w${index + 1}` } - } - expect(convertSpec({ paths: { '/a': { $ref: '#/webhooks/w0' } }, webhooks }).paths).toEqual({ - '/a': { get: { responses: {} } }, - }) - }) - - it('ignores summary and description overrides when it inlines a reference', () => { - const result = convertSpec({ - components: { - callbacks: { C: { $ref: '#/webhooks/newPet/x-callback', description: 'ignored' } }, - examples: { E: { $ref: '#/webhooks/newPet/x-example', summary: 'outer' } }, - parameters: { P: { $ref: '#/webhooks/newPet/x-alias', description: 'outer' } }, - }, - webhooks: { - newPet: { - ...hook, - 'x-alias': { $ref: '#/webhooks/newPet/post/parameters/0', description: 'inner' }, - 'x-callback': { '{$url}': { summary: 's' } }, - 'x-example': { description: 'd', summary: 's', value: 1 }, - }, - }, - }) - expect(result.components).toEqual({ - callbacks: { C: { '{$url}': { summary: 's' } } }, - examples: { E: { description: 'd', summary: 's', value: 1 } }, - parameters: { P: hookParameter }, - }) - }) - - it.each([ - ['a missing target', '#/webhooks/newPet/post/parameters/9'], - ['a non-object target', '#/webhooks/newPet/post/operationId'], - ['a looping chain', '#/components/pathItems/Loop/parameters/0'], - ['a malformed percent escape', '#/webhooks/%E0%A4%A'], - ])('leaves a reference to %s as written, in every position', (_name, ref) => { - const reference = { $ref: ref, description: 'd' } - const bare = { $ref: ref } - const result = convertSpec({ - components: { - callbacks: { C: reference }, - examples: { E: reference }, - headers: { H: reference }, - links: { L: reference }, - parameters: { P: reference }, - pathItems: { - Loop: { - parameters: [ - { $ref: '#/components/pathItems/Loop/parameters/1' }, - { $ref: '#/components/pathItems/Loop/parameters/0' }, - ], - }, - }, - requestBodies: { B: reference }, - responses: { R: reference }, - schemas: { S: bare, T: { $ref: ref, type: 'string' } }, - securitySchemes: { S: reference }, - }, - paths: { - '/a': { - get: { - callbacks: { cb: reference }, - parameters: [reference, { in: 'query', name: 'kept' }], - requestBody: reference, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: reference } } }, - examples: { e: reference }, - schema: bare, - }, - }, - description: 'ok', - headers: { H: reference }, - links: { l: reference }, - }, - 201: reference, - }, - }, - parameters: [reference], - }, - '/b': reference, - }, - webhooks: { newPet: hook }, - }) - expect(result.components).toEqual({ - callbacks: { C: bare }, - examples: { E: bare }, - headers: { H: bare }, - links: { L: bare }, - parameters: { P: bare }, - requestBodies: { B: bare }, - responses: { R: bare }, - schemas: { S: bare, T: { allOf: [bare], type: 'string' } }, - securitySchemes: { S: bare }, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - callbacks: { cb: bare }, - parameters: [bare, { in: 'query', name: 'kept' }], - requestBody: bare, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: bare } } }, - examples: { e: bare }, - schema: bare, - }, - }, - description: 'ok', - headers: { H: bare }, - links: { l: bare }, - }, - 201: bare, - }, - }, - parameters: [bare], - }, - '/b': reference, - }) - }) - - it('inlines Schema $refs with or without siblings and converts boolean targets', () => { - const pointer = (path: string) => `#/components/pathItems/Schemas/x-schemas/${path}` - const result = convertSpec({ - components: { - pathItems: { - Schemas: { - 'x-schemas': { - alias: { $ref: pointer('nullable') }, - never: false, - nullable: { type: ['string', 'null'] }, - withSiblings: { $ref: pointer('nullable'), description: 'wrapped' }, - }, - }, - }, - schemas: { - Alias: { $ref: pointer('alias') }, - Never: { $ref: pointer('never') }, - NotNever: { not: { $ref: pointer('never') } }, - Siblings: { $ref: pointer('nullable'), description: 'd' }, - WithSiblings: { $ref: pointer('withSiblings') }, - }, - }, - }) - const nullable = { nullable: true, type: 'string' } - expect(result.components).toEqual({ - schemas: { - Alias: nullable, - Never: { not: {} }, - NotNever: { not: { not: {} } }, - Siblings: { allOf: [nullable], description: 'd' }, - WithSiblings: { allOf: [nullable], description: 'wrapped' }, - }, - }) - }) - - it('cuts recursion into {} for schemas and into own fields for path items, keeping the output acyclic', () => { - const result = convertSpec({ - components: { schemas: { Tree: { $ref: '#/webhooks/tree/post/requestBody/content/application~1json/schema' } } }, - paths: { - '/ping': { $ref: '#/webhooks/ping' }, - '/tree': { $ref: '#/webhooks/tree' }, - }, - webhooks: { - ping: { - post: { - callbacks: { - pong: { $ref: '#/webhooks/ping/post/callbacks/self' }, - self: { '{$request.body#/url}': { $ref: '#/webhooks/ping' } }, - }, - responses: {}, - }, - }, - tree: { - post: { - requestBody: { - content: { - 'application/json': { - schema: { - properties: { - children: { items: { $ref: '#/webhooks/tree/post/requestBody/content/application~1json/schema' }, type: 'array' }, - }, - type: 'object', - }, - }, - }, - }, - responses: {}, - }, - }, - }, - }) - expect(result.components).toEqual({ - schemas: { Tree: { properties: { children: { items: {}, type: 'array' } }, type: 'object' } }, - }) - expect( - dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema'), - ).toEqual(dig(result, 'components', 'schemas', 'Tree')) - expect(dig(result, 'paths', '/ping', 'post', 'callbacks')).toEqual({ - pong: { '{$request.body#/url}': {} }, - self: { '{$request.body#/url}': {} }, - }) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) - expect(JSON.stringify(result)).not.toMatch(removedPointer) - }) - - it('converts a target reached through many references once', () => { - const pointer = (index: number) => `#/webhooks/w${index}/post/requestBody/content/application~1json/schema` - const leaf = { content: { 'application/json': { schema: { type: ['string', 'null'] } } } } - const webhooks: Record = { w64: { post: { requestBody: leaf } } } - for (let index = 0; index < 64; index++) { - const schema = { properties: { a: { $ref: pointer(index + 1) }, b: { $ref: pointer(index + 1) } }, type: 'object' } - webhooks[`w${index}`] = { post: { requestBody: { content: { 'application/json': { schema } } } } } - } - let node = dig(convertSpec({ components: { schemas: { Root: { $ref: pointer(0) } } }, webhooks }), 'components', 'schemas', 'Root') - for (let index = 0; index < 64; index++) { - expect(dig(node, 'properties', 'a')).toBe(dig(node, 'properties', 'b')) - node = dig(node, 'properties', 'a') - } - expect(node).toEqual({ nullable: true, type: 'string' }) - }) - - it('resolves percent-encoded and tilde-escaped pointers', () => { - const result = convertSpec({ - paths: { - '/a': { - get: { - parameters: [ - { $ref: '#/webhooks/new%20pet/post/parameters/0' }, - { $ref: '#/webhooks/a~0b~1c/post/parameters/0' }, - { $ref: '#%2Fwebhooks%2Fnew%20pet%2Fpost%2Fparameters%2F1' }, - ], - responses: {}, - }, - }, - '/b': { $ref: '#/webhooks/new%20pet/post/callbacks/cb/%7B$request.body%23~1url%7D' }, - }, - webhooks: { - 'a~b/c': { post: { parameters: [{ in: 'query', name: 'tilde' }] } }, - 'new pet': { - post: { - callbacks: { cb: { '{$request.body#/url}': { summary: 'callback' } } }, - parameters: [ - { in: 'query', name: 'space' }, - { in: 'query', name: 'encoded' }, - ], - }, - }, - }, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - parameters: [ - { in: 'query', name: 'space' }, - { in: 'query', name: 'tilde' }, - { in: 'query', name: 'encoded' }, - ], - responses: {}, - }, - }, - '/b': { summary: 'callback' }, - }) - }) - - it('resolves pointer tokens only against keys and indices the document owns', () => { - const refs = [ - '#/webhooks/__proto__/post/parameters/0', - '#/webhooks/__proto__/post/parameters/length', - '#/webhooks/__proto__/post/parameters/00', - '#/webhooks/__proto__/post/parameters/-', - '#/webhooks/constructor', - '#/webhooks/hasOwnProperty', - ] - const result = convertSpec({ - paths: { '/a': { get: { parameters: refs.map($ref => ({ $ref })), responses: {} } } }, - webhooks: JSON.parse('{"__proto__":{"post":{"parameters":[{"in":"query","name":"own"}]}}}'), - }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ - { in: 'query', name: 'own' }, - ...refs.slice(1).map($ref => ({ $ref })), - ]) - }) - - it('removes links whose operationRef points into the removed parts, together with references to them', () => { - const result = convertSpec({ - components: { - callbacks: { Hook: { '{$url}': { $ref: '#/webhooks/callbackHook' } } }, - links: { - ByComponentCallback: { operationRef: '#/webhooks/callbackHook/post' }, - Gone: { operationRef: '#/webhooks/orphan/post' }, - Kept: { description: 'kept', operationRef: '#/webhooks/newPet/post' }, - }, - pathItems: { Item: item, NoId: { get: { responses: {} } } }, - }, - paths: { - '/a': { - get: { - callbacks: { - cb: { '{$request.body#/url}': { $ref: '#/components/pathItems/Item' } }, - }, - responses: { - 200: { - description: 'ok', - links: { - both: { operationId: 'stale', operationRef: '#/webhooks/newPet/post' }, - byCallback: { - operationRef: '#/components/pathItems/Item/get', - parameters: { id: '$response.body#/id' }, - }, - byId: { operationId: 'orphanHook' }, - byPath: { operationRef: '#/paths/~1b/post' }, - external: { $ref: 'https://example.com/links.json#/Kept' }, - inlined: { $ref: '#/webhooks/newPet/post/responses/200/links/self' }, - missing: { operationRef: '#/webhooks/missing/post' }, - noId: { operationRef: '#/components/pathItems/NoId/get' }, - refGone: { $ref: '#/components/links/Gone' }, - refKept: { $ref: '#/components/links/Kept' }, - refUnknown: { $ref: '#/components/links/Unknown' }, - }, - }, - }, - }, - }, - '/b': { $ref: '#/webhooks/newPet' }, - '/c': { $ref: '#/components/pathItems/NoId' }, - '/d': { $ref: '#/webhooks/newPet' }, - '/junk': 'junk', - 'x-orphan': { post: { operationId: 'orphanHook' } }, - }, - webhooks: { - callbackHook: { post: { operationId: 'callbackHookOp', responses: {} } }, - newPet: { - post: { - operationId: 'newPetHook', - responses: { - 200: { - description: 'ok', - links: { self: { operationRef: '#/webhooks/newPet/post' } }, - }, - }, - }, - }, - orphan: { post: { operationId: 'orphanHook', responses: {} } }, - }, - }) - expect(result.components).toEqual({ - callbacks: { Hook: { '{$url}': { post: { operationId: 'callbackHookOp', responses: {} } } } }, - links: {}, - }) - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'links')).toEqual({ - byId: { operationId: 'orphanHook' }, - byPath: { operationRef: '#/paths/~1b/post' }, - external: { $ref: 'https://example.com/links.json#/Kept' }, - refUnknown: { $ref: '#/components/links/Unknown' }, - }) - expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({}) - expect(JSON.stringify(result)).not.toMatch(removedPointer) - }) - - it('removes a link to an operation that an own field of the referencing path item replaces', () => { - expect( - convertSpec({ - components: { links: { L: { operationRef: '#/webhooks/w/post' } } }, - paths: { '/a': { $ref: '#/webhooks/w', post: { responses: {} } } }, - webhooks: { w: { post: { operationId: 'hidden', responses: {} } } }, - }).components, - ).toEqual({ links: {} }) - }) - - it('removes a link to a removed operation in a document without components', () => { - expect( - convertSpec({ - paths: { - '/a': { - get: { - callbacks: { junk: 42 }, - responses: { 200: { description: 'ok', links: { l: { operationRef: '#/webhooks/w/post' } } } }, - }, - }, - }, - webhooks: { w: { post: { operationId: 'hook', responses: {} } } }, - }).paths, - ).toEqual({ - '/a': { get: { callbacks: { junk: 42 }, responses: { 200: { description: 'ok', links: {} } } } }, - }) - }) - - it('removes discriminator mapping entries into the removed parts', () => { - expect( - convertSpec({ - components: { - schemas: { - Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, - Pet: { - discriminator: { - mapping: { - cat: '#/components/schemas/Cat', - dog: schemaPointer, - fish: 'Fish', - hamster: '#/components/pathItems/Item', - }, - propertyName: 'kind', - }, - }, - }, - }, - }).components, - ).toEqual({ - schemas: { - Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, - Pet: { - discriminator: { - mapping: { cat: '#/components/schemas/Cat', fish: 'Fish' }, - propertyName: 'kind', - }, - }, - }, - }) - }) - - it('inlines into a cyclic input graph, preserving its cycle', () => { - const node: Record = { type: 'object' } - node.properties = { hook: { $ref: schemaPointer }, self: node } - const result = convertSpec({ components: { schemas: { Node: node } }, webhooks: { newPet: hook } }) - const converted = dig(result, 'components', 'schemas', 'Node') - expect(dig(converted, 'properties', 'self')).toBe(converted) - expect(dig(converted, 'properties', 'hook')).toEqual({ properties: { name: { type: 'string' } }, type: 'object' }) - }) - - it('inlines references in operation, path item, media type, parameter, and encoding positions', () => { - const pointer = (path: string) => `#/webhooks/full/post/${path}` - const result = convertSpec({ - paths: { - '/a': { - get: { - parameters: [{ - examples: { e: { $ref: pointer('x-example') } }, - in: 'query', - name: 'q', - }], - requestBody: { $ref: pointer('requestBody') }, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: { $ref: pointer('x-header') } } } }, - examples: { e: { $ref: pointer('x-example') } }, - }, - }, - description: 'ok', - }, - }, - }, - parameters: [{ $ref: pointer('x-parameter') }], - }, - }, - webhooks: { - full: { - post: { - 'requestBody': { content: { 'text/plain': { schema: { const: 'x' } } } }, - 'x-example': { value: 1 }, - 'x-header': { schema: { type: ['string', 'null'] } }, - 'x-parameter': { in: 'path', name: 'id' }, - }, - }, - }, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - parameters: [{ examples: { e: { value: 1 } }, in: 'query', name: 'q' }], - requestBody: { content: { 'text/plain': { schema: { enum: ['x'] } } } }, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: { schema: { nullable: true, type: 'string' } } } } }, - examples: { e: { value: 1 } }, - }, - }, - description: 'ok', - }, - }, - }, - parameters: [{ in: 'path', name: 'id', required: true }], - }, - }) - }) - - it('cuts callbacks that reach back into an enclosing callback, keeping the output acyclic', () => { - const responses = { 200: { description: 'ok' } } - const result = convertSpec({ - components: { - pathItems: { - Item: { - post: { - callbacks: { - A: { '{$url}': { post: { callbacks: { toB: { $ref: '#/components/pathItems/Item/post/callbacks/B' } }, responses } } }, - B: { '{$url}': { post: { callbacks: { toA: { $ref: '#/components/pathItems/Item/post/callbacks/A' } }, responses } } }, - }, - responses, - }, - }, - }, - }, - paths: { - '/item': { $ref: '#/components/pathItems/Item' }, - '/self': { $ref: '#/webhooks/w' }, - }, - webhooks: { - w: { - post: { - callbacks: { cb: { '{$url}': { post: { callbacks: { again: { $ref: '#/webhooks/w/post/callbacks/cb' } }, responses } } } }, - responses, - }, - }, - }, - }) - expect(dig(result, 'paths', '/self', 'post', 'callbacks', 'cb', '{$url}', 'post', 'callbacks')).toEqual({ again: {} }) - expect(dig(result, 'paths', '/item', 'post', 'callbacks', 'A', '{$url}', 'post', 'callbacks', 'toB', '{$url}', 'post', 'callbacks')).toEqual({ toA: {} }) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) - }) - - it('cuts own fields that lead back into a path item still being converted', () => { - const responses = { 200: { description: 'ok' } } - const loop = { - $ref: '#/components/pathItems/T', - get: { callbacks: { d: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses }, - } - const result = convertSpec({ - components: { - callbacks: { C: { '{$url}': { $ref: '#/components/pathItems/A/post/callbacks/c/{$url}' } } }, - pathItems: { - A: { post: { callbacks: { c: { '{$url}': loop } }, responses } }, - T: { summary: 't' }, - }, - }, - }) - expect(dig(result, 'components', 'callbacks', 'C', '{$url}', 'get', 'callbacks', 'd', '{$url}', 'post', 'callbacks')).toEqual({ - c: { '{$url}': { summary: 't' } }, - }) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) - }) - - it('cuts fields inherited from a later hop that lead back into it', () => { - const responses = { 200: { description: 'ok' } } - const result = convertSpec({ - components: { - pathItems: { - A: { $ref: '#/components/pathItems/T', post: { callbacks: { c: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses } }, - T: { summary: 't' }, - }, - }, - paths: { '/p': { $ref: '#/components/pathItems/A' } }, - }) - expect(result.paths).toEqual({ - '/p': { post: { callbacks: { c: { '{$url}': { summary: 't' } } }, responses }, summary: 't' }, - }) - }) - - it('keeps an object cycle that an inlined target also reaches', () => { - const a: Record = { properties: {}, type: 'object' } - const b = { properties: { back: a }, type: 'object' } - a.properties = { hook: { $ref: schemaPointer }, b } - const result = convertSpec({ - components: { schemas: { A: a } }, - webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b }, type: 'object' } } } } } } }, - }) - const converted = dig(result, 'components', 'schemas', 'A') - expect(dig(converted, 'properties', 'b', 'properties', 'back')).toBe(converted) - expect(dig(converted, 'properties', 'hook', 'properties', 'b', 'properties', 'back')).toEqual({}) - }) - - it('cuts a reference that comes back to an object shared within the input', () => { - const shared: Record = { properties: { a: { $ref: schemaPointer } }, type: 'object' } - const result = convertSpec({ - components: { schemas: { S: shared } }, - webhooks: { - newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b: shared }, type: 'object' } } } } } }, - }, - }) - expect(dig(result, 'components', 'schemas', 'S')).toEqual({ - properties: { a: { properties: { b: {} }, type: 'object' } }, - type: 'object', - }) - }) - - it('cuts a callback that reaches back into the path item that contains it', () => { - const result = convertSpec({ - components: { callbacks: { C: { $ref: '#/webhooks/ping/post/callbacks/self' } } }, - webhooks: { - ping: { post: { callbacks: { self: { expr: { $ref: '#/webhooks/ping' } } }, responses: {} } }, - }, - }) - expect(dig(result, 'components', 'callbacks', 'C')).toEqual({ - expr: { post: { callbacks: { self: {} }, responses: {} } }, - }) - }) - - it('keeps the fields of every hop when it cuts a recursive path item', () => { - const result = convertSpec({ - paths: { '/a': { $ref: '#/webhooks/a' } }, - webhooks: { - a: { post: { callbacks: { cb: { expr: { $ref: '#/webhooks/alias', summary: 'outer' } } }, responses: {} } }, - alias: { $ref: '#/webhooks/a', description: 'alias' }, - }, - }) - expect(dig(result, 'paths', '/a', 'post', 'callbacks', 'cb', 'expr')).toEqual({ description: 'alias', summary: 'outer' }) - }) - - it('converts path items and headers reached through many references once', () => { - const webhooks: Record = { w30: { 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } } } - for (let index = 0; index < 30; index++) { - const next = { $ref: `#/webhooks/w${index + 1}` } - const header = { $ref: `#/webhooks/w${index + 1}/x-header` } - webhooks[`w${index}`] = { - 'get': { callbacks: { a: { expr: next }, b: { expr: next } }, responses: {} }, - 'x-header': { content: { 'text/plain': { encoding: { e: { headers: { a: header, b: header } } } } } }, - } - } - const result = convertSpec({ - components: { headers: { H: { $ref: '#/webhooks/w0/x-header' } } }, - paths: { '/a': { $ref: '#/webhooks/w0' } }, - webhooks, - }) - let pathItem = dig(result, 'paths', '/a') - let header = dig(result, 'components', 'headers', 'H') - for (let index = 0; index < 30; index++) { - expect(dig(pathItem, 'get', 'callbacks', 'a', 'expr')).toBe(dig(pathItem, 'get', 'callbacks', 'b', 'expr')) - pathItem = dig(pathItem, 'get', 'callbacks', 'a', 'expr') - const headers = dig(header, 'content', 'text/plain', 'encoding', 'e', 'headers') - expect(dig(headers, 'a')).toBe(dig(headers, 'b')) - header = dig(headers, 'a') - } - expect(pathItem).toEqual({ 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } }) - expect(header).toEqual({ schema: { type: 'string' } }) - }) - - it.each([ - ['a schema property named callbacks', '#/webhooks/w/post/requestBody/content/a~1b/schema/properties/callbacks/properties/x'], - ['a webhook named callbacks', '#/webhooks/callbacks/get/responses'], - ['a callback extension', '#/webhooks/w/post/callbacks/c/x-note'], - ])('leaves a Path Item $ref to %s as written', (_name, ref) => { - expect( - convertSpec({ - paths: { '/a': { $ref: ref } }, - webhooks: { - callbacks: { get: { responses: { 200: { description: 'ok' } } } }, - w: { - post: { - callbacks: { c: { 'x-note': { get: {} } } }, - requestBody: { - content: { 'a/b': { schema: { properties: { callbacks: { properties: { x: { get: 'prop', type: 'string' } } } } } } }, - }, - }, - }, - }, - }).paths, - ).toEqual({ '/a': { $ref: ref } }) - }) - - it('inlines a Path Item $ref to a callback nested in another callback', () => { - expect( - convertSpec({ - paths: { '/a': { $ref: '#/webhooks/w/post/callbacks/c/{$url}/get/callbacks/d/{$url}' } }, - webhooks: { - w: { post: { callbacks: { c: { '{$url}': { get: { callbacks: { d: { '{$url}': { summary: 'nested' } } } } } } } } }, - }, - }).paths, - ).toEqual({ '/a': { summary: 'nested' } }) - }) - - it('removes security schemes aliased into the removed parts by type', () => { - const result = convertSpec({ - components: { - securitySchemes: { - 'Escaped': { $ref: '#/components/securitySchemes/m~1tls' }, - 'Http': { $ref: '#/webhooks/w/x-http' }, - 'm/tls': { type: 'mutualTLS' }, - 'Tls': { $ref: '#/webhooks/w/x-tls' }, - }, - }, - paths: { '/a': { get: { responses: {}, security: [{ Tls: [] }, { Escaped: [] }, { Http: ['read'] }] } } }, - security: [{ Tls: [] }], - webhooks: { w: { 'x-http': { scheme: 'bearer', type: 'http' }, 'x-tls': { type: 'mutualTLS' } } }, - }) - expect(result.components).toEqual({ securitySchemes: { Http: { scheme: 'bearer', type: 'http' } } }) - expect(result.security).toBeUndefined() - expect(dig(result, 'paths', '/a', 'get', 'security')).toEqual([{ Http: [] }]) - }) - - it('leaves references and mapping entries in a standalone schema untouched', () => { - const schema = { - discriminator: { mapping: { a: schemaPointer }, propertyName: 'kind' }, - properties: { a: { $ref: schemaPointer } }, - } - expect(downgradeSchemaV31ToV30(schema as any)).toEqual(schema) - }) - }) - - describe('reference objects', () => { - it('strips reference summary and description across components maps', () => { - expect( - convertSpec({ - components: { - callbacks: { C: { $ref: '#/c/cb', summary: 's' } }, - examples: { E: { $ref: '#/c/e', description: 'd' } }, - headers: { H: { $ref: '#/c/h', summary: 's' } }, - links: { L: { $ref: '#/c/l', description: 'd' } }, - parameters: { - P: { $ref: '#/c/p', description: 'd', summary: 's' }, - }, - requestBodies: { B: { $ref: '#/c/b', summary: 's' } }, - responses: { R: { $ref: '#/c/r', description: 'd' } }, - securitySchemes: { S: { $ref: '#/c/s', description: 'd' } }, - }, - }).components, - ).toEqual({ - callbacks: { C: { $ref: '#/c/cb' } }, - examples: { E: { $ref: '#/c/e' } }, - headers: { H: { $ref: '#/c/h' } }, - links: { L: { $ref: '#/c/l' } }, - parameters: { P: { $ref: '#/c/p' } }, - requestBodies: { B: { $ref: '#/c/b' } }, - responses: { R: { $ref: '#/c/r' } }, - securitySchemes: { S: { $ref: '#/c/s' } }, - }) - }) - - it('strips reference overrides inside operations and path items', () => { - expect( - convertPathItem({ - get: { - callbacks: { cb: { $ref: '#/c/cb', summary: 's' } }, - parameters: [{ $ref: '#/c/p', description: 'd' }], - requestBody: { $ref: '#/c/b', summary: 's' }, - responses: { 200: { $ref: '#/c/r', summary: 's' } }, - }, - parameters: [{ $ref: '#/c/pp', summary: 's' }], - }), - ).toEqual({ - get: { - callbacks: { cb: { $ref: '#/c/cb' } }, - parameters: [{ $ref: '#/c/p' }], - requestBody: { $ref: '#/c/b' }, - responses: { 200: { $ref: '#/c/r' } }, - }, - parameters: [{ $ref: '#/c/pp' }], - }) - }) - - it('strips reference overrides in response headers, links, and media type examples', () => { - expect( - convertComponent('responses', { - content: { - 'application/json': { - examples: { e: { $ref: '#/c/e', summary: 's' } }, - schema: { type: ['string', 'null'] }, - }, - }, - description: 'ok', - headers: { H: { $ref: '#/c/h', summary: 's' } }, - links: { l: { $ref: '#/c/l', description: 'd' } }, - }), - ).toEqual({ - content: { - 'application/json': { - examples: { e: { $ref: '#/c/e' } }, - schema: { nullable: true, type: 'string' }, - }, - }, - description: 'ok', - headers: { H: { $ref: '#/c/h' } }, - links: { l: { $ref: '#/c/l' } }, - }) - }) - - it('keeps x- entries in a responses map unconverted', () => { - const responses = { - '200': { description: 'ok' }, - 'x-note': { $ref: '#/c/r', summary: 's' }, - } - expect(convertPathItem({ get: { responses } })).toEqual({ - get: { responses }, - }) - }) - }) - - describe('operations', () => { - it('synthesizes a minimal default responses object when an operation lacks one', () => { - expect(convertPathItem({ get: { operationId: 'getA' } })).toEqual({ - get: { - operationId: 'getA', - responses: { default: { description: '' } }, - }, - }) - }) - - it('converts parameter schemas, content, and examples', () => { - expect( - convertPathItem({ - get: { - parameters: [ - { - examples: { e: { $ref: '#/c/e', summary: 's' } }, - in: 'query', - name: 'p', - schema: { type: ['string', 'null'] }, - }, - { - content: { - 'text/plain': { schema: { type: ['integer', 'null'] } }, - }, - in: 'query', - name: 'q', - }, - ], - responses: {}, - }, - }), - ).toEqual({ - get: { - parameters: [ - { - examples: { e: { $ref: '#/c/e' } }, - in: 'query', - name: 'p', - schema: { nullable: true, type: 'string' }, - }, - { - content: { - 'text/plain': { schema: { nullable: true, type: 'integer' } }, - }, - in: 'query', - name: 'q', - }, - ], - responses: {}, - }, - }) - }) - - it('adds required: true to path parameters that lack it', () => { - expect( - convertPathItem({ - get: { - parameters: [ - { - content: { 'text/plain': { schema: { type: 'string' } } }, - in: 'path', - name: 'id', - }, - { in: 'query', name: 'q', schema: {} }, - ], - responses: {}, - }, - }), - ).toEqual({ - get: { - parameters: [ - { - content: { 'text/plain': { schema: { type: 'string' } } }, - in: 'path', - name: 'id', - required: true, - }, - { in: 'query', name: 'q', schema: {} }, - ], - responses: {}, - }, - }) - }) - - it('converts request body content, media type encoding, and encoding headers', () => { - expect( - convertComponent('requestBodies', { - content: { - 'multipart/form-data': { - encoding: { - field: { - contentType: 'text/plain', - headers: { - H: { $ref: '#/c/h', summary: 's' }, - H2: { schema: { type: ['string', 'null'] } }, - }, - }, - }, - example: { field: 'v' }, - schema: { type: 'object' }, - }, - }, - description: 'body', - required: true, - }), - ).toEqual({ - content: { - 'multipart/form-data': { - encoding: { - field: { - contentType: 'text/plain', - headers: { - H: { $ref: '#/c/h' }, - H2: { schema: { nullable: true, type: 'string' } }, - }, - }, - }, - example: { field: 'v' }, - schema: { type: 'object' }, - }, - }, - description: 'body', - required: true, - }) - }) - - it('converts inline callback objects, cloning x- keys and junk entries', () => { - expect( - convertPathItem({ - get: { - callbacks: { - inline: { 'expr': { get: {} }, 'x-k': { expr: { get: {} } } }, - junk: 7, - }, - responses: {}, - }, - }), - ).toEqual({ - get: { - callbacks: { - inline: { - 'expr': { get: { responses: { default: { description: '' } } } }, - 'x-k': { expr: { get: {} } }, - }, - junk: 7, - }, - responses: {}, - }, - }) - }) - }) - - describe('form request body parts', () => { - const octetStream = { contentType: 'application/octet-stream' } - - function convertForm(mediaType: unknown, type = 'multipart/form-data'): unknown { - const result = convertSpec({ - components: { - requestBodies: { X: { content: { [type]: mediaType } } }, - schemas: { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} }, - }, - }) - return dig(result, 'components', 'requestBodies', 'X', 'content', type) - } - - it.each([ - ['a schema without type', {}], - ['a true schema', true], - ['raw binary', { contentMediaType: 'image/png' }], - ['a base64 string', { contentEncoding: 'base64', type: 'string' }], - ['a string with a contentEncoding that no 3.0 format expresses', { contentEncoding: 'base64url', type: 'string' }], - ['an array of untyped items', { items: {}, type: 'array' }], - ['an array of raw binary', { items: { contentMediaType: 'image/png' }, type: 'array' }], - ['an array without items', { type: 'array' }], - ['untyped anyOf branches', { anyOf: [{ contentMediaType: 'image/png' }, { contentMediaType: 'image/jpeg' }] }], - ['a reference to an untyped schema', { $ref: '#/components/schemas/Raw' }], - ])('sets contentType: application/octet-stream, the 3.1 default, on %s', (_name, part) => { - expect(convertForm({ schema: { properties: { part } } })).toEqual({ - encoding: { part: octetStream }, - schema: { properties: { part: expect.anything() } }, - }) - }) - - it('writes the same Encoding Object whether the body schema is inline or a reference', () => { - const result = convertSpec({ - components: { - requestBodies: { - Inline: { content: { 'multipart/form-data': { schema: { properties: { img: { contentMediaType: 'image/png' } } } } } }, - Referenced: { content: { 'multipart/form-data': { schema: { $ref: '#/components/schemas/Upload' } } } }, - }, - schemas: { Upload: { properties: { img: { contentMediaType: 'image/png' } } } }, - }, - }) - for (const name of ['Inline', 'Referenced']) { - expect(dig(result, 'components', 'requestBodies', name, 'content', 'multipart/form-data', 'encoding')).toEqual({ img: octetStream }) - } - }) - - it.each([ - ['a string', { format: 'uuid', type: 'string' }], - ['an object', { type: 'object' }], - ['a type found through allOf', { allOf: [{ $ref: '#/components/schemas/Pet' }] }], - ['a null type', { type: 'null' }], - ['several types', { type: ['string', 'integer'] }], - ['typed prefixItems', { prefixItems: [{ type: 'string' }], type: 'array' }], - ['nested arrays, which have no multipart form', { items: { items: {}, type: 'array' }, type: 'array' }], - ['an external reference', { $ref: 'other.yaml#/File' }], - ['a missing reference', { $ref: '#/components/schemas/Missing' }], - ['a false schema', false], - ])('adds no Encoding Object for %s', (_name, part) => { - expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') - }) - - it('adds no Encoding Object for an array whose items loop back to it', () => { - const part: Record = { type: 'array' } - part.items = part - expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') - }) - - it('keeps Encoding Objects that set contentType or RFC6570-style fields, and adds contentType beside headers', () => { - const headers = { 'X-Id': { schema: { type: 'string' } } } - expect( - convertForm({ - encoding: { - exploded: { explode: true }, - explicit: { contentType: 'image/png' }, - headed: { headers }, - junk: 'junk', - reserved: { allowReserved: true }, - styled: { style: 'form' }, - }, - schema: { properties: { exploded: {}, explicit: {}, headed: {}, junk: {}, reserved: {}, styled: {} } }, - }), - ).toEqual({ - encoding: { - exploded: { explode: true }, - explicit: { contentType: 'image/png' }, - headed: { ...octetStream, headers }, - junk: 'junk', - reserved: { allowReserved: true }, - styled: { style: 'form' }, - }, - schema: { properties: { exploded: {}, explicit: {}, headed: {}, junk: {}, reserved: {}, styled: {} } }, - }) - }) - - it('finds parts through references and allOf in the body schema, including keys named like Object.prototype members', () => { - expect(convertForm({ schema: { $ref: '#/components/schemas/Form' } })).toEqual({ - encoding: { a: octetStream, b: octetStream }, - schema: { $ref: '#/components/schemas/Form' }, - }) - const encoding = dig(convertForm({ schema: { properties: JSON.parse('{"__proto__":{}}') } }), 'encoding') as object - expect(Object.getPrototypeOf(encoding)).toBe(Object.prototype) - expect(Object.hasOwn(encoding, '__proto__')).toBe(true) - }) - - it('applies to multipart and URL-encoded request bodies only', () => { - const mediaType = { schema: { properties: { file: {} } } } - for (const type of ['multipart/mixed', 'Application/X-WWW-Form-Urlencoded; charset=utf-8']) { - expect(convertForm(mediaType, type)).toEqual({ ...mediaType, encoding: { file: octetStream } }) - } - for (const type of ['application/json', 'application/x-www-form-urlencoded-v2']) { - expect(convertForm(mediaType, type)).toEqual(mediaType) - } - const content = { 'multipart/form-data': mediaType } - expect(convertComponent('responses', { content, description: 'd' })).toEqual({ content, description: 'd' }) - expect(convertComponent('parameters', { content, in: 'query', name: 'q' })).toEqual({ content, in: 'query', name: 'q' }) - }) - - it('converts a media type shared between a form body and a response as each', () => { - const mediaType = { schema: { properties: { file: {} } } } - const result = convertSpec({ - components: { - requestBodies: { B: { content: { 'multipart/form-data': mediaType } } }, - responses: { R: { content: { 'multipart/form-data': mediaType }, description: 'd' } }, - }, - }) - expect(dig(result, 'components', 'requestBodies', 'B', 'content', 'multipart/form-data')).toEqual({ ...mediaType, encoding: { file: octetStream } }) - expect(dig(result, 'components', 'responses', 'R', 'content', 'multipart/form-data')).toEqual(mediaType) - }) - - it('leaves an Encoding Object shared with another part unchanged', () => { - const entry = { headers: { 'X-Id': { schema: { type: 'string' } } } } - const content = { - 'multipart/form-data': { encoding: { part: entry }, schema: { properties: { part: {} } } }, - 'multipart/mixed': { encoding: { part: entry }, schema: { properties: { part: { type: 'string' } } } }, - } - const result = dig(convertComponent('requestBodies', { content }), 'content') - expect(dig(result, 'multipart/form-data', 'encoding', 'part')).toEqual({ ...entry, ...octetStream }) - expect(dig(result, 'multipart/mixed', 'encoding', 'part')).toEqual(entry) - }) - - it('leaves a malformed encoding value alone', () => { - expect(convertForm({ encoding: 'junk', schema: { properties: { file: {} } } })).toEqual({ - encoding: 'junk', - schema: { properties: { file: {} } }, - }) - }) - }) - - describe('components', () => { - it('removes pathItems and keeps the other component maps', () => { - const result = convertSpec({ - components: { - pathItems: { Reusable: { get: { summary: 's' } } }, - schemas: { S: { type: 'string' } }, - }, - }) - expect(result.components).toEqual({ schemas: { S: { type: 'string' } } }) - expect(result.components).not.toHaveProperty('x-pathItems') - }) - - it('strips overrides from a callback reference to a missing path item', () => { - expect( - convertComponent('callbacks', { - $ref: '#/components/pathItems/Reusable', - summary: 's', - }), - ).toEqual({ $ref: '#/components/pathItems/Reusable' }) - }) - - it('converts component callbacks and schemas, including boolean schemas', () => { - expect( - convertSpec({ - components: { - 'callbacks': { - junkCallback: 42, - realCallback: { - 'x-note': { '{$expr}': { get: {} } }, - '{$request.body#/url}': { post: { summary: 's' } }, - }, - }, - 'schemas': { S: { type: ['string', 'null'] }, T: true }, - 'x-extra': { keep: true }, - }, - }).components, - ).toEqual({ - 'callbacks': { - junkCallback: 42, - realCallback: { - 'x-note': { '{$expr}': { get: {} } }, - '{$request.body#/url}': { - post: { - responses: { default: { description: '' } }, - summary: 's', - }, - }, - }, - }, - 'schemas': { S: { nullable: true, type: 'string' }, T: {} }, - 'x-extra': { keep: true }, - }) - }) - - it('clones a malformed components value unchanged', () => { - expect(convertSpec({ components: 'junk' }).components).toBe('junk') - }) - }) - - describe('security', () => { - const apiKey = { in: 'header', name: 'k', type: 'apiKey' } - - it('removes mutualTLS schemes and drops requirements that become empty', () => { - const result = convertSpec({ - components: { - securitySchemes: { api: apiKey, mtls: { type: 'mutualTLS' } }, - }, - security: [{ mtls: [] }, { api: [], mtls: [] }, {}], - }) - expect(result.components).toEqual({ securitySchemes: { api: apiKey } }) - expect(result.security).toEqual([{ api: [] }, {}]) - }) - - it('removes reference aliases of mutualTLS schemes and their requirements', () => { - const result = convertSpec({ - components: { - securitySchemes: { - api: apiKey, - clientCert: { $ref: '#/components/securitySchemes/mtlsBase' }, - mtlsBase: { type: 'mutualTLS' }, - }, - }, - security: [{ clientCert: [] }, { api: [] }], - }) - expect(result.components).toEqual({ securitySchemes: { api: apiKey } }) - expect(result.security).toEqual([{ api: [] }]) - }) - - it('survives cyclic, dangling, external, and malformed scheme aliases', () => { - const securitySchemes = { - dangling: { $ref: '#/components/securitySchemes/missing' }, - external: { $ref: 'https://example.com/s.json#/schemes/a' }, - junk: 42, - nested: { $ref: '#/components/securitySchemes/a/b' }, - ping: { $ref: '#/components/securitySchemes/pong' }, - pong: { $ref: '#/components/securitySchemes/ping' }, - } - const security = [{ dangling: ['a'], external: ['b'], junk: ['c'], nested: ['d'], ping: ['e'] }] - expect(convertSpec({ components: { securitySchemes }, security })).toMatchObject({ components: { securitySchemes }, security }) - }) - - it('empties roles on non-OAuth schemes and keeps them elsewhere', () => { - expect( - convertSpec({ - components: { - securitySchemes: { - api: apiKey, - basic: { scheme: 'basic', type: 'http' }, - oauth: { flows: {}, type: 'oauth2' }, - oidc: { openIdConnectUrl: 'https://x', type: 'openIdConnect' }, - }, - }, - security: [ - { api: ['read'], basic: ['admin'] }, - { oauth: ['read'], oidc: ['a'], unknownScheme: ['s'] }, - ], - }).security, - ).toEqual([ - { api: [], basic: [] }, - { oauth: ['read'], oidc: ['a'], unknownScheme: ['s'] }, - ]) - }) - - it('converts operation-level security lists', () => { - expect( - convertSpec({ - components: { - securitySchemes: { api: apiKey, mtls: { type: 'mutualTLS' } }, - }, - paths: { - '/a': { - get: { - responses: {}, - security: [{ mtls: [] }, { api: ['read'] }], - }, - }, - }, - }).paths, - ).toEqual({ '/a': { get: { responses: {}, security: [{ api: [] }] } } }) - }) - - it('omits a security list that mutualTLS removal emptied instead of making it public', () => { - const result = convertSpec({ - components: { securitySchemes: { mtls: { type: 'mutualTLS' } } }, - paths: { - '/admin': { get: { responses: {}, security: [{ mtls: [] }] } }, - }, - security: [{ mtls: [] }], - }) - expect(result.paths).toEqual({ '/admin': { get: { responses: {} } } }) - expect(result).not.toHaveProperty('security') - }) - - it('keeps an explicitly empty security list', () => { - const result = convertSpec({ - paths: { '/a': { get: { responses: {}, security: [] } } }, - security: [], - }) - expect(result.paths).toEqual({ - '/a': { get: { responses: {}, security: [] } }, - }) - expect(result.security).toEqual([]) - }) - - it('clones malformed security values and scheme maps unchanged', () => { - expect( - convertSpec({ security: [{ api: [] }, 'junk', 42] }).security, - ).toEqual([{ api: [] }, 'junk', 42]) - expect(convertSpec({ security: { api: [] } }).security).toEqual({ - api: [], - }) - expect( - convertSpec({ components: { securitySchemes: 'junk' } }).components, - ).toEqual({ - securitySchemes: 'junk', - }) - }) - }) - - describe('shared objects', () => { - it('converts an object shared between an operation and a schema as each', () => { - const empty = {} - for (const fields of [ - { components: { schemas: { S: empty } }, paths: { '/a': { get: empty } } }, - { paths: { '/a': { get: empty } }, components: { schemas: { S: empty } } }, - ]) { - const result = convertSpec(fields) - expect(dig(result, 'paths', '/a', 'get')).toEqual({ responses: { default: { description: '' } } }) - expect(dig(result, 'components', 'schemas', 'S')).toEqual({}) - } - }) - }) - - describe('robustness', () => { - it('never mutates the input document', () => { - const input: OpenAPIV3_1.OpenAPIObject = { - components: { - pathItems: { Reusable: { get: { summary: 's' } } }, - schemas: { S: { $ref: '#/c/s', type: ['string', 'null'] } }, - securitySchemes: { - api: { in: 'header', name: 'k', type: 'apiKey' }, - mtls: { type: 'mutualTLS' }, - }, - }, - info: { - license: { identifier: 'MIT', name: 'MIT' }, - summary: 'short', - title: 't', - version: '1', - }, - jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', - openapi: '3.1.0', - paths: { - '/a': { - get: { - parameters: [{ $ref: '#/c/p', summary: 's' }], - security: [{ mtls: [] }, { api: ['read'] }], - }, - }, - }, - security: [{ mtls: [] }], - webhooks: { newPet: { post: { summary: 's' } } }, - } - const before = structuredClone(input) - downgradeSpecV31ToV30(input) - expect(input).toEqual(before) - }) - - it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { - const callback: Record = {} - const pathItem: Record = { - get: { callbacks: { cb: callback } }, - } - callback.expr = pathItem - const result = convertPathItem(pathItem) - expect(dig(result, 'get', 'responses')).toEqual({ default: { description: '' } }) - expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) - }) - - it('converts a dereferenced schema shared across the document once', () => { - const pet = { properties: { name: { type: ['string', 'null'] } }, type: 'object' } - const result = convertSpec({ - components: { schemas: { Pet: pet } }, - paths: { '/pets': { get: { responses: { 200: { content: { 'application/json': { schema: pet } }, description: 'ok' } } } } }, - }) - const schema = dig(result, 'components', 'schemas', 'Pet') - expect(schema).toEqual({ - properties: { name: { nullable: true, type: 'string' } }, - type: 'object', - }) - expect(dig(result, 'paths', '/pets', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toBe(schema) - }) - }) -}) - -describe('downgradeSchemaV31ToV30', () => { - describe('boolean and junk schemas', () => { - it('converts the boolean schemas', () => { - expect(downgradeSchemaV31ToV30(true)).toEqual({}) - expect(downgradeSchemaV31ToV30(false)).toEqual({ not: {} }) - }) - - it('clones junk input unchanged', () => { - expect(convertSchema(null)).toBeNull() - expect(convertSchema(42)).toBe(42) - expect(convertSchema('x')).toBe('x') - const list = [{ type: 'string' }] - const result = convertSchema(list) - expect(result).toEqual(list) - expect(result).not.toBe(list) - }) - }) - - describe('$ref', () => { - it('keeps a pure $ref as a bare reference object, wherever it points', () => { - const input = { $ref: '#/components/schemas/Pet' } - const result = downgradeSchemaV31ToV30(input) - expect(result).toEqual(input) - expect(result).not.toBe(input) - expect(convertSchema({ $ref: '#/components/pathItems/Foo' })).toEqual({ - $ref: '#/components/pathItems/Foo', - }) - }) - - it.each([ - [ - 'wraps a $ref with sibling keywords into allOf', - { $ref: '#/c/s', minLength: 1 }, - { allOf: [{ $ref: '#/c/s' }], minLength: 1 }, - ], - [ - 'merges a $ref into an existing allOf', - { $ref: '#/c/s', allOf: [{ type: 'string' }] }, - { allOf: [{ $ref: '#/c/s' }, { type: 'string' }] }, - ], - [ - 'nests a malformed allOf and moves the $ref into allOf', - { $ref: '#/c/s', allOf: 'junk' }, - { allOf: [{ $ref: '#/c/s' }, { allOf: 'junk' }] }, - ], - [ - 'passes a non-string $ref through unchanged', - { $ref: 123, type: 'string' }, - { $ref: 123, type: 'string' }, - ], - [ - 'passes a lone non-string $ref through unchanged', - { $ref: 123 }, - { $ref: 123 }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('type', () => { - it.each([ - ['keeps a single string type', { type: 'string' }, { type: 'string' }], - [ - 'converts a type array with null into type plus nullable', - { type: ['string', 'null'] }, - { nullable: true, type: 'string' }, - ], - [ - 'converts a null-only type array into a null enum', - { type: ['null'] }, - { enum: [null] }, - ], - [ - 'converts a null-only type string into a null enum', - { type: 'null' }, - { enum: [null] }, - ], - [ - 'intersects an existing enum with a null-only type', - { enum: ['a', null], type: ['null'] }, - { enum: [null] }, - ], - [ - 'matches nothing when the enum of a null-only type excludes null', - { enum: ['a'], type: ['null'] }, - { enum: ['a'], not: {} }, - ], - [ - 'clones a malformed enum of a null-only type through', - { enum: 'junk', type: ['null'] }, - { enum: 'junk' }, - ], - [ - 'keeps a null const as the enum of a null-only type', - { const: null, type: ['null'] }, - { enum: [null] }, - ], - [ - 'matches nothing when a non-null const contradicts a null-only type', - { const: 7, type: ['null'] }, - { enum: [7], not: {} }, - ], - [ - 'converts a null-only anyOf branch into a null enum', - { anyOf: [{ type: 'string' }, { type: 'null' }] }, - { anyOf: [{ type: 'string' }, { enum: [null] }] }, - ], - [ - 'converts multiple non-null types into anyOf variants', - { type: ['string', 'integer'] }, - { anyOf: [{ type: 'string' }, { type: 'integer' }] }, - ], - [ - 'converts multiple types with null into nullable anyOf variants', - { type: ['string', 'integer', 'null'] }, - { - anyOf: [ - { nullable: true, type: 'string' }, - { nullable: true, type: 'integer' }, - ], - }, - ], - [ - 'gives synthesized array variants an empty items', - { type: ['array', 'string'] }, - { anyOf: [{ items: {}, type: 'array' }, { type: 'string' }] }, - ], - [ - 'moves existing items into the synthesized array variant', - { items: { type: 'integer' }, type: ['array', 'string', 'null'] }, - { - anyOf: [ - { items: { type: 'integer' }, nullable: true, type: 'array' }, - { nullable: true, type: 'string' }, - ], - }, - ], - [ - 'keeps items in place when the type union has no array variant', - { items: { type: 'integer' }, type: ['object', 'string'] }, - { - anyOf: [{ type: 'object' }, { type: 'string' }], - items: { type: 'integer' }, - }, - ], - [ - 'wraps the type union into allOf when anyOf already exists', - { anyOf: [{ minLength: 1 }], type: ['string', 'integer'] }, - { - allOf: [{ anyOf: [{ type: 'string' }, { type: 'integer' }] }], - anyOf: [{ minLength: 1 }], - }, - ], - [ - 'appends the type union to an existing allOf when anyOf also exists', - { - allOf: [{ title: 't' }], - anyOf: [{ minLength: 1 }], - type: ['string', 'integer'], - }, - { - allOf: [ - { title: 't' }, - { anyOf: [{ type: 'string' }, { type: 'integer' }] }, - ], - anyOf: [{ minLength: 1 }], - }, - ], - [ - 'nests a malformed allOf beside the type union when anyOf exists', - { - allOf: 'junk', - anyOf: [{ type: 'string' }], - items: { type: 'integer' }, - type: ['array', 'string'], - }, - { - allOf: [{ allOf: 'junk' }, { anyOf: [{ items: { type: 'integer' }, type: 'array' }, { type: 'string' }] }], - anyOf: [{ type: 'string' }], - }, - ], - [ - 'deduplicates type array entries', - { type: ['string', 'string'] }, - { type: 'string' }, - ], - [ - 'ignores non-string type array entries beside valid ones', - { type: ['string', 42] }, - { type: 'string' }, - ], - [ - 'passes a type array of only junk entries through', - { type: [42] }, - { type: [42] }, - ], - ['passes a junk number type through', { type: 42 }, { type: 42 }], - [ - 'passes a junk object type through', - { type: { a: 1 } }, - { type: { a: 1 } }, - ], - ['drops an empty type array', { type: [] }, {}], - [ - 'adds empty items to an array type without items', - { type: 'array' }, - { items: {}, type: 'array' }, - ], - [ - 'adds empty items to a nullable array type without items', - { type: ['array', 'null'] }, - { items: {}, nullable: true, type: 'array' }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('const', () => { - it.each([ - [ - 'converts const into a single-value enum', - { const: 'a' }, - { enum: ['a'] }, - ], - ['converts a zero const', { const: 0 }, { enum: [0] }], - ['converts a false const', { const: false }, { enum: [false] }], - ['converts an empty-string const', { const: '' }, { enum: [''] }], - [ - 'converts a null const into a null enum', - { const: null }, - { enum: [null] }, - ], - [ - 'keeps the nullable variants of a multi-type null const', - { const: null, type: ['string', 'integer', 'null'] }, - { - anyOf: [ - { nullable: true, type: 'string' }, - { nullable: true, type: 'integer' }, - ], - enum: [null], - }, - ], - [ - 'matches nothing when a null const contradicts a non-null type', - { const: null, type: 'string' }, - { enum: [null], type: 'string' }, - ], - [ - 'replaces an existing enum with the const value', - { const: 5, enum: [1, 2] }, - { enum: [5] }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('exclusive bounds', () => { - it.each([ - [ - 'converts a numeric exclusiveMinimum into minimum plus flag', - { exclusiveMinimum: 3 }, - { exclusiveMinimum: true, minimum: 3 }, - ], - [ - 'keeps a tighter inclusive minimum and drops the exclusive one', - { exclusiveMinimum: 3, minimum: 5 }, - { minimum: 5 }, - ], - [ - 'overrides a looser inclusive minimum with the exclusive bound', - { exclusiveMinimum: 5, minimum: 3 }, - { exclusiveMinimum: true, minimum: 5 }, - ], - [ - 'prefers the exclusive form for equal minimum bounds', - { exclusiveMinimum: 3, minimum: 3 }, - { exclusiveMinimum: true, minimum: 3 }, - ], - [ - 'converts a numeric exclusiveMaximum into maximum plus flag', - { exclusiveMaximum: 10 }, - { exclusiveMaximum: true, maximum: 10 }, - ], - [ - 'keeps a tighter inclusive maximum and drops the exclusive one', - { exclusiveMaximum: 10, maximum: 5 }, - { maximum: 5 }, - ], - [ - 'overrides a looser inclusive maximum with the exclusive bound', - { exclusiveMaximum: 5, maximum: 10 }, - { exclusiveMaximum: true, maximum: 5 }, - ], - [ - 'prefers the exclusive form for equal maximum bounds', - { exclusiveMaximum: 5, maximum: 5 }, - { exclusiveMaximum: true, maximum: 5 }, - ], - [ - 'passes a 3.0-style boolean exclusiveMinimum through', - { exclusiveMinimum: true, minimum: 3 }, - { exclusiveMinimum: true, minimum: 3 }, - ], - [ - 'passes a 3.0-style boolean exclusiveMaximum through', - { exclusiveMaximum: false, maximum: 3 }, - { exclusiveMaximum: false, maximum: 3 }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('examples', () => { - it.each([ - [ - 'promotes the first examples entry to example', - { examples: ['a', 'b'] }, - { example: 'a' }, - ], - [ - 'keeps an explicit example over the examples entries', - { example: 'e', examples: ['a'] }, - { example: 'e' }, - ], - ['drops empty examples arrays', { examples: [] }, {}], - ['drops non-array examples values', { examples: 'junk' }, {}], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('content keywords', () => { - it.each([ - [ - 'converts encoded binary into type string with format byte', - { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' }, - { format: 'byte', type: 'string' }, - ], - [ - 'converts raw binary into type string with format binary', - { contentMediaType: 'image/png' }, - { format: 'binary', type: 'string' }, - ], - [ - 'keeps nullable on binary strings', - { contentMediaType: 'image/png', type: ['string', 'null'] }, - { format: 'binary', nullable: true, type: 'string' }, - ], - [ - 'keeps format beside a multi-type anyOf that includes string', - { contentMediaType: 'image/png', type: ['string', 'integer'] }, - { anyOf: [{ type: 'string' }, { type: 'integer' }], format: 'binary' }, - ], - [ - 'keeps an existing format over contentEncoding', - { contentEncoding: 'base64', format: 'custom' }, - { format: 'custom', type: 'string' }, - ], - [ - 'drops content keywords on non-string types', - { contentMediaType: 'image/png', type: 'object' }, - { type: 'object' }, - ], - [ - 'drops base64url, which format byte does not accept', - { contentEncoding: 'base64url', contentMediaType: 'image/png', type: 'string' }, - { type: 'string' }, - ], - [ - 'drops non-string content media types', - { contentMediaType: 42 }, - {}, - ], - ['drops contentSchema', { contentSchema: { type: 'string' } }, {}], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('dropped keywords', () => { - it('removes every keyword with no 3.0 equivalent', () => { - expect( - convertSchema({ - $anchor: 'a', - $comment: 'c', - $defs: { D: { type: 'string' } }, - $dynamicAnchor: 'da', - $dynamicRef: '#dr', - $id: 'https://example.com/s', - $schema: 'https://json-schema.org/draft/2020-12/schema', - $vocabulary: { 'https://example.com/v': true }, - contains: { type: 'string' }, - contentSchema: { type: 'string' }, - dependentRequired: { a: ['b'] }, - dependentSchemas: { a: { type: 'object' } }, - else: { title: 'e' }, - if: { title: 'i' }, - maxContains: 2, - minContains: 1, - patternProperties: { '^x': { type: 'string' } }, - prefixItems: [{ type: 'string' }], - propertyNames: { pattern: '^a' }, - then: { title: 't' }, - type: 'string', - unevaluatedItems: false, - unevaluatedProperties: false, - }), - ).toEqual({ type: 'string' }) - }) - - it.each([ - [ - 'drops prefixItems together with its trailing items', - { items: { type: 'integer' }, prefixItems: [{ type: 'string' }] }, - {}, - ], - [ - 'drops boolean additionalProperties together with patternProperties', - { - additionalProperties: false, - patternProperties: { '^x-': {} }, - properties: { name: { type: 'string' } }, - type: 'object', - }, - { properties: { name: { type: 'string' } }, type: 'object' }, - ], - [ - 'drops schema-valued additionalProperties together with patternProperties', - { - additionalProperties: { type: 'integer' }, - patternProperties: { '^x-': {} }, - type: 'object', - }, - { type: 'object' }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('enum and required', () => { - it.each([ - [ - 'removes an empty enum', - { enum: [], type: 'string' }, - { type: 'string' }, - ], - [ - 'keeps a non-empty enum', - { enum: ['a'], type: 'string' }, - { enum: ['a'], type: 'string' }, - ], - ['drops an empty required array', { required: [] }, {}], - [ - 'keeps a non-empty required array', - { required: ['a'] }, - { required: ['a'] }, - ], - [ - 'deduplicates required entries', - { required: ['a', 'b', 'a'], type: 'object' }, - { required: ['a', 'b'], type: 'object' }, - ], - [ - 'clones a non-array required value unchanged', - { required: 'junk' }, - { required: 'junk' }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('subschemas', () => { - it.each([ - [ - 'converts nested property schemas', - { - properties: { a: { type: ['string', 'null'] }, b: true }, - type: 'object', - }, - { - properties: { a: { nullable: true, type: 'string' }, b: {} }, - type: 'object', - }, - ], - [ - 'keeps boolean additionalProperties', - { additionalProperties: false }, - { additionalProperties: false }, - ], - [ - 'converts schema additionalProperties', - { additionalProperties: { type: ['string', 'null'] } }, - { additionalProperties: { nullable: true, type: 'string' } }, - ], - [ - 'converts allOf, anyOf, oneOf, and not members', - { - allOf: [true], - anyOf: [{ const: 1 }], - not: false, - oneOf: [{ type: ['integer', 'null'] }], - }, - { - allOf: [{}], - anyOf: [{ enum: [1] }], - not: { not: {} }, - oneOf: [{ nullable: true, type: 'integer' }], - }, - ], - [ - 'clones a non-array allOf value unchanged', - { allOf: 'junk' }, - { allOf: 'junk' }, - ], - [ - 'keeps and converts items when there are no prefixItems', - { items: { type: ['string', 'null'] } }, - { items: { nullable: true, type: 'string' } }, - ], - ['converts a true items schema', { items: true }, { items: {} }], - [ - 'converts a false items schema', - { items: false }, - { items: { not: {} } }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('references into dropped keywords', () => { - it('inlines $refs into $defs, cutting recursion into {}', () => { - expect(convertSchema({ - $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, - $ref: '#/$defs/node', - })).toEqual({ allOf: [{ properties: { next: {} }, type: 'object' }] }) - expect(convertSchema({ $defs: { a: { type: 'string' } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ items: { type: 'string' }, type: 'array' }) - }) - - it('inlines $refs into $defs entries that reference an external file', () => { - expect(convertSchema({ - $defs: { pet: { $ref: './schemas/pet.yaml' } }, - properties: { pet: { $ref: '#/$defs/pet' } }, - })).toEqual({ properties: { pet: { $ref: './schemas/pet.yaml' } } }) - }) - - it('inlines a $ref to items removed beside prefixItems instead of the items placeholder', () => { - expect(convertSchema({ - properties: { - cell: { $ref: '#/properties/row/items' }, - notCell: { not: { $ref: '#/properties/row/items' } }, - row: { items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' }, - }, - })).toEqual({ - properties: { - cell: { type: 'integer' }, - notCell: { not: { type: 'integer' } }, - row: { items: {}, type: 'array' }, - }, - }) - }) - }) - - describe('never tightening what validates', () => { - it.each([ - ['drops a not whose operand lost a keyword', { not: { patternProperties: { a: {} } } }, {}], - ['drops a not whose operand is loosened deeper down', { not: { properties: { a: { if: {} } } } }, {}], - ['drops a not whose operand is a cut recursion', { $defs: { a: { not: { $ref: '#/$defs/a' } } }, $ref: '#/$defs/a' }, { allOf: [{}] }], - ['drops a not whose operand had an empty enum', { not: { enum: [] } }, {}], - ['drops a not whose null-only type has a malformed enum', { not: { enum: 'junk', type: 'null' } }, {}], - ['drops a not whose const falls outside its enum', { not: { const: 1, enum: [2] } }, {}], - ['keeps a not whose const lies inside its enum', { not: { const: 1, enum: [1, 2] } }, { not: { enum: [1] } }], - ['keeps a not whose operand converts exactly', { not: { type: ['string', 'null'] } }, { not: { nullable: true, type: 'string' } }], - ['keeps a not whose null-only operand matches nothing exactly', { not: { const: 'a', type: 'null' } }, { not: { enum: ['a'], not: {} } }], - ['drops both nots of a loosened double negation', { not: { not: { prefixItems: [] } } }, {}], - [ - 'turns a oneOf with a loosened branch into anyOf', - { oneOf: [{ prefixItems: [] }, { type: 'string' }] }, - { anyOf: [{}, { type: 'string' }] }, - ], - [ - 'nests that anyOf in allOf beside an existing anyOf', - { anyOf: [{ type: 'string' }], oneOf: [{ unevaluatedProperties: false }] }, - { allOf: [{ anyOf: [{}] }], anyOf: [{ type: 'string' }] }, - ], - [ - 'propagates loosening through items, additionalProperties, allOf, and anyOf', - { not: { allOf: [{ anyOf: [{ additionalProperties: { items: { contains: {} } } }] }] } }, - {}, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - - it('treats a cycle of the input graph as loosened under not and oneOf', () => { - const negated: any = { not: { properties: {} }, patternProperties: { '^x': { type: 'string' } } } - negated.not.properties.p = negated - expect(convertSchema(negated)).toEqual({}) - const tree: any = { oneOf: [{ required: ['value'], type: 'object' }], unevaluatedProperties: false } - tree.oneOf.push({ properties: { children: { items: tree, type: 'array' } }, type: 'object' }) - const out = convertSchema(tree) as any - expect(out.oneOf).toBeUndefined() - expect(out.anyOf[1].properties.children.items).toBe(out) - }) - }) - - describe('xml nodeType carried over from 3.2', () => { - it.each([ - [ - 'converts nodeType attribute to attribute: true', - { type: 'string', xml: { name: 'n', nodeType: 'attribute' } }, - { type: 'string', xml: { attribute: true, name: 'n' } }, - ], - [ - 'converts nodeType element on an array schema to wrapped: true', - { items: {}, type: 'array', xml: { nodeType: 'element' } }, - { items: {}, type: 'array', xml: { wrapped: true } }, - ], - [ - 'converts nodeType element on a nullable array schema to wrapped: true', - { type: ['array', 'null'], xml: { nodeType: 'element' } }, - { items: {}, nullable: true, type: 'array', xml: { wrapped: true } }, - ], - [ - 'removes nodeType element on non-array schemas', - { type: 'string', xml: { nodeType: 'element' } }, - { type: 'string', xml: {} }, - ], - [ - 'removes inexpressible nodeType values', - { type: 'string', xml: { name: 'n', nodeType: 'text' } }, - { type: 'string', xml: { name: 'n' } }, - ], - [ - 'clones xml objects without nodeType unchanged', - { type: 'string', xml: { attribute: true, name: 'n' } }, - { type: 'string', xml: { attribute: true, name: 'n' } }, - ], - [ - 'clones malformed xml values unchanged', - { type: 'string', xml: 'junk' }, - { type: 'string', xml: 'junk' }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - }) - - describe('extensions and unknown keywords', () => { - it('preserves x- keys and unknown keywords', () => { - const input = { 'customKeyword': 'v', 'title': 't', 'x-foo': { a: 1 } } - expect(convertSchema(input)).toEqual(input) - }) - - it('treats keywords named like Object.prototype members as unknown keywords', () => { - const input = { - constructor: 1, - hasOwnProperty: 2, - toString: 3, - type: 'string', - } - expect(convertSchema(input)).toEqual(input) - }) - }) - - describe('robustness', () => { - it('never mutates the input schema', () => { - const input: OpenAPIV3_1.SchemaObject = { - $ref: '#/c/s', - allOf: [{ type: 'string' }], - const: null, - examples: ['a'], - exclusiveMinimum: 5, - minimum: 3, - prefixItems: [{ type: 'string' }], - properties: { a: { type: ['string', 'null'] } }, - type: ['object', 'null'], - } - const before = structuredClone(input) - downgradeSchemaV31ToV30(input) - expect(input).toEqual(before) - }) - - it('converts deeply nested schemas without throwing', () => { - let deep: OpenAPIV3_1.SchemaObject = { type: 'string' } - for (let index = 0; index < 1000; index += 1) { - deep = { items: deep, type: 'array' } - } - expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow() - }) - - it('keeps nested multi-type arrays linear instead of doubling per level', () => { - let input: OpenAPIV3_1.SchemaObject = { type: 'string' } - let expected: unknown = { type: 'string' } - for (let index = 0; index < 10; index += 1) { - input = { items: input, type: ['array', 'object'] } - expected = { anyOf: [{ items: expected, type: 'array' }, { type: 'object' }] } - } - expect(convertSchema(input)).toEqual(expected) - }) - - it('converts a dereferenced cyclic schema, pointing the cycle at the converted ancestor', () => { - const properties: Record = {} - const node: Record = { - properties, - type: ['object', 'null'], - } - properties.self = node - properties.children = { items: node, type: 'array' } - const result = convertSchema(node) as Record - expect(result.type).toBe('object') - expect(result.nullable).toBe(true) - expect(dig(result, 'properties', 'self')).toBe(result) - expect(dig(result, 'properties', 'children', 'items')).toBe(result) - expect(node.type).toEqual(['object', 'null']) - }) - - it('converts a dereferenced schema reached along many paths once', () => { - let node: OpenAPIV3_1.SchemaObject = { type: ['string', 'null'] } - for (let index = 0; index < 64; index += 1) { - node = { properties: { left: node, right: node }, type: 'object' } - } - const result = convertSchema(node) - expect(dig(result, 'properties', 'left')).toBe(dig(result, 'properties', 'right')) - let leaf = result - for (let index = 0; index < 64; index += 1) { - leaf = dig(leaf, 'properties', 'left') - } - expect(leaf).toEqual({ nullable: true, type: 'string' }) - }) - - it('points the array variant of a cyclic multi-type schema at the converted schema', () => { - const node: Record = { type: ['array', 'object'] } - node.items = node - const result = convertSchema(node) as Record - expect(result).not.toHaveProperty('items') - expect(dig(result, 'anyOf', '0', 'items')).toBe(result) - expect(dig(result, 'anyOf', '1')).toEqual({ type: 'object' }) - expect(node.items).toBe(node) - }) - }) -}) diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts deleted file mode 100644 index faec5aa..0000000 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ /dev/null @@ -1,2169 +0,0 @@ -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { dig } from '../tests/helpers' -import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from './v3.2-to-v3.1' - -function convertSpec(fields: Record) { - return downgradeSpecV32ToV31({ openapi: '3.2.0', ...fields } as any) -} - -function convertPathItem(pathItem: unknown, components: Record = {}): unknown { - return dig(convertSpec({ components, paths: { '/a': pathItem } }), 'paths', '/a') -} - -function convertComponent(kind: string, value: unknown, components: Record = {}): unknown { - return dig( - convertSpec({ components: { ...components, [kind]: { X: value } } }), - 'components', - kind, - 'X', - ) -} - -function convertContent(content: unknown, components: Record = {}): unknown { - return dig( - convertPathItem( - { post: { requestBody: { content }, responses: {} } }, - components, - ), - 'post', - 'requestBody', - 'content', - ) -} - -describe('downgradeSpecV32ToV31', () => { - describe('document', () => { - it('rewrites the openapi field to 3.1.2', () => { - expect( - downgradeSpecV32ToV31({ - info: { title: 't', version: '1.0.0' }, - openapi: '3.2.0', - }), - ).toEqual({ - info: { title: 't', version: '1.0.0' }, - openapi: '3.1.2', - }) - }) - - it('adds openapi: 3.1.2 when the input has no openapi field', () => { - expect(downgradeSpecV32ToV31({} as any)).toEqual({ openapi: '3.1.2' }) - }) - - it('removes $self', () => { - expect(convertSpec({ $self: 'https://example.com/api.json' })).toEqual({ - openapi: '3.1.2', - }) - }) - - it.each([ - [ - 'rewrites the dated 3.2 OAS dialect to the 3.1 base dialect', - 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17', - 'https://spec.openapis.org/oas/3.1/dialect/base', - ], - [ - 'rewrites a draft 3.2 OAS dialect to the 3.1 base dialect', - 'https://spec.openapis.org/oas/3.2/dialect/WORK-IN-PROGRESS', - 'https://spec.openapis.org/oas/3.1/dialect/base', - ], - [ - 'keeps a 3.1 OAS dialect', - 'https://spec.openapis.org/oas/3.1/dialect/base', - 'https://spec.openapis.org/oas/3.1/dialect/base', - ], - [ - 'keeps a custom dialect', - 'https://example.com/my-dialect', - 'https://example.com/my-dialect', - ], - ['clones a malformed dialect through', { junk: true }, { junk: true }], - ])('%s', (_name, dialect, expected) => { - expect(convertSpec({ jsonSchemaDialect: dialect })).toEqual({ - jsonSchemaDialect: expected, - openapi: '3.1.2', - }) - }) - - it('returns non-object input unchanged', () => { - expect(downgradeSpecV32ToV31(null as any)).toBeNull() - expect(downgradeSpecV32ToV31('junk' as any)).toBe('junk') - expect(downgradeSpecV32ToV31([1, 2] as any)).toEqual([1, 2]) - }) - - it('preserves x- keys and unknown keys at the document, path item, and operation levels', () => { - const fields = { - 'futureKey': { anything: [1] }, - 'info': { title: 't', version: '1' }, - 'jsonSchemaDialect': 'https://spec.openapis.org/oas/3.1/dialect/base', - 'paths': { - '/a': { - 'get': { - 'operationId': 'getA', - 'responses': {}, - 'unknownOperationKey': 1, - 'x-op': true, - }, - 'unknownPathItemKey': 'kept', - 'x-item': [1, 2], - }, - }, - 'security': [{ oauth: ['read'] }], - 'x-root': { deep: { value: 1 } }, - } - expect(convertSpec(fields)).toEqual({ ...fields, openapi: '3.1.2' }) - }) - }) - - describe('servers', () => { - it('removes server name at the root, path item, operation, and link levels', () => { - const result = convertSpec({ - components: { - links: { L: { operationId: 'op', server: { name: 's', url: '/u' } } }, - }, - paths: { - '/a': { - get: { responses: {}, servers: [{ name: 's', url: '/u' }] }, - servers: [{ name: 's', url: '/u' }], - }, - }, - servers: [ - { description: 'd', name: 'prod', url: 'https://example.com' }, - ], - }) - expect(result).toEqual({ - components: { - links: { L: { operationId: 'op', server: { url: '/u' } } }, - }, - openapi: '3.1.2', - paths: { - '/a': { - get: { responses: {}, servers: [{ url: '/u' }] }, - servers: [{ url: '/u' }], - }, - }, - servers: [{ description: 'd', url: 'https://example.com' }], - }) - }) - - it('clones non-array servers and non-object server entries through', () => { - expect( - convertSpec({ - paths: { '/a': { servers: 'junk' } }, - servers: [5, null], - }), - ).toEqual({ - openapi: '3.1.2', - paths: { '/a': { servers: 'junk' } }, - servers: [5, null], - }) - }) - }) - - describe('tags', () => { - it('removes tag summary, parent, and kind and keeps other fields', () => { - expect( - convertSpec({ - tags: [ - { - description: 'd', - externalDocs: { url: 'https://example.com' }, - kind: 'nav', - name: 'pets', - parent: 'animals', - summary: 'Pets', - }, - 'junk', - 1, - ], - }).tags, - ).toEqual([ - { - description: 'd', - externalDocs: { url: 'https://example.com' }, - name: 'pets', - }, - 'junk', - 1, - ]) - }) - }) - - describe('paths and path items', () => { - it('removes the query operation and additionalOperations whatever their shape', () => { - expect( - convertSpec({ - paths: { - '/a': { - get: { responses: {} }, - query: { description: 'q', responses: {} }, - }, - '/b': { additionalOperations: { NOTIFY: { description: 'n' } } }, - '/c': { additionalOperations: 'junk' }, - '/d': { additionalOperations: 42, query: 'junk' }, - }, - }).paths, - ).toEqual({ - '/a': { get: { responses: {} } }, - '/b': {}, - '/c': {}, - '/d': {}, - }) - }) - - it('converts only keys starting with a slash and clones the rest', () => { - expect( - convertSpec({ - paths: { - '/a': { query: { description: 'dropped' } }, - 'x-meta': { query: { description: 'kept' } }, - }, - }).paths, - ).toEqual({ '/a': {}, 'x-meta': { query: { description: 'kept' } } }) - }) - - it('clones malformed paths, path items, and nested objects through', () => { - expect(convertSpec({ paths: 'junk' }).paths).toBe('junk') - const paths = { - '/a': { - get: 'junk', - post: { - requestBody: { - content: { - 'application/json': 42, - 'multipart/form-data': { - encoding: { field: 'junk' }, - example: 5, - }, - }, - }, - }, - put: { requestBody: 42, responses: { 200: 42 } }, - }, - } - expect(convertSpec({ paths }).paths).toEqual(paths) - }) - }) - - describe('parameters', () => { - it('removes querystring parameters from operation and path item lists, keeping neighbors and references', () => { - expect( - convertPathItem({ - get: { - parameters: [ - { - content: { 'application/x-www-form-urlencoded': {} }, - in: 'querystring', - name: 'q', - }, - { in: 'query', name: 'keep' }, - { $ref: '#/components/parameters/P' }, - ], - responses: {}, - }, - parameters: [ - { in: 'querystring', name: 'q' }, - { in: 'path', name: 'id', required: true }, - ], - }), - ).toEqual({ - get: { - parameters: [ - { in: 'query', name: 'keep' }, - { $ref: '#/components/parameters/P' }, - ], - responses: {}, - }, - parameters: [{ in: 'path', name: 'id', required: true }], - }) - }) - - it('removes querystring entries from components.parameters, keeping neighbors and reference entries', () => { - expect( - convertSpec({ - components: { - parameters: { - N: { in: 'header', name: 'h' }, - Q: { in: 'querystring', name: 'q' }, - R: { $ref: '#/components/parameters/N' }, - }, - }, - }).components, - ).toEqual({ - parameters: { - N: { in: 'header', name: 'h' }, - R: { $ref: '#/components/parameters/N' }, - }, - }) - }) - - it('removes references to removed querystring parameters, following alias chains', () => { - const result = convertSpec({ - components: { - parameters: { - Alias: { $ref: '#/components/parameters/Qs' }, - AliasOfAlias: { $ref: '#/components/parameters/Alias' }, - Keep: { in: 'query', name: 'k', schema: {} }, - Qs: { - content: { 'application/x-www-form-urlencoded': { schema: {} } }, - in: 'querystring', - name: 'filter', - }, - }, - }, - paths: { - '/a': { - get: { - parameters: [ - { $ref: '#/components/parameters/AliasOfAlias' }, - { $ref: '#/components/parameters/Qs' }, - { $ref: '#/components/parameters/Keep' }, - ], - responses: {}, - }, - parameters: [{ $ref: '#/components/parameters/Qs' }], - }, - }, - }) - expect(result.components).toEqual({ - parameters: { Keep: { in: 'query', name: 'k', schema: {} } }, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - parameters: [{ $ref: '#/components/parameters/Keep' }], - responses: {}, - }, - parameters: [], - }, - }) - }) - - it.each([ - [ - 'removes style: cookie and keeps the other fields', - { in: 'cookie', name: 'c', style: 'cookie' }, - { in: 'cookie', name: 'c' }, - ], - [ - 'keeps other style values', - { in: 'query', name: 'q', style: 'deepObject' }, - { in: 'query', name: 'q', style: 'deepObject' }, - ], - [ - 'keeps allowReserved on query parameters', - { allowReserved: true, in: 'query', name: 'q', schema: {} }, - { allowReserved: true, in: 'query', name: 'q', schema: {} }, - ], - [ - 'removes allowReserved on path parameters', - { - allowReserved: true, - in: 'path', - name: 'id', - required: true, - schema: {}, - }, - { in: 'path', name: 'id', required: true, schema: {} }, - ], - [ - 'removes allowReserved on cookie parameters', - { allowReserved: true, in: 'cookie', name: 'c', schema: {} }, - { in: 'cookie', name: 'c', schema: {} }, - ], - [ - 'keeps allowReserved on objects without an in field', - { allowReserved: true, schema: {} }, - { allowReserved: true, schema: {} }, - ], - [ - 'maps parameter schema xml nodeType and converts example maps', - { - examples: { - inline: { dataValue: 1 }, - referenced: { $ref: '#/components/examples/E' }, - }, - in: 'query', - name: 'q', - schema: { type: 'string', xml: { nodeType: 'attribute' } }, - }, - { - examples: { - inline: { value: 1 }, - referenced: { $ref: '#/components/examples/E' }, - }, - in: 'query', - name: 'q', - schema: { type: 'string', xml: { attribute: true } }, - }, - ], - ])('%s', (_name, input, expected) => { - expect(convertComponent('parameters', input)).toEqual(expected) - }) - - it('clones non-object parameter entries, non-array lists, and a malformed components map through', () => { - expect(convertPathItem({ parameters: [null, 'junk'] })).toEqual({ - parameters: [null, 'junk'], - }) - expect(convertPathItem({ parameters: 'junk' })).toEqual({ - parameters: 'junk', - }) - expect( - convertSpec({ components: { parameters: 'junk' } }).components, - ).toEqual({ - parameters: 'junk', - }) - }) - }) - - describe('parameters and headers with content', () => { - it('removes parameter and header examples beside content', () => { - const content = { 'a/b': { schema: { type: 'object' } } } - const operation = dig(convertSpec({ - paths: { - '/a': { - get: { - parameters: [ - { content, example: { a: 1 }, in: 'query', name: 'moved' }, - { content: { 'a/b': { example: 'own' } }, examples: { e: { dataValue: 1 } }, in: 'query', name: 'kept' }, - { content: { 'a/b': {}, 'c/d': {} }, example: 1, in: 'query', name: 'many' }, - { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, - ], - responses: { 200: { description: 'ok', headers: { X: { content, examples: { e: { dataValue: 2 } } } } } }, - }, - }, - }, - }), 'paths', '/a', 'get') - expect(dig(operation, 'parameters')).toEqual([ - { content, in: 'query', name: 'moved' }, - { content: { 'a/b': { example: 'own' } }, in: 'query', name: 'kept' }, - { content: { 'a/b': {}, 'c/d': {} }, in: 'query', name: 'many' }, - { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, - ]) - expect(dig(operation, 'responses', '200', 'headers', 'X')).toEqual({ content }) - }) - - it('keeps a parameter whose content was already empty', () => { - const parameter = { content: {}, in: 'query', name: 'q' } - expect(dig(convertSpec({ paths: { '/a': { get: { parameters: [parameter] } } } }), 'paths', '/a', 'get', 'parameters')).toEqual([parameter]) - }) - }) - - describe('components.mediaTypes inlining', () => { - it('inlines a media type reference with the converted media type', () => { - expect( - convertContent( - { 'application/jsonl': { $ref: '#/components/mediaTypes/Stream' } }, - { mediaTypes: { Stream: { itemSchema: { type: 'object' } } } }, - ), - ).toEqual({ - 'application/jsonl': { - schema: { items: { type: 'object' }, type: 'array' }, - }, - }) - }) - - it('inlines media type references in response, parameter, and header content maps', () => { - const reference = { - 'application/json': { $ref: '#/components/mediaTypes/Json' }, - } - const inlined = { 'application/json': { schema: { type: 'string' } } } - expect( - convertPathItem( - { - get: { - parameters: [{ content: reference, in: 'query', name: 'q' }], - responses: { - 200: { - content: reference, - description: 'ok', - headers: { 'X-H': { content: reference } }, - }, - }, - }, - }, - { mediaTypes: { Json: { schema: { type: 'string' } } } }, - ), - ).toEqual({ - get: { - parameters: [{ content: inlined, in: 'query', name: 'q' }], - responses: { - 200: { - content: inlined, - description: 'ok', - headers: { 'X-H': { content: inlined } }, - }, - }, - }, - }) - }) - - it('resolves chained media type references down to the final object', () => { - expect( - convertContent( - { 'application/json': { $ref: '#/components/mediaTypes/A' } }, - { - mediaTypes: { - A: { $ref: '#/components/mediaTypes/B' }, - B: { schema: { type: 'number' } }, - }, - }, - ), - ).toEqual({ 'application/json': { schema: { type: 'number' } } }) - }) - - it('resolves long acyclic reference chains', () => { - const links = Array.from({ length: 40 }, (_unused, index) => [ - `m${index}`, - { $ref: `#/components/mediaTypes/m${index + 1}` }, - ]) - const mediaTypes = Object.fromEntries([ - ...links, - ['m40', { schema: { type: 'string' } }], - ]) - expect( - convertContent( - { 'application/json': { $ref: '#/components/mediaTypes/m0' } }, - { mediaTypes }, - ), - ).toEqual({ 'application/json': { schema: { type: 'string' } } }) - }) - - it('removes content entries whose reference chain is cyclic', () => { - expect( - convertContent( - { - 'application/json': { $ref: '#/components/mediaTypes/Loop' }, - 'application/xml': { $ref: '#/components/mediaTypes/Ping' }, - }, - { - mediaTypes: { - Loop: { $ref: '#/components/mediaTypes/Loop' }, - Ping: { $ref: '#/components/mediaTypes/Pong' }, - Pong: { $ref: '#/components/mediaTypes/Ping' }, - }, - }, - ), - ).toEqual({}) - }) - - it('removes content entries with external, unknown, and unparseable references', () => { - expect( - convertContent( - { - 'a/1': { $ref: '#/components/schemas/Foo' }, - 'a/2': { $ref: '#/components/mediaTypes/nested/name' }, - 'a/3': { $ref: '#/components/mediaTypes/' }, - 'a/4': { $ref: 'https://example.com/other.json#/mediaTypes/A' }, - 'a/5': { $ref: '#/components/mediaTypes/Unknown' }, - 'a/6': { $ref: '#/components/mediaTypes/Known' }, - }, - { mediaTypes: { Known: { example: 1 } } }, - ), - ).toEqual({ 'a/6': { example: 1 } }) - }) - - it('inlines content-map references to any local media type, decoding escaped names', () => { - expect( - convertSpec({ - components: { - mediaTypes: { 'a/b': { schema: { type: 'string' } } }, - requestBodies: { - Json: { content: { 'application/json': { schema: { type: 'number' } } } }, - Reuse: { - content: { - 'application/json': { - $ref: '#/components/requestBodies/Json/content/application~1json', - }, - 'text/plain': { $ref: '#/components/mediaTypes/a~1b' }, - }, - }, - }, - }, - }).components?.requestBodies, - ).toEqual({ - Json: { content: { 'application/json': { schema: { type: 'number' } } } }, - Reuse: { - content: { - 'application/json': { schema: { type: 'number' } }, - 'text/plain': { schema: { type: 'string' } }, - }, - }, - }) - }) - - it('does not resolve names through the prototype chain of the mediaTypes map', () => { - expect( - convertContent( - { - 'application/json': { - $ref: '#/components/mediaTypes/hasOwnProperty', - }, - }, - { mediaTypes: {} }, - ), - ).toEqual({}) - }) - - it('removes media type references when components.mediaTypes is missing or malformed', () => { - const content = { - 'application/json': { $ref: '#/components/mediaTypes/A' }, - } - expect( - convertSpec({ - paths: { - '/a': { post: { requestBody: { content }, responses: {} } }, - }, - }), - ).toEqual({ - openapi: '3.1.2', - paths: { - '/a': { post: { requestBody: { content: {} }, responses: {} } }, - }, - }) - expect(convertContent(content, { mediaTypes: 'junk' })).toEqual({}) - }) - - it('removes the mediaTypes map from components', () => { - expect( - convertSpec({ - components: { - mediaTypes: { Json: { schema: {} } }, - schemas: { S: { type: 'string' } }, - }, - }).components, - ).toEqual({ schemas: { S: { type: 'string' } } }) - }) - - it('clones a non-object content value through', () => { - expect(convertContent('junk')).toBe('junk') - }) - - describe('parameters and headers losing their entire content', () => { - const missing = { - 'application/json': { $ref: '#/components/mediaTypes/Missing' }, - } - - it('removes a parameter whose only content entry could not be inlined', () => { - expect( - convertPathItem({ - get: { - parameters: [ - { content: missing, in: 'query', name: 'q' }, - { in: 'query', name: 'keep', schema: {} }, - ], - responses: {}, - }, - }), - ).toEqual({ - get: { - parameters: [{ in: 'query', name: 'keep', schema: {} }], - responses: {}, - }, - }) - }) - - it('keeps a parameter when part of its content could be inlined', () => { - expect( - convertPathItem( - { - get: { - parameters: [ - { - content: { - ...missing, - 'application/xml': { - $ref: '#/components/mediaTypes/Known', - }, - }, - in: 'query', - name: 'q', - }, - ], - responses: {}, - }, - }, - { mediaTypes: { Known: { example: 1 } } }, - ), - ).toEqual({ - get: { - parameters: [ - { - content: { 'application/xml': { example: 1 } }, - in: 'query', - name: 'q', - }, - ], - responses: {}, - }, - }) - }) - - it('removes headers and component parameters whose entire content could not be inlined', () => { - const result = convertSpec({ - components: { - headers: { Broken: { content: missing }, Keep: { schema: {} } }, - parameters: { - Broken: { content: missing, in: 'query', name: 'q' }, - }, - }, - paths: { - '/a': { - get: { - responses: { - 200: { - description: 'ok', - headers: { - 'X-Broken': { content: missing }, - 'X-Keep': { schema: {} }, - }, - }, - }, - }, - }, - }, - }) - expect(result.components).toEqual({ - headers: { Keep: { schema: {} } }, - parameters: {}, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - responses: { - 200: { - description: 'ok', - headers: { 'X-Keep': { schema: {} } }, - }, - }, - }, - }, - }) - }) - - it('removes references to removed parameters and headers, following alias chains', () => { - const result = convertSpec({ - components: { - headers: { - Broken: { - content: { - 'text/plain': { $ref: '#/components/mediaTypes/Loop' }, - }, - }, - BrokenAlias: { $ref: '#/components/headers/Broken' }, - }, - mediaTypes: { Loop: { $ref: '#/components/mediaTypes/Loop' } }, - parameters: { - Broken: { content: missing, in: 'query', name: 'q' }, - BrokenAlias: { $ref: '#/components/parameters/Broken' }, - }, - }, - paths: { - '/a': { - get: { - parameters: [ - { $ref: '#/components/parameters/Broken' }, - { $ref: '#/components/parameters/BrokenAlias' }, - ], - responses: { - 200: { - description: 'ok', - headers: { - 'X-Broken': { $ref: '#/components/headers/Broken' }, - 'X-BrokenAlias': { - $ref: '#/components/headers/BrokenAlias', - }, - }, - }, - }, - }, - }, - }, - }) - expect(result.components).toEqual({ headers: {}, parameters: {} }) - expect(result.paths).toEqual({ - '/a': { - get: { - parameters: [], - responses: { 200: { description: 'ok', headers: {} } }, - }, - }, - }) - }) - }) - }) - - describe('media types', () => { - it('turns itemSchema into a deep-cloned array schema when no schema exists', () => { - const itemSchema = { type: 'object', xml: { nodeType: 'text' } } - const result = convertContent({ 'application/jsonl': { itemSchema } }) - expect(result).toEqual({ - 'application/jsonl': { - schema: { - items: { type: 'object', xml: {} }, - type: 'array', - }, - }, - }) - const promoted = dig(result, 'application/jsonl', 'schema', 'items') - expect(promoted).not.toBe(itemSchema) - expect(dig(promoted, 'xml')).not.toBe(itemSchema.xml) - }) - - it.each([ - [ - 'removes itemSchema when a schema already exists', - { itemSchema: { type: 'string' }, schema: { type: 'array' } }, - { schema: { type: 'array' } }, - ], - [ - 'removes prefixEncoding and itemEncoding', - { - example: 1, - itemEncoding: { contentType: 'text/plain' }, - prefixEncoding: [{ contentType: 'application/json' }], - }, - { example: 1 }, - ], - [ - 'removes the 3.2-only description and keeps other fields', - { - description: 'a JSON payload', - example: 5, - schema: { type: 'integer' }, - }, - { example: 5, schema: { type: 'integer' } }, - ], - [ - 'converts example maps', - { - examples: { - inline: { serializedValue: 'raw' }, - referenced: { $ref: '#/components/examples/E' }, - }, - }, - { - examples: { - inline: { value: 'raw' }, - referenced: { $ref: '#/components/examples/E' }, - }, - }, - ], - ])('%s', (_name, mediaType, expected) => { - expect(convertContent({ 'application/json': mediaType })).toEqual({ - 'application/json': expected, - }) - }) - - it('removes nested and positional encoding inside encoding objects while still converting headers', () => { - expect( - convertContent({ - 'multipart/form-data': { - encoding: { - part: { - contentType: 'application/json', - encoding: { - inner: { headers: { 'X-C': { style: 'cookie' } } }, - }, - headers: { - 'Referenced': { $ref: '#/components/headers/H' }, - 'X-H': { description: 'h', style: 'cookie' }, - }, - itemEncoding: { contentType: 'text/plain' }, - prefixEncoding: [{ contentType: 'text/csv' }], - }, - }, - }, - }), - ).toEqual({ - 'multipart/form-data': { - encoding: { - part: { - contentType: 'application/json', - headers: { - 'Referenced': { $ref: '#/components/headers/H' }, - 'X-H': { description: 'h' }, - }, - }, - }, - }, - }) - }) - }) - - describe('responses', () => { - it.each([ - [ - 'uses summary as the description when none exists', - { summary: 'ok' }, - { description: 'ok' }, - ], - [ - 'removes summary when a description exists', - { description: 'd', summary: 's' }, - { description: 'd' }, - ], - [ - 'synthesizes an empty description when neither summary nor description exist', - {}, - { description: '' }, - ], - [ - 'synthesizes an empty description instead of promoting a malformed summary', - { summary: 42 }, - { description: '' }, - ], - [ - 'clones a non-object headers value through', - { description: 'ok', headers: 'junk' }, - { description: 'ok', headers: 'junk' }, - ], - [ - 'leaves response reference objects untouched, including summary and description overrides', - { - $ref: '#/components/responses/R', - description: 'override', - summary: 'kept', - }, - { - $ref: '#/components/responses/R', - description: 'override', - summary: 'kept', - }, - ], - ])('%s', (_name, response, expected) => { - expect(convertComponent('responses', response)).toEqual(expected) - }) - - it('clones x- keys of the responses map without response conversion', () => { - expect( - convertPathItem({ - get: { - responses: { - '200': { summary: 'ok' }, - 'x-note': { summary: 'not a response' }, - }, - }, - }), - ).toEqual({ - get: { - responses: { - '200': { description: 'ok' }, - 'x-note': { summary: 'not a response' }, - }, - }, - }) - }) - - it('converts response headers, content, and links', () => { - expect( - convertComponent('responses', { - content: { 'application/json': { itemSchema: { type: 'string' } } }, - description: 'ok', - headers: { 'X-H': { style: 'cookie' } }, - links: { - inline: { server: { name: 's', url: '/u' } }, - referenced: { $ref: '#/components/links/L' }, - }, - }), - ).toEqual({ - content: { - 'application/json': { - schema: { items: { type: 'string' }, type: 'array' }, - }, - }, - description: 'ok', - headers: { 'X-H': {} }, - links: { - inline: { server: { url: '/u' } }, - referenced: { $ref: '#/components/links/L' }, - }, - }) - }) - }) - - describe('examples', () => { - it.each([ - [ - 'moves dataValue into the free value slot', - { dataValue: { a: 1 } }, - { value: { a: 1 } }, - ], - [ - 'moves serializedValue into the free value slot', - { serializedValue: 'a=1' }, - { value: 'a=1' }, - ], - [ - 'removes dataValue when value already exists', - { dataValue: 1, value: 2 }, - { value: 2 }, - ], - [ - 'removes serializedValue when value already exists', - { serializedValue: 's', value: 2 }, - { value: 2 }, - ], - [ - 'removes dataValue and serializedValue when externalValue exists', - { - dataValue: 1, - externalValue: 'https://example.com/e.json', - serializedValue: 's', - }, - { externalValue: 'https://example.com/e.json' }, - ], - [ - 'lets dataValue win the value slot over serializedValue', - { dataValue: 1, serializedValue: 's' }, - { value: 1 }, - ], - [ - 'keeps other example fields untouched', - { dataValue: 1, description: 'd', summary: 's' }, - { description: 'd', summary: 's', value: 1 }, - ], - [ - 'leaves an example without any value fields unchanged', - { summary: 's' }, - { summary: 's' }, - ], - ['clones a malformed example through', 42, 42], - ])('%s', (_name, example, expected) => { - expect(convertComponent('examples', example)).toEqual(expected) - }) - }) - - describe('security schemes', () => { - const flow = { - authorizationUrl: 'https://example.com/auth', - scopes: {}, - tokenUrl: 'https://example.com/token', - } - - it.each([ - [ - 'removes deprecated: true', - { deprecated: true, type: 'http' }, - { type: 'http' }, - ], - [ - 'removes deprecated: false', - { deprecated: false, type: 'http' }, - { type: 'http' }, - ], - [ - 'removes a malformed deprecated', - { deprecated: 'yes', type: 'http' }, - { type: 'http' }, - ], - [ - 'removes oauth2MetadataUrl', - { oauth2MetadataUrl: 'https://example.com/meta', type: 'oauth2' }, - { type: 'oauth2' }, - ], - [ - 'removes a malformed oauth2MetadataUrl', - { oauth2MetadataUrl: 42, type: 'oauth2' }, - { type: 'oauth2' }, - ], - [ - 'removes the deviceAuthorization flow and keeps other flows', - { - flows: { - authorizationCode: flow, - deviceAuthorization: { - deviceAuthorizationUrl: 'https://example.com/device', - scopes: {}, - tokenUrl: flow.tokenUrl, - }, - }, - type: 'oauth2', - }, - { flows: { authorizationCode: flow }, type: 'oauth2' }, - ], - [ - 'removes a malformed deviceAuthorization', - { flows: { deviceAuthorization: 'junk' }, type: 'oauth2' }, - { flows: {}, type: 'oauth2' }, - ], - [ - 'clones malformed flows through', - { flows: 'junk', type: 'oauth2' }, - { flows: 'junk', type: 'oauth2' }, - ], - [ - 'clones references through', - { $ref: '#/components/securitySchemes/Other' }, - { $ref: '#/components/securitySchemes/Other' }, - ], - ['passes a non-object security scheme through', 'junk', 'junk'], - ])('%s', (_name, scheme, expected) => { - expect(convertComponent('securitySchemes', scheme)).toEqual(expected) - }) - }) - - describe('webhooks and components.pathItems', () => { - it('converts webhook path items and removes their query operation', () => { - expect( - convertSpec({ - webhooks: { - newPet: { - post: { responses: { 200: { summary: 'ok' } } }, - query: { description: 'q' }, - }, - }, - }).webhooks, - ).toEqual({ - newPet: { post: { responses: { 200: { description: 'ok' } } } }, - }) - }) - - it('converts components.pathItems path items and removes their query operation', () => { - expect( - convertComponent('pathItems', { - get: { responses: {} }, - query: { description: 'q' }, - }), - ).toEqual({ get: { responses: {} } }) - }) - }) - - describe('callbacks', () => { - it('converts path items in operation-level callbacks and clones x- keys', () => { - expect( - convertPathItem({ - post: { - callbacks: { - onEvent: { - 'x-note': { query: { description: 'kept' } }, - '{$request.body#/url}': { - post: { responses: { 200: { summary: 'ok' } } }, - query: { description: 'q' }, - }, - }, - referenced: { $ref: '#/components/callbacks/C' }, - }, - responses: {}, - }, - }), - ).toEqual({ - post: { - callbacks: { - onEvent: { - 'x-note': { query: { description: 'kept' } }, - '{$request.body#/url}': { - post: { responses: { 200: { description: 'ok' } } }, - }, - }, - referenced: { $ref: '#/components/callbacks/C' }, - }, - responses: {}, - }, - }) - }) - - it('converts components.callbacks, handling both references and inline callbacks', () => { - expect( - convertSpec({ - components: { - callbacks: { - inline: { - 'https://example.com/cb': { - post: { responses: { 200: { summary: 'ok' } } }, - query: { description: 'q' }, - }, - }, - referenced: { $ref: '#/components/callbacks/inline' }, - }, - }, - }).components, - ).toEqual({ - callbacks: { - inline: { - 'https://example.com/cb': { - post: { responses: { 200: { description: 'ok' } } }, - }, - }, - referenced: { $ref: '#/components/callbacks/inline' }, - }, - }) - }) - }) - - describe('components', () => { - it('handles references and inline objects across component maps', () => { - expect( - convertSpec({ - components: { - examples: { - E: { dataValue: 1 }, - ERef: { $ref: '#/components/examples/E' }, - }, - headers: { - H: { style: 'cookie' }, - HRef: { $ref: '#/components/headers/H' }, - }, - links: { junkLink: 42 }, - parameters: { - P: { in: 'querystring', name: 'q' }, - PRef: { $ref: '#/components/parameters/P' }, - }, - requestBodies: { - B: { - content: { - 'application/json': { itemSchema: { type: 'string' } }, - }, - }, - BRef: { $ref: '#/components/requestBodies/B' }, - }, - responses: { - R: { summary: 'ok' }, - RRef: { $ref: '#/components/responses/R' }, - }, - }, - }).components, - ).toEqual({ - examples: { - E: { value: 1 }, - ERef: { $ref: '#/components/examples/E' }, - }, - headers: { H: {}, HRef: { $ref: '#/components/headers/H' } }, - links: { junkLink: 42 }, - parameters: {}, - requestBodies: { - B: { - content: { - 'application/json': { - schema: { items: { type: 'string' }, type: 'array' }, - }, - }, - }, - BRef: { $ref: '#/components/requestBodies/B' }, - }, - responses: { - R: { description: 'ok' }, - RRef: { $ref: '#/components/responses/R' }, - }, - }) - }) - - it('maps xml nodeType and removes discriminator defaultMapping in components.schemas entries', () => { - const schema = { - discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, - xml: { nodeType: 'attribute' }, - } - expect(convertComponent('schemas', schema)).toEqual({ - discriminator: { propertyName: 'kind' }, - xml: { attribute: true }, - }) - }) - - it('clones unknown component keys and passes non-object components through', () => { - expect( - convertSpec({ components: { custom: { anything: true } } }).components, - ).toEqual({ - custom: { anything: true }, - }) - expect(convertSpec({ components: 'junk' }).components).toBe('junk') - }) - }) - - describe('references into removed parts', () => { - const petRef = { $ref: '#/components/mediaTypes/Pet/schema' } - const pet = { type: 'object', xml: { nodeType: 'element' } } - const convertedPet = { type: 'object', xml: {} } - - it('inlines schema $refs at every subschema position', () => { - const everyPosition = (schema: unknown) => ({ - $defs: { d: schema }, - additionalProperties: schema, - allOf: [schema], - anyOf: [schema], - contains: schema, - contentSchema: schema, - dependentSchemas: { d: schema }, - else: schema, - if: schema, - items: schema, - not: schema, - oneOf: [schema], - patternProperties: { '^x': schema }, - prefixItems: [schema], - properties: { p: schema }, - propertyNames: schema, - then: schema, - unevaluatedItems: schema, - unevaluatedProperties: schema, - }) - expect( - convertComponent('schemas', everyPosition(petRef), { - mediaTypes: { Pet: { schema: pet } }, - }), - ).toEqual(everyPosition(convertedPet)) - }) - - it('inlines schema $refs in parameter, header, media type, and itemSchema positions', () => { - expect( - convertSpec({ - components: { - headers: { H: { schema: petRef } }, - mediaTypes: { Pet: { schema: pet } }, - parameters: { P: { in: 'query', name: 'p', schema: petRef } }, - requestBodies: { - B: { - content: { - 'application/json': { schema: petRef }, - 'application/jsonl': { itemSchema: petRef }, - }, - }, - }, - }, - }).components, - ).toEqual({ - headers: { H: { schema: convertedPet } }, - parameters: { P: { in: 'query', name: 'p', schema: convertedPet } }, - requestBodies: { - B: { - content: { - 'application/json': { schema: convertedPet }, - 'application/jsonl': { schema: { items: convertedPet, type: 'array' } }, - }, - }, - }, - }) - }) - - it('keeps data keywords, extensions, and non-string $ref values verbatim', () => { - const schema = { - 'const': petRef, - 'default': petRef, - 'enum': [petRef], - 'examples': [petRef], - 'properties': { p: { $ref: 42 } }, - 'x-data': petRef, - } - expect( - convertSpec({ - components: { - headers: { H: { schema: petRef } }, - mediaTypes: { Pet: { schema: pet } }, - schemas: { S: schema }, - }, - }).components, - ).toEqual({ headers: { H: { schema: convertedPet } }, schemas: { S: schema } }) - }) - - it.each([ - [ - 'adds allOf beside sibling annotations', - { $ref: petRef.$ref, description: 'd' }, - { allOf: [convertedPet], description: 'd' }, - ], - [ - 'appends to an existing allOf, keeping its indices', - { $ref: petRef.$ref, allOf: [{ required: ['a'] }] }, - { allOf: [{ required: ['a'] }, convertedPet] }, - ], - [ - 'nests the siblings when allOf is malformed', - { $ref: petRef.$ref, allOf: 'junk' }, - { allOf: [{ allOf: 'junk' }, convertedPet] }, - ], - ])('merges a dangling schema $ref with its siblings: %s', (_name, schema, expected) => { - expect( - convertComponent('schemas', schema, { mediaTypes: { Pet: { schema: pet } } }), - ).toEqual(expected) - }) - - it('inlines a boolean target schema', () => { - expect( - convertComponent('schemas', { $ref: '#/components/mediaTypes/None/schema' }, { - mediaTypes: { None: { schema: false } }, - }), - ).toBe(false) - }) - - it('inlines Reference Objects into query, additionalOperations, and components.mediaTypes, converting each target', () => { - const result = convertSpec({ - components: { - callbacks: { C: { $ref: '#/paths/~1search/query/callbacks/onDone' } }, - examples: { E: { $ref: '#/components/mediaTypes/Pet/examples/e' } }, - headers: { - H: { $ref: '#/components/mediaTypes/Pet/encoding/file/headers/X-Rate' }, - }, - links: { L: { $ref: '#/paths/~1search/query/responses/200/links/next' } }, - mediaTypes: { - Pet: { - encoding: { - file: { - headers: { - 'X-Rate': { examples: { a: { serializedValue: '1' } } }, - }, - }, - }, - examples: { e: { dataValue: 1 } }, - }, - }, - }, - paths: { - '/search': { - additionalOperations: { - COPY: { responses: { 201: { summary: 'Copied' } } }, - }, - post: { - parameters: [{ $ref: '#/paths/~1search/query/parameters/0' }], - requestBody: { $ref: '#/paths/~1search/query/requestBody' }, - responses: { - 200: { $ref: '#/paths/~1search/query/responses/200' }, - 201: { - $ref: '#/paths/~1search/additionalOperations/COPY/responses/201', - }, - }, - }, - query: { - callbacks: { - onDone: { - '{$request.body#/url}': { - post: { responses: { 200: { summary: 'ack' } } }, - }, - }, - }, - parameters: [{ in: 'cookie', name: 'c', style: 'cookie' }], - requestBody: { - content: { 'application/jsonl': { itemSchema: { type: 'string' } } }, - }, - responses: { - 200: { - links: { next: { operationId: 'x', server: { name: 'n', url: '/' } } }, - summary: 'Found', - }, - }, - }, - }, - }, - }) - expect(result.components).toEqual({ - callbacks: { - C: { - '{$request.body#/url}': { - post: { responses: { 200: { description: 'ack' } } }, - }, - }, - }, - examples: { E: { value: 1 } }, - headers: { H: { examples: { a: { value: '1' } } } }, - links: { L: { operationId: 'x', server: { url: '/' } } }, - }) - expect(result.paths).toEqual({ - '/search': { - post: { - parameters: [{ in: 'cookie', name: 'c' }], - requestBody: { - content: { - 'application/jsonl': { - schema: { items: { type: 'string' }, type: 'array' }, - }, - }, - }, - responses: { - 200: { - description: 'Found', - links: { next: { operationId: 'x', server: { url: '/' } } }, - }, - 201: { description: 'Copied' }, - }, - }, - }, - }) - }) - - it('follows reference chains through removed parts and keeps references that reach surviving ones', () => { - const result = convertSpec({ - components: { - responses: { - Deep: { $ref: '#/paths/~1a/query/responses/200' }, - Kept: { $ref: '#/paths/~1a/query/responses/201' }, - Real: { description: 'real' }, - }, - }, - paths: { - '/a': { - query: { - responses: { - 200: { $ref: '#/paths/~1b/query/responses/200' }, - 201: { $ref: '#/components/responses/Real' }, - }, - }, - }, - '/b': { query: { responses: { 200: { summary: 'deep' } } } }, - }, - }) - expect(result.components).toEqual({ - responses: { - Deep: { description: 'deep' }, - Kept: { $ref: '#/components/responses/Real' }, - Real: { description: 'real' }, - }, - }) - expect(result.paths).toEqual({ '/a': {}, '/b': {} }) - }) - - it('leaves a Reference Object whose chain loops through removed parts as written', () => { - const result = convertSpec({ - components: { - responses: { - Keep: { description: 'k' }, - Loop: { $ref: '#/paths/~1a/query/responses/200' }, - }, - }, - paths: { - '/a': { query: { responses: { 200: { $ref: '#/paths/~1b/query/responses/200' } } } }, - '/b': { query: { responses: { 200: { $ref: '#/paths/~1a/query/responses/200' } } } }, - '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, - }, - }) - expect(result.components).toEqual({ - responses: { - Keep: { description: 'k' }, - Loop: { $ref: '#/paths/~1a/query/responses/200' }, - }, - }) - expect(result.paths).toEqual({ - '/a': {}, - '/b': {}, - '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, - }) - }) - - it('cuts a recursive schema at its first repeat by removing only the $ref keyword', () => { - const tree = { - properties: { - children: { - items: { $ref: '#/components/mediaTypes/Tree/schema' }, - type: 'array', - }, - parent: { $ref: '#/components/mediaTypes/Tree/schema', description: 'up' }, - }, - type: 'object', - } - expect( - convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema' }, { - mediaTypes: { Tree: { schema: tree } }, - }), - ).toEqual({ - properties: { - children: { items: {}, type: 'array' }, - parent: { description: 'up' }, - }, - type: 'object', - }) - }) - - it('cuts a recursive schema reached through a content map instead of emitting a circular object', () => { - const result = convertSpec({ - paths: { - '/a': { - get: { - responses: { - 200: { - content: { 'application/json': { $ref: '#/components/mediaTypes/Tree' } }, - description: 'ok', - }, - }, - }, - }, - }, - components: { - mediaTypes: { - Tree: { - schema: { - properties: { - children: { - items: { $ref: '#/components/mediaTypes/Tree/schema' }, - type: 'array', - }, - }, - type: 'object', - }, - }, - }, - }, - }) - expect(() => JSON.stringify(result)).not.toThrow() - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toEqual({ - properties: { children: { items: {}, type: 'array' } }, - type: 'object', - }) - }) - - it('cuts a cycle entered through a pointer into a recursive schema', () => { - const result = convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema/properties/children' }, { - mediaTypes: { - Tree: { - schema: { - properties: { - children: { - items: { $ref: '#/components/mediaTypes/Tree/schema' }, - type: 'array', - }, - }, - type: 'object', - }, - }, - }, - }) - expect(() => JSON.stringify(result)).not.toThrow() - expect(result).toEqual({ items: { properties: { children: {} }, type: 'object' }, type: 'array' }) - }) - - it('inlines references into a parameter list that lost entries, since its indices shift', () => { - const result = convertSpec({ - components: { - parameters: { - External: { $ref: '#/paths/~1a/get/parameters/1' }, - Kept: { $ref: '#/paths/~1b/get/parameters/0' }, - Shifted: { $ref: '#/paths/~1a/get/parameters/2' }, - }, - }, - paths: { - '/a': { - get: { - parameters: [ - { in: 'querystring', name: 'qs' }, - { $ref: './parameters/limit.yaml' }, - { in: 'query', name: 'b' }, - ], - responses: {}, - }, - }, - '/b': { get: { parameters: [{ in: 'query', name: 'c' }], responses: {} } }, - }, - }) - expect(result.components).toEqual({ - parameters: { - External: { $ref: './parameters/limit.yaml' }, - Kept: { $ref: '#/paths/~1b/get/parameters/0' }, - Shifted: { in: 'query', name: 'b' }, - }, - }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ - { $ref: './parameters/limit.yaml' }, - { in: 'query', name: 'b' }, - ]) - }) - - it('removes parameter and header references that resolve to removed ones through any pointer', () => { - expect( - convertSpec({ - components: { - headers: { - H: { $ref: '#/paths/~1a/get/responses/200/headers/X-Broken' }, - }, - parameters: { P: { $ref: '#/paths/~1a/get/parameters/0' } }, - }, - paths: { - '/a': { - get: { - parameters: [{ in: 'querystring', name: 'qs' }], - responses: { - 200: { - description: 'ok', - headers: { - 'X-Broken': { - content: { - 'application/json': { $ref: '#/components/mediaTypes/Missing' }, - }, - }, - }, - }, - }, - }, - }, - }, - }).components, - ).toEqual({ headers: {}, parameters: {} }) - }) - - it('leaves references whose alias chain loops as written, since they never resolve', () => { - expect( - convertSpec({ - components: { - parameters: { - A: { $ref: '#/components/parameters/B' }, - B: { $ref: '#/components/parameters/A' }, - }, - }, - }).components, - ).toEqual({ - parameters: { - A: { $ref: '#/components/parameters/B' }, - B: { $ref: '#/components/parameters/A' }, - }, - }) - }) - - it('leaves a looping reference as written, so later parameter indices stay correct', () => { - const result = convertSpec({ - components: { parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }, - paths: { - '/a': { - get: { - parameters: [{ $ref: '#/paths/~1b/query/parameters/0' }, { in: 'query', name: 'b' }], - responses: {}, - }, - }, - '/b': { query: { parameters: [{ $ref: '#/paths/~1c/query/parameters/0' }] } }, - '/c': { query: { parameters: [{ $ref: '#/paths/~1b/query/parameters/0' }] } }, - }, - }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ - { $ref: '#/paths/~1b/query/parameters/0' }, - { in: 'query', name: 'b' }, - ]) - expect(result.components).toEqual({ parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }) - }) - - it('leaves external, anchor, root, unparseable, and already dangling references untouched', () => { - const schemas = { - Anchor: { $ref: '#pet' }, - BadEscape: { $ref: '#/components/mediaTypes/%E0%A4%A' }, - External: { - $ref: 'https://example.com/api.json#/components/mediaTypes/Pet/schema', - }, - Missing: { $ref: '#/components/mediaTypes/Nope/schema' }, - Root: { $ref: '#' }, - } - expect( - convertSpec({ - components: { - headers: { H: { schema: petRef } }, - mediaTypes: { Pet: { schema: pet } }, - schemas, - }, - }).components, - ).toEqual({ headers: { H: { schema: convertedPet } }, schemas }) - }) - - it('decodes escaped and percent-encoded pointer tokens', () => { - expect( - convertSpec({ - components: { - mediaTypes: { - 'a/b~c': { schema: { type: 'string' } }, - 'My Type': { schema: { type: 'number' } }, - }, - schemas: { - Escaped: { $ref: '#/components/mediaTypes/a~1b~0c/schema' }, - Percent: { $ref: '#/components/mediaTypes/My%20Type/schema' }, - Templated: { - $ref: '#/paths/~1pets~1%7Bid%7D/query/requestBody/content/application~1json/schema', - }, - }, - }, - paths: { - '/pets/{id}': { - query: { - requestBody: { - content: { 'application/json': { schema: { type: 'integer' } } }, - }, - }, - }, - }, - }).components, - ).toEqual({ - schemas: { - Escaped: { type: 'string' }, - Percent: { type: 'number' }, - Templated: { type: 'integer' }, - }, - }) - }) - - it('inlines pointers to an itemSchema that the conversion moves or removes', () => { - expect( - convertSpec({ - components: { - requestBodies: { - B: { - content: { - 'application/json': { - itemSchema: { type: 'number' }, - schema: { type: 'array' }, - }, - 'application/jsonl': { itemSchema: { type: 'string' } }, - }, - }, - }, - schemas: { - Moved: { - $ref: '#/components/requestBodies/B/content/application~1jsonl/itemSchema', - }, - Removed: { - $ref: '#/components/requestBodies/B/content/application~1json/itemSchema', - }, - }, - }, - }).components?.schemas, - ).toEqual({ Moved: { type: 'string' }, Removed: { type: 'number' } }) - }) - it('keeps a header alias whose target is only cut by a media type cycle', () => { - const result = convertSpec({ - components: { - headers: { A: { $ref: '#/components/mediaTypes/M/encoding/e/headers/h' } }, - mediaTypes: { - M: { - encoding: { e: { headers: { h: { content: { 'a/b': { $ref: '#/components/mediaTypes/M' } } }, x: { $ref: '#/components/headers/A' } } } }, - schema: { type: 'string' }, - }, - }, - }, - paths: { - '/p': { - get: { - responses: { - 200: { - content: { 'a/b': { $ref: '#/components/mediaTypes/M' } }, - description: 'ok', - headers: { X: { $ref: '#/components/headers/A' } }, - }, - }, - }, - }, - }, - }) - expect(dig(result, 'paths', '/p', 'get', 'responses', '200', 'headers')).toEqual({ X: { $ref: '#/components/headers/A' } }) - expect(dig(result, 'components', 'headers', 'A', 'content', 'a/b', 'schema')).toEqual({ type: 'string' }) - }) - - it('removes links and discriminator mappings that point into removed parts', () => { - const result = convertSpec({ - components: { - links: { gone: { operationRef: '#/paths/~1a/query' }, kept: { operationRef: '#/paths/~1a/get' } }, - mediaTypes: { M: { schema: {} } }, - schemas: { - Pet: { - discriminator: { - mapping: { cat: '#/components/schemas/Cat', item: '#/components/mediaTypes/M/schema' }, - propertyName: 'kind', - }, - }, - }, - }, - paths: { '/a': { get: {}, query: {} } }, - }) - expect(dig(result, 'components', 'links')).toEqual({ kept: { operationRef: '#/paths/~1a/get' } }) - expect(dig(result, 'components', 'schemas', 'Pet', 'discriminator')).toEqual({ mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }) - }) - - it('follows schema alias chains through removed parts, stopping at an alias with siblings', () => { - const result = convertSpec({ - components: { - mediaTypes: { - A: { schema: { $ref: '#/components/mediaTypes/B/schema' } }, - B: { schema: { $ref: '#/components/mediaTypes/C/schema', description: 'b' } }, - C: { schema: { type: 'string' } }, - }, - schemas: { S: { $ref: '#/components/mediaTypes/A/schema' } }, - }, - }) - expect(dig(result, 'components', 'schemas', 'S')).toEqual({ allOf: [{ type: 'string' }], description: 'b' }) - }) - - it('keeps $id and $anchor on the first copy of a schema inlined in several places', () => { - const pet = { $id: 'https://example.com/pet', properties: { name: { $anchor: 'name', type: 'string' } }, type: 'object' } - const result = convertSpec({ - components: { - mediaTypes: { Pet: { schema: pet } }, - schemas: { Named: { $ref: '#/components/mediaTypes/Pet/schema', description: 'named' } }, - }, - paths: { - '/a': { - get: { - responses: { - 200: { - content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } }, - description: 'ok', - }, - }, - }, - }, - }, - }) - expect(dig(result, 'components', 'schemas', 'Named')).toEqual({ allOf: [pet], description: 'named' }) - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content')).toEqual({ - 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } }, - }) - }) - - it('keeps identifiers on a moved or shifted original rather than on the copies inlined from it', () => { - expect(convertPathItem({ - get: { - parameters: [ - { content: { 'text/plain': {} }, in: 'querystring', name: 'q' }, - { in: 'query', name: 'p', schema: { $dynamicAnchor: 'p', type: 'string' } }, - ], - responses: { - 200: { content: { 'application/jsonl': { itemSchema: { $id: 'https://example.com/item' } } }, description: 'ok' }, - }, - }, - post: { - parameters: [{ $ref: '#/paths/~1a/get/parameters/1' }], - requestBody: { - content: { 'application/json': { schema: { $ref: '#/paths/~1a/get/responses/200/content/application~1jsonl/itemSchema' } } }, - }, - }, - })).toEqual({ - get: { - parameters: [{ in: 'query', name: 'p', schema: { $dynamicAnchor: 'p', type: 'string' } }], - responses: { - 200: { content: { 'application/jsonl': { schema: { items: { $id: 'https://example.com/item' }, type: 'array' } } }, description: 'ok' }, - }, - }, - post: { - parameters: [{ in: 'query', name: 'p', schema: { type: 'string' } }], - requestBody: { content: { 'application/json': { schema: {} } } }, - }, - }) - }) - - it('inlines a path item $ref that points into a removed operation, keeping own fields', () => { - const callbacks = { c: { '{$url}': { description: 'inlined', summary: 'Inlined' } } } - const result = convertSpec({ - components: { - pathItems: { - copy: { $ref: '#/paths/~1a/additionalOperations/COPY/callbacks/c/{$url}' }, - query: { $ref: '#/paths/~1a/query/callbacks/c/{$url}', summary: 'Own' }, - }, - }, - paths: { '/a': { additionalOperations: { COPY: { callbacks } }, query: { callbacks } } }, - }) - expect(dig(result, 'components', 'pathItems')).toEqual({ - copy: { description: 'inlined', summary: 'Inlined' }, - query: { description: 'inlined', summary: 'Own' }, - }) - }) - - it('inlines a path item $ref into a removed operation of a callbacks component', () => { - const result = convertSpec({ - components: { - callbacks: { - C: { '{$url}': { query: { callbacks: { d: { '{$v}': { description: 'inlined' } } } } }, 'x-cb': { query: {} } }, - }, - pathItems: { - P: { $ref: '#/components/callbacks/C/{$url}/query/callbacks/d/{$v}' }, - X: { $ref: '#/components/callbacks/C/x-cb' }, - }, - }, - }) - expect(dig(result, 'components', 'pathItems')).toEqual({ - P: { description: 'inlined' }, - X: { $ref: '#/components/callbacks/C/x-cb' }, - }) - }) - }) - - describe('robustness', () => { - it('never mutates the input document', () => { - const spec = { - $self: 'https://example.com/api.json', - components: { - examples: { E: { dataValue: 1, serializedValue: 's' } }, - mediaTypes: { - A: { $ref: '#/components/mediaTypes/B' }, - B: { itemSchema: { xml: { nodeType: 'text' } } }, - }, - pathItems: { P: { query: { description: 'q' } } }, - schemas: { - R: { $ref: '#/components/mediaTypes/B/itemSchema', description: 'r' }, - S: { discriminator: { defaultMapping: 'Dog' } }, - }, - securitySchemes: { O: { deprecated: true, type: 'oauth2' } }, - }, - openapi: '3.2.0', - paths: { - '/a': { - additionalOperations: { NOTIFY: { description: 'n' } }, - get: { - parameters: [{ in: 'querystring', name: 'q' }], - requestBody: { - content: { - 'application/json': { $ref: '#/components/mediaTypes/A' }, - }, - }, - responses: { 200: { summary: 'ok' } }, - }, - query: { description: 'q' }, - servers: [{ name: 's', url: '/u' }], - }, - }, - servers: [{ name: 'root', url: 'https://example.com' }], - tags: [{ kind: 'nav', name: 't', parent: 'p', summary: 's' }], - webhooks: { hook: { query: { description: 'wq' } } }, - } as any - const before = structuredClone(spec) - downgradeSpecV32ToV31(spec) - expect(spec).toEqual(before) - }) - - it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { - const callback: Record = {} - const pathItem: Record = { - get: { callbacks: { cb: callback }, responses: { 200: { summary: 'ok' } } }, - query: { description: 'q' }, - } - callback.expr = pathItem - const result = convertPathItem(pathItem) - expect(result).not.toHaveProperty('query') - expect(dig(result, 'get', 'responses', '200')).toEqual({ description: 'ok' }) - expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) - }) - - it('converts a deep shared schema diamond once in both passes', () => { - let schema: Record = { type: 'string' } - for (let depth = 0; depth < 40; depth++) { - schema = { properties: { a: schema, b: schema }, type: 'object' } - } - const result = convertSpec({ - components: { - mediaTypes: { Gone: { schema: {} } }, - schemas: { Dangling: { $ref: '#/components/mediaTypes/Gone/schema' }, Root: schema }, - }, - }) - const root = dig(result, 'components', 'schemas', 'Root') - expect(dig(root, 'properties', 'a')).toBe(dig(root, 'properties', 'b')) - }) - - it('copies a dereferenced schema shared across the document once', () => { - const pet = { $anchor: 'pet', $id: 'https://example.com/pet', properties: { name: { type: 'string' } }, type: 'object' } - const result = convertSpec({ - components: { schemas: { Pet: pet } }, - paths: { '/pets': { get: { responses: { 200: { content: { 'application/json': { schema: pet } }, description: 'ok' } } } } }, - }) - const schema = dig(result, 'components', 'schemas', 'Pet') - expect(schema).toEqual(pet) - expect(schema).not.toBe(pet) - expect(dig(result, 'paths', '/pets', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toBe(schema) - }) - }) -}) - -describe('downgradeSchemaV32ToV31', () => { - it('deep-clones schemas, removing discriminator defaultMapping and mapping xml nodeType', () => { - const source = { - discriminator: { - defaultMapping: 'Dog', - mapping: { dog: '#/components/schemas/Dog' }, - propertyName: 'kind', - }, - properties: { a: { xml: { nodeType: 'text' } } }, - type: 'object', - xml: { nodeType: 'attribute' }, - } satisfies OpenAPIV3_2.SchemaObject - const result = downgradeSchemaV32ToV31(source) - expect(result).toEqual({ - discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, - properties: { a: { xml: {} } }, - type: 'object', - xml: { attribute: true }, - }) - expect(result).not.toBe(source) - expect(dig(result, 'discriminator')).not.toBe(source.discriminator) - expect(dig(result, 'properties')).not.toBe(source.properties) - expect(dig(result, 'properties', 'a')).not.toBe(source.properties.a) - expect(dig(result, 'properties', 'a', 'xml')).not.toBe( - source.properties.a.xml, - ) - expect(dig(result, 'xml')).not.toBe(source.xml) - }) - - it('clones subschema containers at every level', () => { - const source = { - allOf: [{ discriminator: { defaultMapping: 'Dog' } }, true], - items: { xml: { nodeType: 'cdata' } }, - } - const result = downgradeSchemaV32ToV31(source as any) - expect(result).toEqual({ allOf: [{ discriminator: {} }, true], items: { xml: {} } }) - expect(dig(result, 'allOf')).not.toBe(source.allOf) - expect(dig(result, 'allOf', '0')).not.toBe(source.allOf[0]) - expect(dig(result, 'items')).not.toBe(source.items) - }) - - it.each([ - ['maps an attribute node', { xml: { name: 'n', nodeType: 'attribute' } }, { xml: { attribute: true, name: 'n' } }], - ['maps an element node on an array to wrapped', { type: ['array', 'null'], xml: { nodeType: 'element' } }, { type: ['array', 'null'], xml: { wrapped: true } }], - ['drops an element node elsewhere', { type: 'object', xml: { nodeType: 'element' } }, { type: 'object', xml: {} }], - ['drops nodes 3.1 cannot express', { xml: { nodeType: 'text' } }, { xml: {} }], - ['passes a malformed xml through', { xml: 'junk' }, { xml: 'junk' }], - ])('xml: %s', (_name, input, expected) => { - expect(downgradeSchemaV32ToV31(input as any)).toEqual(expected) - }) - - it('converts nested schemas at every subschema position', () => { - const inner = { discriminator: { defaultMapping: 'A', propertyName: 'kind' } } - const out = { discriminator: { propertyName: 'kind' } } - const keywords = ['additionalProperties', 'contains', 'contentSchema', 'else', 'if', 'items', 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties'] - const lists = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] - const maps = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'] - expect(downgradeSchemaV32ToV31({ - ...Object.fromEntries(keywords.map(key => [key, inner])), - ...Object.fromEntries(lists.map(key => [key, [inner, true]])), - ...Object.fromEntries(maps.map(key => [key, { a: inner }])), - 'const': inner, - 'x-extension': inner, - } as any)).toEqual({ - ...Object.fromEntries(keywords.map(key => [key, out])), - ...Object.fromEntries(lists.map(key => [key, [out, true]])), - ...Object.fromEntries(maps.map(key => [key, { a: out }])), - 'const': inner, - 'x-extension': inner, - }) - }) - - it('keeps unknown schema keywords, validation keywords, and extensions unchanged', () => { - const source = { - 'customKeyword': { nested: true }, - 'maximum': 5, - 'type': 'number', - 'x-note': 'kept', - } - expect(downgradeSchemaV32ToV31(source as any)).toEqual(source) - }) - - it('passes boolean and junk schema input through', () => { - expect(downgradeSchemaV32ToV31(true)).toBe(true) - expect(downgradeSchemaV32ToV31(false)).toBe(false) - expect(downgradeSchemaV32ToV31('junk' as any)).toBe('junk') - expect(downgradeSchemaV32ToV31(null as any)).toBeNull() - expect( - downgradeSchemaV32ToV31({ allOf: 'junk', properties: 5 } as any), - ).toEqual({ - allOf: 'junk', - properties: 5, - }) - }) - - it('returns a fresh copy on every call', () => { - const schema: OpenAPIV3_2.SchemaObject = { properties: { a: { type: 'string' } }, type: 'object' } - expect(downgradeSchemaV32ToV31(schema)).not.toBe(downgradeSchemaV32ToV31(schema)) - }) - - it('never mutates the input schema', () => { - const schema: OpenAPIV3_2.SchemaObject = { - discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, - properties: { a: { xml: { nodeType: 'attribute' } } }, - type: 'object', - } - const before = structuredClone(schema) - downgradeSchemaV32ToV31(schema) - expect(schema).toEqual(before) - }) - - it('converts deeply nested schemas without throwing', () => { - let deep: OpenAPIV3_2.SchemaObject = { type: 'string' } - for (let index = 0; index < 1000; index += 1) { - deep = { items: deep, type: 'array' } - } - expect(() => downgradeSchemaV32ToV31(deep)).not.toThrow() - }) -}) diff --git a/packages/downgrader/tests/__snapshots__/chained.test.ts.snap b/packages/downgrader/tests/__snapshots__/chained.test.ts.snap new file mode 100644 index 0000000..908a7ef --- /dev/null +++ b/packages/downgrader/tests/__snapshots__/chained.test.ts.snap @@ -0,0 +1,141 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`official examples > converts the mega document 1`] = ` +{ + "components": { + "schemas": { + "Foo": { + "properties": { + "type": { + "enum": [ + "foo", + ], + }, + }, + "type": "object", + }, + }, + "securitySchemes": {}, + }, + "info": { + "license": { + "name": "Apache 2.0", + }, + "title": "My API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/": { + "get": { + "parameters": [], + "responses": { + "default": { + "description": "", + }, + }, + }, + }, + "/{pathTest}": {}, + }, +} +`; + +exports[`official examples > converts the query example 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/flights/search": {}, + }, +} +`; + +exports[`official examples > converts the tags example 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/flights": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "List all flights", + "tags": [ + "flights", + ], + }, + }, + "/flights/delayed": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "Get delayed flights", + "tags": [ + "delays", + ], + }, + }, + "/flights/domestic": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "List domestic flights", + "tags": [ + "domestic", + ], + }, + }, + "/flights/international": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "List international flights", + "tags": [ + "international", + ], + }, + }, + }, + "tags": [ + { + "description": "Core flight operations", + "name": "flights", + }, + { + "description": "Flights that cross country borders", + "name": "international", + }, + { + "description": "Flights within a single country", + "name": "domestic", + }, + { + "description": "Information about flight delays", + "externalDocs": { + "description": "Delay compensation policies", + "url": "https://docs.example.com/delay-policies", + }, + "name": "delays", + }, + ], +} +`; diff --git a/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap b/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap deleted file mode 100644 index eea66fd..0000000 --- a/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap +++ /dev/null @@ -1,953 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`3.1 example documents downgraded to 3.0 > converts the 3.1 mega document, removing 3.1-only constructs and the mutualTLS scheme 1`] = ` -{ - "components": { - "schemas": { - "Foo": { - "properties": { - "type": { - "enum": [ - "foo", - ], - }, - }, - "type": "object", - }, - }, - "securitySchemes": {}, - }, - "info": { - "license": { - "name": "Apache 2.0", - }, - "title": "My API", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/": { - "get": { - "parameters": [], - "responses": { - "default": { - "description": "", - }, - }, - }, - }, - "/{pathTest}": {}, - }, -} -`; - -exports[`3.1 example documents downgraded to 3.0 > converts the non-OAuth-scopes example, emptying roles on the non-OAuth scheme 1`] = ` -{ - "components": { - "securitySchemes": { - "bearerAuth": { - "bearerFormat": "jwt", - "description": "note: non-oauth scopes are not defined at the securityScheme level", - "scheme": "bearer", - "type": "http", - }, - }, - }, - "info": { - "title": "Non-oAuth Scopes example", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/users": { - "get": { - "responses": { - "default": { - "description": "", - }, - }, - "security": [ - { - "bearerAuth": [], - }, - ], - }, - }, - }, -} -`; - -exports[`3.1 example documents downgraded to 3.0 > converts the tictactoe example to a valid 3.0.4 document without mutating the input 1`] = ` -{ - "components": { - "parameters": { - "columnParam": { - "description": "Board column (horizontal coordinate)", - "in": "path", - "name": "column", - "required": true, - "schema": { - "$ref": "#/components/schemas/coordinate", - }, - }, - "rowParam": { - "description": "Board row (vertical coordinate)", - "in": "path", - "name": "row", - "required": true, - "schema": { - "$ref": "#/components/schemas/coordinate", - }, - }, - }, - "schemas": { - "board": { - "items": { - "items": { - "$ref": "#/components/schemas/mark", - }, - "maxItems": 3, - "minItems": 3, - "type": "array", - }, - "maxItems": 3, - "minItems": 3, - "type": "array", - }, - "coordinate": { - "example": 1, - "maximum": 3, - "minimum": 1, - "type": "integer", - }, - "errorMessage": { - "description": "A text message describing an error", - "maxLength": 256, - "type": "string", - }, - "mark": { - "description": "Possible values for a board square. \`.\` means empty square.", - "enum": [ - ".", - "X", - "O", - ], - "example": ".", - "type": "string", - }, - "status": { - "properties": { - "board": { - "$ref": "#/components/schemas/board", - }, - "winner": { - "$ref": "#/components/schemas/winner", - }, - }, - "type": "object", - }, - "winner": { - "description": "Winner of the game. \`.\` means nobody has won yet.", - "enum": [ - ".", - "X", - "O", - ], - "example": ".", - "type": "string", - }, - }, - "securitySchemes": { - "app2AppOauth": { - "flows": { - "clientCredentials": { - "scopes": { - "board:read": "Read the board", - }, - "tokenUrl": "https://learn.openapis.org/oauth/2.0/token", - }, - }, - "type": "oauth2", - }, - "basicHttpAuthentication": { - "description": "Basic HTTP Authentication", - "scheme": "Basic", - "type": "http", - }, - "bearerHttpAuthentication": { - "bearerFormat": "JWT", - "description": "Bearer token using a JWT", - "scheme": "Bearer", - "type": "http", - }, - "defaultApiKey": { - "description": "API key provided in console", - "in": "header", - "name": "api-key", - "type": "apiKey", - }, - "user2AppOauth": { - "flows": { - "authorizationCode": { - "authorizationUrl": "https://learn.openapis.org/oauth/2.0/auth", - "scopes": { - "board:read": "Read the board", - "board:write": "Write to the board", - }, - "tokenUrl": "https://learn.openapis.org/oauth/2.0/token", - }, - }, - "type": "oauth2", - }, - }, - }, - "info": { - "description": "This API allows writing down marks on a Tic Tac Toe board -and requesting the state of the board or of individual squares. -", - "title": "Tic Tac Toe", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/board": { - "get": { - "description": "Retrieves the current state of the board and the winner.", - "operationId": "get-board", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/status", - }, - }, - }, - "description": "OK", - }, - }, - "security": [ - { - "defaultApiKey": [], - }, - { - "app2AppOauth": [ - "board:read", - ], - }, - ], - "summary": "Get the whole board", - "tags": [ - "Gameplay", - ], - }, - }, - "/board/{row}/{column}": { - "get": { - "description": "Retrieves the requested square.", - "operationId": "get-square", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mark", - }, - }, - }, - "description": "OK", - }, - "400": { - "content": { - "text/html": { - "example": "Illegal coordinates", - "schema": { - "$ref": "#/components/schemas/errorMessage", - }, - }, - }, - "description": "The provided parameters are incorrect", - }, - }, - "security": [ - { - "bearerHttpAuthentication": [], - }, - { - "user2AppOauth": [ - "board:read", - ], - }, - ], - "summary": "Get a single board square", - "tags": [ - "Gameplay", - ], - }, - "parameters": [ - { - "$ref": "#/components/parameters/rowParam", - }, - { - "$ref": "#/components/parameters/columnParam", - }, - ], - "put": { - "description": "Places a mark on the board and retrieves the whole board and the winner (if any).", - "operationId": "put-square", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/mark", - }, - }, - }, - "required": true, - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/status", - }, - }, - }, - "description": "OK", - }, - "400": { - "content": { - "text/html": { - "examples": { - "illegalCoordinates": { - "value": "Illegal coordinates.", - }, - "invalidMark": { - "value": "Invalid Mark (X or O).", - }, - "notEmpty": { - "value": "Square is not empty.", - }, - }, - "schema": { - "$ref": "#/components/schemas/errorMessage", - }, - }, - }, - "description": "The provided parameters are incorrect", - }, - }, - "security": [ - { - "bearerHttpAuthentication": [], - }, - { - "user2AppOauth": [ - "board:write", - ], - }, - ], - "summary": "Set a single board square", - "tags": [ - "Gameplay", - ], - }, - }, - }, - "tags": [ - { - "name": "Gameplay", - }, - ], -} -`; - -exports[`3.1 example documents downgraded to 3.0 > converts the webhook example, removing webhooks and synthesizing empty paths 1`] = ` -{ - "components": { - "schemas": { - "Pet": { - "properties": { - "id": { - "format": "int64", - "type": "integer", - }, - "name": { - "type": "string", - }, - "tag": { - "type": "string", - }, - }, - "required": [ - "id", - "name", - ], - "type": "object", - }, - }, - }, - "info": { - "title": "Webhook Example", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": {}, -} -`; - -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts the 3.2 mega document, removing the discriminator defaultMapping from the schema > v3.0 1`] = ` -{ - "components": { - "schemas": { - "Foo": { - "properties": { - "type": { - "enum": [ - "foo", - ], - }, - }, - "type": "object", - }, - }, - "securitySchemes": {}, - }, - "info": { - "license": { - "name": "Apache 2.0", - }, - "title": "My API", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/": { - "get": { - "parameters": [], - "responses": { - "default": { - "description": "", - }, - }, - }, - }, - "/{pathTest}": {}, - }, -} -`; - -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts the 3.2 mega document, removing the discriminator defaultMapping from the schema > v3.1 1`] = ` -{ - "components": { - "pathItems": { - "myPathItem": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/Foo", - }, - ], - "discriminator": { - "mapping": { - "foo": "Foo", - }, - "propertyName": "type", - "x-extension": true, - }, - "externalDocs": { - "description": "More docs!", - "url": "https://example.com/elsewhere.html", - }, - "myArbitraryKeyword": true, - "properties": { - "arr": { - "$comment": "Array without items keyword", - "type": "array", - }, - "either": { - "type": [ - "string", - "null", - ], - }, - "int": { - "exclusiveMaximum": 100, - "exclusiveMinimum": 0, - "type": "integer", - }, - "none": { - "type": "null", - }, - "type": { - "type": "string", - }, - }, - "type": "object", - }, - }, - }, - "required": true, - }, - }, - }, - }, - "schemas": { - "Foo": { - "properties": { - "type": { - "const": "foo", - }, - }, - "type": "object", - }, - }, - "securitySchemes": { - "mtls": { - "type": "mutualTLS", - }, - }, - }, - "info": { - "license": { - "identifier": "Apache-2.0", - "name": "Apache 2.0", - }, - "summary": "My API's summary", - "title": "My API", - "version": "1.0.0", - }, - "openapi": "3.1.2", - "paths": { - "/": { - "get": { - "parameters": [], - }, - }, - "/{pathTest}": {}, - }, - "webhooks": { - "myWebhook": { - "$ref": "#/components/pathItems/myPathItem", - "description": "Overriding description", - }, - }, -} -`; - -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes tag summary, parent, and kind of the tags example > v3.0 1`] = ` -{ - "info": { - "title": "Flight API", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/flights": { - "get": { - "responses": { - "default": { - "description": "", - }, - }, - "summary": "List all flights", - "tags": [ - "flights", - ], - }, - }, - "/flights/delayed": { - "get": { - "responses": { - "default": { - "description": "", - }, - }, - "summary": "Get delayed flights", - "tags": [ - "delays", - ], - }, - }, - "/flights/domestic": { - "get": { - "responses": { - "default": { - "description": "", - }, - }, - "summary": "List domestic flights", - "tags": [ - "domestic", - ], - }, - }, - "/flights/international": { - "get": { - "responses": { - "default": { - "description": "", - }, - }, - "summary": "List international flights", - "tags": [ - "international", - ], - }, - }, - }, - "tags": [ - { - "description": "Core flight operations", - "name": "flights", - }, - { - "description": "Flights that cross country borders", - "name": "international", - }, - { - "description": "Flights within a single country", - "name": "domestic", - }, - { - "description": "Information about flight delays", - "externalDocs": { - "description": "Delay compensation policies", - "url": "https://docs.example.com/delay-policies", - }, - "name": "delays", - }, - ], -} -`; - -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes tag summary, parent, and kind of the tags example > v3.1 1`] = ` -{ - "info": { - "title": "Flight API", - "version": "1.0.0", - }, - "openapi": "3.1.2", - "paths": { - "/flights": { - "get": { - "summary": "List all flights", - "tags": [ - "flights", - ], - }, - }, - "/flights/delayed": { - "get": { - "summary": "Get delayed flights", - "tags": [ - "delays", - ], - }, - }, - "/flights/domestic": { - "get": { - "summary": "List domestic flights", - "tags": [ - "domestic", - ], - }, - }, - "/flights/international": { - "get": { - "summary": "List international flights", - "tags": [ - "international", - ], - }, - }, - }, - "tags": [ - { - "description": "Core flight operations", - "name": "flights", - }, - { - "description": "Flights that cross country borders", - "name": "international", - }, - { - "description": "Flights within a single country", - "name": "domestic", - }, - { - "description": "Information about flight delays", - "externalDocs": { - "description": "Delay compensation policies", - "url": "https://docs.example.com/delay-policies", - }, - "name": "delays", - }, - ], -} -`; - -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes the query operation of the query example, leaving an empty path item > v3.0 1`] = ` -{ - "info": { - "title": "Flight API", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/flights/search": {}, - }, -} -`; - -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes the query operation of the query example, leaving an empty path item > v3.1 1`] = ` -{ - "info": { - "title": "Flight API", - "version": "1.0.0", - }, - "openapi": "3.1.2", - "paths": { - "/flights/search": {}, - }, -} -`; - -exports[`kitchen-sink 3.2 document chained down to 3.0 > converts every 3.2-only construct and stays valid through both hops > v3.0 1`] = ` -{ - "components": { - "parameters": { - "page": { - "in": "query", - "name": "page", - "schema": { - "type": "integer", - }, - }, - }, - "securitySchemes": { - "deviceAuth": { - "flows": {}, - "type": "oauth2", - }, - }, - }, - "info": { - "title": "Kitchen Sink", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/events": { - "get": { - "operationId": "streamEvents", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "object", - }, - "type": "array", - }, - }, - "application/jsonl": { - "schema": { - "items": { - "properties": { - "kind": { - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", - }, - }, - }, - "description": "Event stream", - }, - "204": { - "description": "", - }, - }, - }, - }, - "/search": { - "get": { - "operationId": "searchEvents", - "parameters": [ - { - "examples": { - "kept": { - "value": "sid=1", - }, - "linked": { - "externalValue": "https://example.com/session.json", - }, - "promoted": { - "value": "sid=3", - }, - }, - "in": "cookie", - "name": "session", - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string", - }, - "type": "array", - }, - }, - }, - "description": "Search results", - }, - }, - }, - }, - }, - "security": [ - { - "deviceAuth": [ - "events:read", - ], - }, - ], - "servers": [ - { - "url": "https://api.example.com", - }, - ], -} -`; - -exports[`kitchen-sink 3.2 document chained down to 3.0 > converts every 3.2-only construct and stays valid through both hops > v3.1 1`] = ` -{ - "components": { - "parameters": { - "page": { - "in": "query", - "name": "page", - "schema": { - "type": "integer", - }, - }, - }, - "securitySchemes": { - "deviceAuth": { - "flows": {}, - "type": "oauth2", - }, - }, - }, - "info": { - "title": "Kitchen Sink", - "version": "1.0.0", - }, - "openapi": "3.1.2", - "paths": { - "/events": { - "get": { - "operationId": "streamEvents", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "object", - }, - "type": "array", - }, - }, - "application/jsonl": { - "schema": { - "items": { - "properties": { - "kind": { - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", - }, - }, - }, - "description": "Event stream", - }, - "204": { - "description": "", - }, - }, - }, - }, - "/search": { - "get": { - "operationId": "searchEvents", - "parameters": [ - { - "examples": { - "kept": { - "value": "sid=1", - }, - "linked": { - "externalValue": "https://example.com/session.json", - }, - "promoted": { - "value": "sid=3", - }, - }, - "in": "cookie", - "name": "session", - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string", - }, - "type": "array", - }, - }, - }, - "description": "Search results", - }, - }, - }, - }, - }, - "security": [ - { - "deviceAuth": [ - "events:read", - ], - }, - ], - "servers": [ - { - "url": "https://api.example.com", - }, - ], -} -`; diff --git a/packages/downgrader/tests/chained.test.ts b/packages/downgrader/tests/chained.test.ts new file mode 100644 index 0000000..f390212 --- /dev/null +++ b/packages/downgrader/tests/chained.test.ts @@ -0,0 +1,122 @@ +// There is no direct 3.2 → 3.0 converter on purpose: the two steps compose +// (see the package README). Every official 3.2 document must survive both +// steps as a valid document at each version. + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { doc as queryExample } from '../../types/tests/examples/3-2-query-example' +import { doc as tagsExample } from '../../types/tests/examples/3-2-tags-example' +import { doc as callbackObjectExamples } from '../../types/tests/schema-tests-3.2/callback-object-examples' +import { doc as compPathitems } from '../../types/tests/schema-tests-3.2/comp-pathitems' +import { doc as componentsObjectExample } from '../../types/tests/schema-tests-3.2/components-object-example' +import { doc as exampleObjectExamples } from '../../types/tests/schema-tests-3.2/example-object-examples' +import { doc as headerObjectExamples } from '../../types/tests/schema-tests-3.2/header-object-examples' +import { doc as infoObjectExample } from '../../types/tests/schema-tests-3.2/info-object-example' +import { doc as infoSummary } from '../../types/tests/schema-tests-3.2/info-summary' +import { doc as jsonSchemaDialect } from '../../types/tests/schema-tests-3.2/json-schema-dialect' +import { doc as licenseIdentifier } from '../../types/tests/schema-tests-3.2/license-identifier' +import { doc as linkObjectExamples } from '../../types/tests/schema-tests-3.2/link-object-examples' +import { doc as mediaTypeExamples } from '../../types/tests/schema-tests-3.2/media-type-examples' +import { doc as mega } from '../../types/tests/schema-tests-3.2/mega' +import { doc as minimalComp } from '../../types/tests/schema-tests-3.2/minimal-comp' +import { doc as minimalHooks } from '../../types/tests/schema-tests-3.2/minimal-hooks' +import { doc as minimalPaths } from '../../types/tests/schema-tests-3.2/minimal-paths' +import { doc as nonOauthScopes } from '../../types/tests/schema-tests-3.2/non-oauth-scopes' +import { doc as operationObjectExample } from '../../types/tests/schema-tests-3.2/operation-object-example' +import { doc as parameterObjectCookieFormAllowReserved } from '../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved' +import { doc as parameterObjectExamples } from '../../types/tests/schema-tests-3.2/parameter-object-examples' +import { doc as parameterObjectPathAllowReserved } from '../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved' +import { doc as parameterObjectQueryAllowReserved } from '../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved' +import { doc as pathItemObjectExample } from '../../types/tests/schema-tests-3.2/path-item-object-example' +import { doc as pathItemServersParameters } from '../../types/tests/schema-tests-3.2/path-item-servers-parameters' +import { doc as pathNoResponse } from '../../types/tests/schema-tests-3.2/path-no-response' +import { doc as pathVarEmptyPathitem } from '../../types/tests/schema-tests-3.2/path-var-empty-pathitem' +import { doc as pathsObjectExample } from '../../types/tests/schema-tests-3.2/paths-object-example' +import { doc as requestBodyExamples } from '../../types/tests/schema-tests-3.2/request-body-examples' +import { doc as responseObjectExamples } from '../../types/tests/schema-tests-3.2/response-object-examples' +import { doc as schema } from '../../types/tests/schema-tests-3.2/schema' +import { doc as schemaObjectDeprecatedExampleKeyword } from '../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword' +import { doc as servers } from '../../types/tests/schema-tests-3.2/servers' +import { doc as specificationExtensions } from '../../types/tests/schema-tests-3.2/specification-extensions' +import { doc as styleDefaults } from '../../types/tests/schema-tests-3.2/style-defaults' +import { doc as tagObjectExample } from '../../types/tests/schema-tests-3.2/tag-object-example' +import { doc as validSchemaTypes } from '../../types/tests/schema-tests-3.2/valid-schema-types' +import { doc as webhookExample } from '../../types/tests/schema-tests-3.2/webhook-example' +import { expectNoNewDanglingRefs, expectValidAs } from './helpers' + +// Left out: security-scheme-object-examples, whose external `$ref` the +// validator cannot resolve. +const corpus: readonly (readonly [name: string, doc: OpenAPIV3_2.OpenAPIObject])[] = [ + ['examples/3-2-query-example', queryExample], + ['examples/3-2-tags-example', tagsExample], + ['callback-object-examples', callbackObjectExamples], + ['comp-pathitems', compPathitems], + ['components-object-example', componentsObjectExample], + ['example-object-examples', exampleObjectExamples], + ['header-object-examples', headerObjectExamples], + ['info-object-example', infoObjectExample], + ['info-summary', infoSummary], + ['json-schema-dialect', jsonSchemaDialect], + ['license-identifier', licenseIdentifier], + ['link-object-examples', linkObjectExamples], + ['media-type-examples', mediaTypeExamples], + ['mega', mega], + ['minimal-comp', minimalComp], + ['minimal-hooks', minimalHooks], + ['minimal-paths', minimalPaths], + ['non-oauth-scopes', nonOauthScopes], + ['operation-object-example', operationObjectExample], + ['parameter-object-cookie-form-allow-reserved', parameterObjectCookieFormAllowReserved], + ['parameter-object-examples', parameterObjectExamples], + ['parameter-object-path-allow-reserved', parameterObjectPathAllowReserved], + ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReserved], + ['path-item-object-example', pathItemObjectExample], + ['path-item-servers-parameters', pathItemServersParameters], + ['path-no-response', pathNoResponse], + ['path-var-empty-pathitem', pathVarEmptyPathitem], + ['paths-object-example', pathsObjectExample], + ['request-body-examples', requestBodyExamples], + ['response-object-examples', responseObjectExamples], + ['schema', schema], + ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeyword], + ['servers', servers], + ['specification-extensions', specificationExtensions], + ['style-defaults', styleDefaults], + ['tag-object-example', tagObjectExample], + ['valid-schema-types', validSchemaTypes], + ['webhook-example', webhookExample], +] + +function downgradeTwice(doc: OpenAPIV3_2.OpenAPIObject) { + return downgradeSpecV31ToV30(downgradeSpecV32ToV31(doc)) +} + +describe('official corpus', () => { + it.each(corpus)('converts %s to valid 3.1 and 3.0 documents without new dangling references', async (_name, doc) => { + const v31 = downgradeSpecV32ToV31(doc) + await expectValidAs(v31, '3.1') + const v30 = downgradeSpecV31ToV30(v31) + expect(v30.openapi).toBe('3.0.4') + await expectValidAs(v30, '3.0') + expectNoNewDanglingRefs(v31, v30) + }) +}) + +describe('official examples', () => { + it('converts the query example', () => { + expect(downgradeTwice(queryExample)).toMatchSnapshot() + }) + + it('converts the tags example', () => { + expect(downgradeTwice(tagsExample)).toMatchSnapshot() + }) + + it('converts the mega document', () => { + const v30 = downgradeTwice(mega) + expect(v30.components).not.toHaveProperty('pathItems') + expect(v30).not.toHaveProperty('webhooks') + expect(v30).toMatchSnapshot() + }) +}) diff --git a/packages/downgrader/tests/corpus.test.ts b/packages/downgrader/tests/corpus.test.ts deleted file mode 100644 index b1f2843..0000000 --- a/packages/downgrader/tests/corpus.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { doc as exampleQueryV32 } from '../../types/tests/examples/3-2-query-example' -import { doc as exampleTagsV32 } from '../../types/tests/examples/3-2-tags-example' -import { doc as exampleNonOauthScopesV31 } from '../../types/tests/examples/non-oauth-scopes-3-1' -import { doc as exampleTictactoeV31 } from '../../types/tests/examples/tictactoe-3-1' -import { doc as exampleWebhookV31 } from '../../types/tests/examples/webhook-example-3-1' -import { doc as callbackObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/callback-object-examples' -import { doc as compPathitemsV31 } from '../../types/tests/schema-tests-3.1/comp-pathitems' -import { doc as componentsObjectExampleV31 } from '../../types/tests/schema-tests-3.1/components-object-example' -import { doc as exampleObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/example-object-examples' -import { doc as headerObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/header-object-examples' -import { doc as infoObjectExampleV31 } from '../../types/tests/schema-tests-3.1/info-object-example' -import { doc as infoSummaryV31 } from '../../types/tests/schema-tests-3.1/info-summary' -import { doc as jsonSchemaDialectV31 } from '../../types/tests/schema-tests-3.1/json-schema-dialect' -import { doc as licenseIdentifierV31 } from '../../types/tests/schema-tests-3.1/license-identifier' -import { doc as linkObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/link-object-examples' -import { doc as mediaTypeExamplesV31 } from '../../types/tests/schema-tests-3.1/media-type-examples' -import { doc as megaV31 } from '../../types/tests/schema-tests-3.1/mega' -import { doc as minimalCompV31 } from '../../types/tests/schema-tests-3.1/minimal-comp' -import { doc as minimalHooksV31 } from '../../types/tests/schema-tests-3.1/minimal-hooks' -import { doc as minimalPathsV31 } from '../../types/tests/schema-tests-3.1/minimal-paths' -import { doc as nonOauthScopesV31 } from '../../types/tests/schema-tests-3.1/non-oauth-scopes' -import { doc as operationObjectExampleV31 } from '../../types/tests/schema-tests-3.1/operation-object-example' -import { doc as parameterObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/parameter-object-examples' -import { doc as parameterObjectQueryAllowReservedV31 } from '../../types/tests/schema-tests-3.1/parameter-object-query-allow-reserved' -import { doc as pathItemObjectExampleV31 } from '../../types/tests/schema-tests-3.1/path-item-object-example' -import { doc as pathItemServersParametersV31 } from '../../types/tests/schema-tests-3.1/path-item-servers-parameters' -import { doc as pathNoResponseV31 } from '../../types/tests/schema-tests-3.1/path-no-response' -import { doc as pathVarEmptyPathitemV31 } from '../../types/tests/schema-tests-3.1/path-var-empty-pathitem' -import { doc as pathsObjectExampleV31 } from '../../types/tests/schema-tests-3.1/paths-object-example' -import { doc as requestBodyExamplesV31 } from '../../types/tests/schema-tests-3.1/request-body-examples' -import { doc as responseObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/response-object-examples' -import { doc as schemaV31 } from '../../types/tests/schema-tests-3.1/schema' -import { doc as schemaObjectDeprecatedExampleKeywordV31 } from '../../types/tests/schema-tests-3.1/schema-object-deprecated-example-keyword' -import { doc as serversV31 } from '../../types/tests/schema-tests-3.1/servers' -import { doc as specificationExtensionsV31 } from '../../types/tests/schema-tests-3.1/specification-extensions' -import { doc as tagObjectExampleV31 } from '../../types/tests/schema-tests-3.1/tag-object-example' -import { doc as validSchemaTypesV31 } from '../../types/tests/schema-tests-3.1/valid-schema-types' -import { doc as webhookExampleV31 } from '../../types/tests/schema-tests-3.1/webhook-example' -import { doc as callbackObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/callback-object-examples' -import { doc as compPathitemsV32 } from '../../types/tests/schema-tests-3.2/comp-pathitems' -import { doc as componentsObjectExampleV32 } from '../../types/tests/schema-tests-3.2/components-object-example' -import { doc as exampleObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/example-object-examples' -import { doc as headerObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/header-object-examples' -import { doc as infoObjectExampleV32 } from '../../types/tests/schema-tests-3.2/info-object-example' -import { doc as infoSummaryV32 } from '../../types/tests/schema-tests-3.2/info-summary' -import { doc as jsonSchemaDialectV32 } from '../../types/tests/schema-tests-3.2/json-schema-dialect' -import { doc as licenseIdentifierV32 } from '../../types/tests/schema-tests-3.2/license-identifier' -import { doc as linkObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/link-object-examples' -import { doc as mediaTypeExamplesV32 } from '../../types/tests/schema-tests-3.2/media-type-examples' -import { doc as megaV32 } from '../../types/tests/schema-tests-3.2/mega' -import { doc as minimalCompV32 } from '../../types/tests/schema-tests-3.2/minimal-comp' -import { doc as minimalHooksV32 } from '../../types/tests/schema-tests-3.2/minimal-hooks' -import { doc as minimalPathsV32 } from '../../types/tests/schema-tests-3.2/minimal-paths' -import { doc as nonOauthScopesV32 } from '../../types/tests/schema-tests-3.2/non-oauth-scopes' -import { doc as operationObjectExampleV32 } from '../../types/tests/schema-tests-3.2/operation-object-example' -import { doc as parameterObjectCookieFormAllowReservedV32 } from '../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved' -import { doc as parameterObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/parameter-object-examples' -import { doc as parameterObjectPathAllowReservedV32 } from '../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved' -import { doc as parameterObjectQueryAllowReservedV32 } from '../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved' -import { doc as pathItemObjectExampleV32 } from '../../types/tests/schema-tests-3.2/path-item-object-example' -import { doc as pathItemServersParametersV32 } from '../../types/tests/schema-tests-3.2/path-item-servers-parameters' -import { doc as pathNoResponseV32 } from '../../types/tests/schema-tests-3.2/path-no-response' -import { doc as pathVarEmptyPathitemV32 } from '../../types/tests/schema-tests-3.2/path-var-empty-pathitem' -import { doc as pathsObjectExampleV32 } from '../../types/tests/schema-tests-3.2/paths-object-example' -import { doc as requestBodyExamplesV32 } from '../../types/tests/schema-tests-3.2/request-body-examples' -import { doc as responseObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/response-object-examples' -import { doc as schemaV32 } from '../../types/tests/schema-tests-3.2/schema' -import { doc as schemaObjectDeprecatedExampleKeywordV32 } from '../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword' -import { doc as serversV32 } from '../../types/tests/schema-tests-3.2/servers' -import { doc as specificationExtensionsV32 } from '../../types/tests/schema-tests-3.2/specification-extensions' -import { doc as styleDefaultsV32 } from '../../types/tests/schema-tests-3.2/style-defaults' -import { doc as tagObjectExampleV32 } from '../../types/tests/schema-tests-3.2/tag-object-example' -import { doc as validSchemaTypesV32 } from '../../types/tests/schema-tests-3.2/valid-schema-types' -import { doc as webhookExampleV32 } from '../../types/tests/schema-tests-3.2/webhook-example' -import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '../src/index' -import { expectNoNewDanglingRefs, expectValidAs } from './helpers' - -// Excluded: security-scheme-object-examples (external $ref the validator cannot resolve) -// and style-defaults (x-comment in an Encoding Object, rejected by the official 3.0 schema). -const corpus31: readonly (readonly [ - name: string, - doc: OpenAPIV3_1.OpenAPIObject, -])[] = [ - ['examples/non-oauth-scopes-3-1', exampleNonOauthScopesV31], - ['examples/tictactoe-3-1', exampleTictactoeV31], - ['examples/webhook-example-3-1', exampleWebhookV31], - ['callback-object-examples', callbackObjectExamplesV31], - ['comp-pathitems', compPathitemsV31], - ['components-object-example', componentsObjectExampleV31], - ['example-object-examples', exampleObjectExamplesV31], - ['header-object-examples', headerObjectExamplesV31], - ['info-object-example', infoObjectExampleV31], - ['info-summary', infoSummaryV31], - ['json-schema-dialect', jsonSchemaDialectV31], - ['license-identifier', licenseIdentifierV31], - ['link-object-examples', linkObjectExamplesV31], - ['media-type-examples', mediaTypeExamplesV31], - ['mega', megaV31], - ['minimal-comp', minimalCompV31], - ['minimal-hooks', minimalHooksV31], - ['minimal-paths', minimalPathsV31], - ['non-oauth-scopes', nonOauthScopesV31], - ['operation-object-example', operationObjectExampleV31], - ['parameter-object-examples', parameterObjectExamplesV31], - [ - 'parameter-object-query-allow-reserved', - parameterObjectQueryAllowReservedV31, - ], - ['path-item-object-example', pathItemObjectExampleV31], - ['path-item-servers-parameters', pathItemServersParametersV31], - ['path-no-response', pathNoResponseV31], - ['path-var-empty-pathitem', pathVarEmptyPathitemV31], - ['paths-object-example', pathsObjectExampleV31], - ['request-body-examples', requestBodyExamplesV31], - ['response-object-examples', responseObjectExamplesV31], - ['schema', schemaV31], - [ - 'schema-object-deprecated-example-keyword', - schemaObjectDeprecatedExampleKeywordV31, - ], - ['servers', serversV31], - ['specification-extensions', specificationExtensionsV31], - ['tag-object-example', tagObjectExampleV31], - ['valid-schema-types', validSchemaTypesV31], - ['webhook-example', webhookExampleV31], -] - -// Excluded: security-scheme-object-examples (external $ref the validator cannot resolve). -const corpus32: readonly (readonly [ - name: string, - doc: OpenAPIV3_2.OpenAPIObject, -])[] = [ - ['examples/3-2-query-example', exampleQueryV32], - ['examples/3-2-tags-example', exampleTagsV32], - ['callback-object-examples', callbackObjectExamplesV32], - ['comp-pathitems', compPathitemsV32], - ['components-object-example', componentsObjectExampleV32], - ['example-object-examples', exampleObjectExamplesV32], - ['header-object-examples', headerObjectExamplesV32], - ['info-object-example', infoObjectExampleV32], - ['info-summary', infoSummaryV32], - ['json-schema-dialect', jsonSchemaDialectV32], - ['license-identifier', licenseIdentifierV32], - ['link-object-examples', linkObjectExamplesV32], - ['media-type-examples', mediaTypeExamplesV32], - ['mega', megaV32], - ['minimal-comp', minimalCompV32], - ['minimal-hooks', minimalHooksV32], - ['minimal-paths', minimalPathsV32], - ['non-oauth-scopes', nonOauthScopesV32], - ['operation-object-example', operationObjectExampleV32], - [ - 'parameter-object-cookie-form-allow-reserved', - parameterObjectCookieFormAllowReservedV32, - ], - ['parameter-object-examples', parameterObjectExamplesV32], - [ - 'parameter-object-path-allow-reserved', - parameterObjectPathAllowReservedV32, - ], - [ - 'parameter-object-query-allow-reserved', - parameterObjectQueryAllowReservedV32, - ], - ['path-item-object-example', pathItemObjectExampleV32], - ['path-item-servers-parameters', pathItemServersParametersV32], - ['path-no-response', pathNoResponseV32], - ['path-var-empty-pathitem', pathVarEmptyPathitemV32], - ['paths-object-example', pathsObjectExampleV32], - ['request-body-examples', requestBodyExamplesV32], - ['response-object-examples', responseObjectExamplesV32], - ['schema', schemaV32], - [ - 'schema-object-deprecated-example-keyword', - schemaObjectDeprecatedExampleKeywordV32, - ], - ['servers', serversV32], - ['specification-extensions', specificationExtensionsV32], - ['style-defaults', styleDefaultsV32], - ['tag-object-example', tagObjectExampleV32], - ['valid-schema-types', validSchemaTypesV32], - ['webhook-example', webhookExampleV32], -] - -describe('3.1 corpus downgraded to 3.0', () => { - it.each(corpus31)( - 'converts %s to a valid 3.0 document without new dangling references or mutating the input', - async (_name, doc) => { - await expectValidAs(doc, '3.1') - const before = structuredClone(doc) - const v30 = downgradeSpecV31ToV30(doc) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expectNoNewDanglingRefs(doc, v30) - expect(doc).toEqual(before) - }, - ) -}) - -describe('3.2 corpus downgraded to 3.1 and chained to 3.0', () => { - it.each(corpus32)( - 'converts %s to valid 3.1 and 3.0 documents without new dangling references or mutating the input', - async (_name, doc) => { - await expectValidAs(doc, '3.2') - const before = structuredClone(doc) - const v31 = downgradeSpecV32ToV31(doc) - expect(v31.openapi).toBe('3.1.2') - await expectValidAs(v31, '3.1') - expectNoNewDanglingRefs(doc, v31) - const v30 = downgradeSpecV31ToV30(v31) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expectNoNewDanglingRefs(v31, v30) - expect(doc).toEqual(before) - }, - ) -}) diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts deleted file mode 100644 index 68ccba4..0000000 --- a/packages/downgrader/tests/e2e.test.ts +++ /dev/null @@ -1,708 +0,0 @@ -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { doc as queryExample } from '../../types/tests/examples/3-2-query-example' -import { doc as tagsExample } from '../../types/tests/examples/3-2-tags-example' -import { doc as nonOauthScopes } from '../../types/tests/examples/non-oauth-scopes-3-1' -import { doc as petstore } from '../../types/tests/examples/petstore-3-0' -import { doc as tictactoe } from '../../types/tests/examples/tictactoe-3-1' -import { doc as webhookExample } from '../../types/tests/examples/webhook-example-3-1' -import { doc as mega31 } from '../../types/tests/schema-tests-3.1/mega' -import { doc as mega32 } from '../../types/tests/schema-tests-3.2/mega' -import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '../src/index' -import { expectValidAs } from './helpers' - -describe('3.1 example documents downgraded to 3.0', () => { - it('converts the tictactoe example to a valid 3.0.4 document without mutating the input', async () => { - const before = structuredClone(tictactoe) - const converted = downgradeSpecV31ToV30(tictactoe) - expect(converted.openapi).toBe('3.0.4') - await expectValidAs(converted, '3.0') - expect(converted).toMatchSnapshot() - expect(tictactoe).toEqual(before) - }) - - it('converts the webhook example, removing webhooks and synthesizing empty paths', async () => { - const before = structuredClone(webhookExample) - const converted = downgradeSpecV31ToV30(webhookExample) - expect(converted.openapi).toBe('3.0.4') - expect(converted).not.toHaveProperty('webhooks') - expect(converted).not.toHaveProperty('x-webhooks') - expect(converted.paths).toEqual({}) - expect(converted.components).toHaveProperty(['schemas', 'Pet']) - await expectValidAs(converted, '3.0') - expect(converted).toMatchSnapshot() - expect(webhookExample).toEqual(before) - }) - - it('converts the non-OAuth-scopes example, emptying roles on the non-OAuth scheme', async () => { - const before = structuredClone(nonOauthScopes) - const converted = downgradeSpecV31ToV30(nonOauthScopes) - expect(converted.openapi).toBe('3.0.4') - expect(converted.paths).toMatchObject({ - '/users': { get: { security: [{ bearerAuth: [] }] } }, - }) - expect(converted.paths?.['/users']?.get?.responses).toEqual({ - default: { description: '' }, - }) - await expectValidAs(converted, '3.0') - expect(converted).toMatchSnapshot() - expect(nonOauthScopes).toEqual(before) - }) - - it('converts the 3.1 mega document, removing 3.1-only constructs and the mutualTLS scheme', async () => { - const before = structuredClone(mega31) - const converted = downgradeSpecV31ToV30(mega31) - expect(converted.openapi).toBe('3.0.4') - expect(converted).not.toHaveProperty('webhooks') - expect(converted).not.toHaveProperty('x-webhooks') - expect(converted.info).toEqual({ - license: { name: 'Apache 2.0' }, - title: 'My API', - version: '1.0.0', - }) - expect(converted.components).not.toHaveProperty('pathItems') - expect(converted.components).not.toHaveProperty('x-pathItems') - expect(converted.components?.securitySchemes).toEqual({}) - expect(JSON.stringify(converted)).not.toContain('#/components/pathItems/') - await expectValidAs(converted, '3.0') - expect(converted).toMatchSnapshot() - expect(mega31).toEqual(before) - }) - - it('inlines $refs into the removed components.pathItems so nothing dangles', async () => { - const doc: OpenAPIV3_1.OpenAPIObject = { - components: { - pathItems: { - shared: { - get: { responses: { 200: { description: 'ok' } } }, - summary: 'Shared', - }, - }, - }, - info: { title: 'Inlined', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/shared': { - $ref: '#/components/pathItems/shared', - description: 'Overriding description', - }, - }, - } - const before = structuredClone(doc) - const converted = downgradeSpecV31ToV30(doc) - expect(converted.components).not.toHaveProperty('pathItems') - expect(converted.paths?.['/shared']).toEqual({ - description: 'Overriding description', - get: { responses: { 200: { description: 'ok' } } }, - summary: 'Shared', - }) - expect(JSON.stringify(converted)).not.toContain('#/components/pathItems/') - await expectValidAs(converted, '3.0') - expect(doc).toEqual(before) - }) - - it('resolves $refs into the removed webhooks and components.pathItems and drops links into them so nothing dangles', async () => { - const petSchema = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' - const doc: OpenAPIV3_1.OpenAPIObject = { - components: { - pathItems: { - item: { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], - }, - }, - schemas: { Pet: { $ref: petSchema } }, - }, - info: { title: 'Webhook references', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/items': { $ref: '#/components/pathItems/item' }, - '/pets': { - get: { - parameters: [ - { $ref: '#/webhooks/newPet/post/parameters/0' }, - { $ref: '#/components/pathItems/item/parameters/0', description: 'Filter' }, - ], - responses: { - 200: { - content: { 'application/json': { schema: { items: { $ref: petSchema }, type: 'array' } } }, - description: 'ok', - links: { - hook: { operationRef: '#/webhooks/newPet/post' }, - item: { operationRef: '#/components/pathItems/item/get' }, - }, - }, - 201: { $ref: '#/webhooks/newPet/post/responses/200' }, - }, - }, - }, - }, - webhooks: { - newPet: { - post: { - operationId: 'newPetHook', - parameters: [{ in: 'header', name: 'X-Signature', schema: { type: 'string' } }], - requestBody: { - content: { - 'application/json': { - schema: { - properties: { name: { type: 'string' }, parent: { $ref: petSchema } }, - type: 'object', - }, - }, - }, - }, - responses: { 200: { description: 'received' } }, - }, - }, - }, - } - await expectValidAs(doc, '3.1') - const before = structuredClone(doc) - const converted = downgradeSpecV31ToV30(doc) - const pet = { properties: { name: { type: 'string' }, parent: {} }, type: 'object' } - expect(converted.components).toEqual({ schemas: { Pet: pet } }) - expect(converted.paths).toEqual({ - '/items': { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], - }, - '/pets': { - get: { - parameters: [ - { in: 'header', name: 'X-Signature', schema: { type: 'string' } }, - { in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }, - ], - responses: { - 200: { - content: { 'application/json': { schema: { items: pet, type: 'array' } } }, - description: 'ok', - links: {}, - }, - 201: { description: 'received' }, - }, - }, - }, - }) - const serialized = JSON.stringify(converted) - expect(serialized).not.toContain('#/webhooks/') - expect(serialized).not.toContain('#/components/pathItems/') - await expectValidAs(converted, '3.0') - expect(doc).toEqual(before) - }) - - it('clones a discriminator with defaultMapping as-is into the 3.0 document', async () => { - const doc = { - components: { - schemas: { - Cat: { - properties: { kind: { type: 'string' } }, - required: ['kind'], - type: 'object', - }, - Pet: { - discriminator: { - defaultMapping: 'Cat', - mapping: { cat: '#/components/schemas/Cat' }, - propertyName: 'kind', - }, - oneOf: [{ $ref: '#/components/schemas/Cat' }], - }, - }, - }, - info: { title: 'Discriminated', version: '1.0.0' }, - openapi: '3.1.0', - paths: {}, - } as any - const before = structuredClone(doc) - const converted = downgradeSpecV31ToV30(doc) - expect(converted).toHaveProperty( - ['components', 'schemas', 'Pet', 'discriminator'], - { - defaultMapping: 'Cat', - mapping: { cat: '#/components/schemas/Cat' }, - propertyName: 'kind', - }, - ) - await expectValidAs(converted, '3.0') - expect(doc).toEqual(before) - }) - - it('converts raw and encoded binary schemas to the 3.0 binary and byte formats', async () => { - const doc: OpenAPIV3_1.OpenAPIObject = { - info: { title: 'Uploads', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/avatar': { - put: { - requestBody: { - content: { - 'image/png': { schema: { contentMediaType: 'image/png' } }, - 'text/plain': { schema: { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' } }, - }, - }, - responses: { 204: { description: 'saved' } }, - }, - }, - }, - } - const before = structuredClone(doc) - const converted = downgradeSpecV31ToV30(doc) - expect(converted.paths['/avatar']?.put?.requestBody).toEqual({ - content: { - 'image/png': { schema: { format: 'binary', type: 'string' } }, - 'text/plain': { schema: { format: 'byte', type: 'string' } }, - }, - }) - await expectValidAs(converted, '3.0') - expect(doc).toEqual(before) - }) - - it('keeps untyped multipart parts sent as application/octet-stream', async () => { - const schema: OpenAPIV3_1.SchemaObject = { - properties: { - addresses: { items: { type: 'object' }, type: 'array' }, - file: { items: {}, type: 'array' }, - id: { format: 'uuid', type: 'string' }, - profileImage: {}, - }, - type: 'object', - } - const headers = { 'X-Rate-Limit-Limit': { schema: { type: 'integer' } } } as const - const doc: OpenAPIV3_1.OpenAPIObject = { - info: { title: 'Uploads', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/profile': { - post: { - requestBody: { - content: { - 'multipart/form-data': { encoding: { profileImage: { headers } }, schema }, - }, - }, - responses: { 204: { description: 'saved' } }, - }, - }, - }, - } - const before = structuredClone(doc) - const converted = downgradeSpecV31ToV30(doc) - expect(converted.paths['/profile']?.post?.requestBody).toEqual({ - content: { - 'multipart/form-data': { - encoding: { - file: { contentType: 'application/octet-stream' }, - profileImage: { contentType: 'application/octet-stream', headers }, - }, - schema, - }, - }, - }) - await expectValidAs(converted, '3.0') - expect(doc).toEqual(before) - }) -}) - -describe('3.2 example documents downgraded to 3.1 and chained to 3.0', () => { - it('removes the query operation of the query example, leaving an empty path item', async () => { - const before = structuredClone(queryExample) - const v31 = downgradeSpecV32ToV31(queryExample) - expect(v31.openapi).toBe('3.1.2') - expect(v31.paths?.['/flights/search']).toEqual({}) - expect(JSON.stringify(v31)).not.toContain('x-additionalOperations') - await expectValidAs(v31, '3.1') - expect(v31).toMatchSnapshot('v3.1') - - const v30 = downgradeSpecV31ToV30(v31) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expect(v30).toMatchSnapshot('v3.0') - expect(queryExample).toEqual(before) - }) - - it('removes tag summary, parent, and kind of the tags example', async () => { - const before = structuredClone(tagsExample) - const v31 = downgradeSpecV32ToV31(tagsExample) - expect(v31.openapi).toBe('3.1.2') - expect(v31.tags).toEqual([ - { description: 'Core flight operations', name: 'flights' }, - { - description: 'Flights that cross country borders', - name: 'international', - }, - { description: 'Flights within a single country', name: 'domestic' }, - { - description: 'Information about flight delays', - externalDocs: { - description: 'Delay compensation policies', - url: 'https://docs.example.com/delay-policies', - }, - name: 'delays', - }, - ]) - await expectValidAs(v31, '3.1') - expect(v31).toMatchSnapshot('v3.1') - - const v30 = downgradeSpecV31ToV30(v31) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expect(v30).toMatchSnapshot('v3.0') - expect(tagsExample).toEqual(before) - }) - - it('inlines $refs into removed 3.2 parts so nothing dangles', async () => { - const pet: OpenAPIV3_2.SchemaObject = { properties: { name: { type: 'string' } }, type: 'object' } - const doc: OpenAPIV3_2.OpenAPIObject = { - components: { - mediaTypes: { - Pet: { examples: { tom: { dataValue: { name: 'Tom' } } }, schema: pet }, - }, - schemas: { Pet: { $ref: '#/components/mediaTypes/Pet/schema' } }, - }, - info: { title: 'Dangling', version: '1.0.0' }, - openapi: '3.2.0', - paths: { - '/pets': { - additionalOperations: { - COPY: { responses: { 201: { description: 'Copied' } } }, - }, - get: { - parameters: [ - { - content: { 'application/x-www-form-urlencoded': { schema: { type: 'object' } } }, - in: 'querystring', - name: 'filter', - }, - { in: 'query', name: 'limit', schema: { type: 'integer' } }, - ], - responses: { - 200: { - content: { - 'application/json': { - examples: { tom: { $ref: '#/components/mediaTypes/Pet/examples/tom' } }, - schema: { items: { $ref: '#/components/schemas/Pet' }, type: 'array' }, - }, - }, - description: 'Pets', - }, - }, - }, - post: { - parameters: [{ $ref: '#/paths/~1pets/get/parameters/1' }], - requestBody: { $ref: '#/paths/~1pets/query/requestBody' }, - responses: { - 200: { $ref: '#/paths/~1pets/query/responses/200' }, - 201: { $ref: '#/paths/~1pets/additionalOperations/COPY/responses/201' }, - }, - }, - query: { - requestBody: { - content: { - 'application/json': { schema: { $ref: '#/components/mediaTypes/Pet/schema' } }, - }, - }, - responses: { 200: { summary: 'Matching pets' } }, - }, - }, - }, - } - const before = structuredClone(doc) - - const v31 = downgradeSpecV32ToV31(doc) - const serialized = JSON.stringify(v31) - expect(serialized).not.toContain('#/components/mediaTypes/') - expect(serialized).not.toContain('~1pets/') - expect(v31.components?.schemas).toEqual({ Pet: pet }) - expect(v31.paths?.['/pets']?.get?.responses?.['200']).toMatchObject({ - content: { 'application/json': { examples: { tom: { value: { name: 'Tom' } } } } }, - }) - expect(v31.paths?.['/pets']?.post).toEqual({ - parameters: [{ in: 'query', name: 'limit', schema: { type: 'integer' } }], - requestBody: { content: { 'application/json': { schema: pet } } }, - responses: { - 200: { description: 'Matching pets' }, - 201: { description: 'Copied' }, - }, - }) - await expectValidAs(v31, '3.1') - - await expectValidAs(downgradeSpecV31ToV30(v31), '3.0') - expect(doc).toEqual(before) - }) - - it('keeps schema identifiers unique when it inlines a schema in several places', async () => { - const doc: OpenAPIV3_2.OpenAPIObject = { - components: { - mediaTypes: { - Pet: { - schema: { - $id: 'https://example.com/pet', - properties: { name: { $anchor: 'name', type: 'string' } }, - type: 'object', - }, - }, - }, - }, - info: { title: 'Identifiers', version: '1.0.0' }, - openapi: '3.2.0', - paths: { - '/pets': { - get: { - responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } }, description: 'Pet' } }, - }, - post: { - requestBody: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } } }, - responses: { - 201: { - content: { 'application/json': { schema: { $ref: '#/components/mediaTypes/Pet/schema/properties/name' } } }, - description: 'Name', - }, - }, - }, - }, - }, - } - const before = structuredClone(doc) - - const v31 = downgradeSpecV32ToV31(doc) - const serialized = JSON.stringify(v31) - expect(serialized.match(/"\$id"/g)).toHaveLength(1) - expect(serialized.match(/"\$anchor"/g)).toHaveLength(1) - await expectValidAs(v31, '3.1') - - await expectValidAs(downgradeSpecV31ToV30(v31), '3.0') - expect(doc).toEqual(before) - }) - - it('converts the 3.2 mega document, removing the discriminator defaultMapping from the schema', async () => { - const before = structuredClone(mega32) - const v31 = downgradeSpecV32ToV31(mega32) - expect(v31.openapi).toBe('3.1.2') - const megaDiscriminatorPath = [ - 'components', - 'pathItems', - 'myPathItem', - 'post', - 'requestBody', - 'content', - 'application/json', - 'schema', - 'discriminator', - ] - expect(v31).not.toHaveProperty([...megaDiscriminatorPath, 'defaultMapping']) - expect(v31).toHaveProperty( - [...megaDiscriminatorPath, 'propertyName'], - 'type', - ) - expect(v31).not.toHaveProperty([ - ...megaDiscriminatorPath, - 'x-defaultMapping', - ]) - await expectValidAs(v31, '3.1') - expect(v31).toMatchSnapshot('v3.1') - - const v30 = downgradeSpecV31ToV30(v31) - expect(v30.openapi).toBe('3.0.4') - expect(v30.components).not.toHaveProperty('pathItems') - expect(v30).not.toHaveProperty('webhooks') - await expectValidAs(v30, '3.0') - expect(v30).toMatchSnapshot('v3.0') - expect(mega32).toEqual(before) - }) -}) - -describe('already-3.0-shaped documents', () => { - it('passes the petstore example through untouched apart from the version stamp', () => { - const before = structuredClone(petstore) - const converted = downgradeSpecV31ToV30(petstore as any) - expect(converted).toEqual({ - ...structuredClone(petstore), - openapi: '3.0.4', - }) - expect(petstore).toEqual(before) - }) -}) - -describe('kitchen-sink 3.2 document chained down to 3.0', () => { - const kitchenSink: OpenAPIV3_2.OpenAPIObject = { - $self: 'https://api.example.com/openapi.json', - components: { - mediaTypes: { - JsonPayload: { - schema: { items: { type: 'string' }, type: 'array' }, - }, - }, - parameters: { - filter: { - content: { - 'application/json': { - schema: { - properties: { term: { type: 'string' } }, - type: 'object', - }, - }, - }, - in: 'querystring', - name: 'filter', - }, - page: { in: 'query', name: 'page', schema: { type: 'integer' } }, - }, - securitySchemes: { - deviceAuth: { - deprecated: true, - flows: { - deviceAuthorization: { - deviceAuthorizationUrl: 'https://auth.example.com/device', - scopes: { 'events:read': 'Read events' }, - tokenUrl: 'https://auth.example.com/token', - }, - }, - oauth2MetadataUrl: 'https://auth.example.com/.well-known/oauth', - type: 'oauth2', - }, - }, - }, - info: { title: 'Kitchen Sink', version: '1.0.0' }, - openapi: '3.2.0', - paths: { - '/events': { - get: { - operationId: 'streamEvents', - responses: { - 200: { - content: { - 'application/json': { - itemSchema: { type: 'object' }, - schema: { items: { type: 'object' }, type: 'array' }, - }, - 'application/jsonl': { - itemSchema: { - properties: { kind: { type: 'string' } }, - type: 'object', - }, - }, - }, - summary: 'Event stream', - }, - 204: {}, - }, - }, - }, - '/search': { - get: { - operationId: 'searchEvents', - parameters: [ - { - content: { - 'application/json': { - schema: { - properties: { term: { type: 'string' } }, - type: 'object', - }, - }, - }, - in: 'querystring', - name: 'filter', - }, - { - examples: { - kept: { serializedValue: 'sid=1', value: 'sid=1' }, - linked: { - dataValue: { sid: 2 }, - externalValue: 'https://example.com/session.json', - }, - promoted: { dataValue: 'sid=3' }, - }, - in: 'cookie', - name: 'session', - schema: { type: 'string' }, - style: 'cookie', - }, - ], - responses: { - 200: { - content: { - 'application/json': { - $ref: '#/components/mediaTypes/JsonPayload', - }, - }, - description: 'Search results', - summary: 'Results', - }, - }, - }, - }, - }, - security: [{ deviceAuth: ['events:read'] }], - servers: [{ name: 'production', url: 'https://api.example.com' }], - } - - it('converts every 3.2-only construct and stays valid through both hops', async () => { - const before = structuredClone(kitchenSink) - - const v31 = downgradeSpecV32ToV31(kitchenSink) - expect(v31.openapi).toBe('3.1.2') - expect(v31).not.toHaveProperty('$self') - expect(v31).not.toHaveProperty('x-self') - expect(v31.servers).toEqual([{ url: 'https://api.example.com' }]) - expect(v31.components).not.toHaveProperty('mediaTypes') - expect(JSON.stringify(v31)).not.toContain('#/components/mediaTypes/') - expect(v31.components?.parameters).toEqual({ - page: { in: 'query', name: 'page', schema: { type: 'integer' } }, - }) - expect(v31.components?.securitySchemes).toEqual({ - deviceAuth: { flows: {}, type: 'oauth2' }, - }) - expect(v31.paths?.['/events']?.get?.responses).toEqual({ - 200: { - content: { - 'application/json': { - schema: { items: { type: 'object' }, type: 'array' }, - }, - 'application/jsonl': { - schema: { - items: { - properties: { kind: { type: 'string' } }, - type: 'object', - }, - type: 'array', - }, - }, - }, - description: 'Event stream', - }, - 204: { description: '' }, - }) - expect(v31.paths?.['/search']?.get?.parameters).toEqual([ - { - examples: { - kept: { value: 'sid=1' }, - linked: { externalValue: 'https://example.com/session.json' }, - promoted: { value: 'sid=3' }, - }, - in: 'cookie', - name: 'session', - schema: { type: 'string' }, - }, - ]) - expect(v31.paths?.['/search']?.get?.responses?.['200']).toEqual({ - content: { - 'application/json': { - schema: { items: { type: 'string' }, type: 'array' }, - }, - }, - description: 'Search results', - }) - expect(v31.security).toEqual([{ deviceAuth: ['events:read'] }]) - await expectValidAs(v31, '3.1') - expect(v31).toMatchSnapshot('v3.1') - - const v30 = downgradeSpecV31ToV30(v31) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expect(v30).toMatchSnapshot('v3.0') - - expect(kitchenSink).toEqual(before) - }) -}) diff --git a/packages/downgrader/tests/helpers.ts b/packages/downgrader/tests/helpers.ts index 9644b33..e976424 100644 --- a/packages/downgrader/tests/helpers.ts +++ b/packages/downgrader/tests/helpers.ts @@ -1,8 +1,10 @@ import { Validator } from '@seriousme/openapi-schema-validator' import { expect } from 'vitest' -import { resolve } from '../src/shared' - +/** + * Reads a nested value, one own key per step, so assertions can reach deep + * into a converted document without optional chaining at every level. + */ export function dig(value: unknown, ...path: string[]): unknown { let current: unknown = value for (const key of path) { @@ -11,6 +13,36 @@ export function dig(value: unknown, ...path: string[]): unknown { return current } +/** + * Resolves a local `$ref` such as `#/components/schemas/Pet` against `root`. + * The fragment is percent-decoded first (RFC 3986) and then split into + * JSON Pointer tokens with `~1` and `~0` unescaped (RFC 6901). + */ +export function resolvePointer(root: unknown, ref: string): unknown { + if (!ref.startsWith('#')) { + return undefined + } + let pointer: string + try { + pointer = decodeURIComponent(ref.slice(1)) + } + catch { + return undefined + } + if (pointer === '') { + return root + } + let current = root + for (const token of pointer.slice(1).split('/')) { + const key = token.replaceAll('~1', '/').replaceAll('~0', '~') + if (typeof current !== 'object' || current === null || !Object.hasOwn(current, key)) { + return undefined + } + current = (current as Record)[key] + } + return current +} + function collectLocalRefs(value: unknown, refs: Set): Set { if (typeof value === 'object' && value !== null) { for (const [key, item] of Object.entries(value)) { @@ -25,15 +57,24 @@ function collectLocalRefs(value: unknown, refs: Set): Set { return refs } +/** + * Every local `$ref` and `operationRef` in `output` that resolved in `input` + * must still resolve in `output`: a conversion may keep a reference only + * when its target survives. + */ export function expectNoNewDanglingRefs(input: object, output: object): void { for (const ref of collectLocalRefs(output, new Set())) { - if (resolve(input, ref) !== undefined) { - expect(resolve(output, ref), ref).toBeDefined() + if (resolvePointer(input, ref) !== undefined) { + expect(resolvePointer(output, ref), ref).toBeDefined() } } } -export async function expectValidAs(spec: object, expectedVersion: string): Promise { +/** + * Validates a document against the official OpenAPI JSON Schema of the + * version its `openapi` field names. + */ +export async function expectValidAs(spec: object, expectedVersion: '3.0' | '3.1' | '3.2'): Promise { const validator = new Validator() const result = await validator.validate(structuredClone(spec) as Record) expect(result.errors ?? []).toEqual([]) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts new file mode 100644 index 0000000..65b8079 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts @@ -0,0 +1,90 @@ +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('examples', () => { + // 3.1 uses the JSON Schema `examples` list, and deprecates the singular + // OpenAPI `example`: https://spec.openapis.org/oas/v3.1.2.html#schema-example + // 3.0 only has the singular one: https://spec.openapis.org/oas/v3.0.4.html#schema-example + // https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#change-schema-example-to-examples + it.each([ + ['promotes the first entry to example', { examples: ['a', 'b'] }, { example: 'a' }], + ['keeps an explicit example over the entries', { example: 'e', examples: ['a'] }, { example: 'e' }], + ['keeps a falsy first entry', { examples: [0] }, { example: 0 }], + ['drops an empty list', { examples: [] }, {}], + ['drops a malformed value', { examples: 'junk' }, {}], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('binary content', () => { + // 3.1 describes binary strings with `contentEncoding` and + // `contentMediaType`, where 3.0 used `format: byte` and `format: binary`: + // https://spec.openapis.org/oas/v3.1.2.html#migrating-binary-descriptions-from-oas-3-0 + // https://spec.openapis.org/oas/v3.0.4.html#working-with-binary-data + // - encoded binary (`contentEncoding: base64`) is `format: byte` + // - raw binary (`contentMediaType` without an encoding) is `format: binary` + // Raw binary has no `type` in 3.1 because it is not a JSON value, but in 3.0 + // it is a `string`. + it.each([ + ['turns base64 into format: byte', { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' }, { format: 'byte', type: 'string' }], + ['turns raw binary into type: string with format: binary', { contentMediaType: 'image/png' }, { format: 'binary', type: 'string' }], + ['adds type: string beside base64 when type is missing', { contentEncoding: 'base64' }, { format: 'byte', type: 'string' }], + ['keeps nullable on binary strings', { contentMediaType: 'image/png', type: ['string', 'null'] }, { format: 'binary', nullable: true, type: 'string' }], + [ + 'keeps format beside a type union that includes string', + { contentMediaType: 'image/png', type: ['string', 'integer'] }, + { anyOf: [{ type: 'string' }, { type: 'integer' }], format: 'binary' }, + ], + ['keeps an existing format', { contentEncoding: 'base64', format: 'custom' }, { format: 'custom', type: 'string' }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // `format: byte` is base64 as in RFC 4648 section 4, so it cannot describe + // the URL-safe alphabet of section 5, or any other encoding: + // https://spec.openapis.org/oas/v3.0.4.html#data-type-format + // Content keywords on a type that is not a string have nothing to map to. + it.each([ + ['drops base64url, which format: byte does not cover', { contentEncoding: 'base64url', contentMediaType: 'image/png', type: 'string' }, { type: 'string' }], + ['drops content keywords on non-string types', { contentMediaType: 'image/png', type: 'object' }, { type: 'object' }], + ['drops a malformed contentMediaType', { contentMediaType: 42 }, {}], + ['drops contentSchema', { contentSchema: { type: 'string' } }, {}], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('xml.nodeType', () => { + // `nodeType` is a 3.2 field (https://spec.openapis.org/oas/v3.2.0.html#xml-node-type) + // that can reach a 3.1 document written by hand or by a lenient tool. The + // 3.2 → 3.1 converter maps it the same way. + it.each([ + ['maps attribute to attribute: true', { type: 'string', xml: { name: 'n', nodeType: 'attribute' } }, { type: 'string', xml: { attribute: true, name: 'n' } }], + ['maps element on an array to wrapped: true', { items: {}, type: 'array', xml: { nodeType: 'element' } }, { items: {}, type: 'array', xml: { wrapped: true } }], + ['maps element on a nullable array to wrapped: true', { type: ['array', 'null'], xml: { nodeType: 'element' } }, { items: {}, nullable: true, type: 'array', xml: { wrapped: true } }], + ['removes element on other schemas', { type: 'string', xml: { nodeType: 'element' } }, { type: 'string', xml: {} }], + ['removes values 3.0 cannot express', { type: 'string', xml: { name: 'n', nodeType: 'text' } }, { type: 'string', xml: { name: 'n' } }], + ['keeps an xml object without nodeType', { type: 'string', xml: { attribute: true, name: 'n' } }, { type: 'string', xml: { attribute: true, name: 'n' } }], + ['passes a malformed xml value through', { type: 'string', xml: 'junk' }, { type: 'string', xml: 'junk' }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('discriminator', () => { + it('keeps the discriminator and its mapping', () => { + const schema = { + discriminator: { mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }, + oneOf: [{ $ref: '#/components/schemas/Cat' }], + } + expect(convert(schema)).toEqual(schema) + }) + + it('passes a malformed discriminator through', () => { + expect(convert({ discriminator: 'junk' })).toEqual({ discriminator: 'junk' }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts new file mode 100644 index 0000000..952f3b0 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts @@ -0,0 +1,66 @@ +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('const', () => { + // `const` arrived in JSON Schema draft 06, after the draft Wright-00 (05) + // that 3.0 builds on. A single-value `enum` means the same: + // https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.3 + it.each([ + ['turns const into a single-value enum', { const: 'a' }, { enum: ['a'] }], + ['keeps a zero const', { const: 0 }, { enum: [0] }], + ['keeps a false const', { const: false }, { enum: [false] }], + ['keeps an empty-string const', { const: '' }, { enum: [''] }], + ['keeps a null const', { const: null }, { enum: [null] }], + ['keeps a null const beside a null-only type', { const: null, type: ['null'] }, { enum: [null] }], + [ + 'keeps the nullable branches of a multi-type null const', + { const: null, type: ['string', 'integer', 'null'] }, + { anyOf: [{ nullable: true, type: 'string' }, { nullable: true, type: 'integer' }], enum: [null] }, + ], + // Accepts nothing in both versions: null is not a string, and 3.0 does + // not add null to a type without `nullable: true`. + ['keeps a null const that contradicts its type', { const: null, type: 'string' }, { enum: [null], type: 'string' }], + ['matches nothing when a non-null const contradicts a null-only type', { const: 7, type: ['null'] }, { enum: [7], not: {} }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // A value must satisfy both `const` and `enum`. When the const value is in + // the enum, the const alone says it all. When it is not, nothing matches + // in 3.1, and the single enum is looser (see loosening.test.ts). + it('replaces an existing enum with the const value', () => { + expect(convert({ const: 5, enum: [1, 2, 5] })).toEqual({ enum: [5] }) + expect(convert({ const: 5, enum: [1, 2] })).toEqual({ enum: [5] }) + }) +}) + +describe('enum', () => { + // The official 3.0 schema requires at least one entry (`minItems: 1`): + // https://spec.openapis.org/oas/3.0/schema/2021-09-28 + // In 3.1 an empty enum matches nothing; without it the 3.0 schema is + // looser (see loosening.test.ts for what that means under `not`). + it.each([ + ['removes an empty enum', { enum: [], type: 'string' }, { type: 'string' }], + ['keeps a non-empty enum', { enum: ['a'], type: 'string' }, { enum: ['a'], type: 'string' }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('required', () => { + // The official 3.0 schema requires a non-empty list of unique names + // (`minItems: 1`, `uniqueItems: true`): https://spec.openapis.org/oas/3.0/schema/2021-09-28 + // An empty list requires nothing, and a repeated name requires nothing + // more, so both fixes keep the meaning. + it.each([ + ['removes an empty required list', { required: [] }, {}], + ['keeps a non-empty required list', { required: ['a'] }, { required: ['a'] }], + ['deduplicates required names', { required: ['a', 'b', 'a'], type: 'object' }, { required: ['a', 'b'], type: 'object' }], + ['passes a malformed required value through', { required: 'junk' }, { required: 'junk' }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts new file mode 100644 index 0000000..ff1c350 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts @@ -0,0 +1,127 @@ +import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' + +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +import { dig } from '../../helpers' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('input shapes', () => { + it('clones non-schema input unchanged', () => { + expect(convert(null)).toBeNull() + expect(convert(42)).toBe(42) + expect(convert('x')).toBe('x') + const list = [{ type: 'string' }] + const result = convert(list) + expect(result).toEqual(list) + expect(result).not.toBe(list) + }) + + it('treats keywords named like Object.prototype members as unknown keywords', () => { + const input = JSON.parse('{"constructor":1,"hasOwnProperty":2,"toString":3,"__proto__":{"type":["string","null"]},"type":"string"}') + const result = convert(input) as object + expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) + expect(Object.getOwnPropertyDescriptor(result, 'hasOwnProperty')?.value).toBe(2) + expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(3) + expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toEqual({ type: ['string', 'null'] }) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + }) + + // JSON.parse creates a real own `__proto__` key, here a property name. + // It is converted like any other property. + it('converts a property named __proto__ without polluting prototypes', () => { + const properties = dig(convert(JSON.parse('{"properties":{"__proto__":{"type":["string","null"]}}}')), 'properties') as object + expect(Object.getOwnPropertyDescriptor(properties, '__proto__')?.value).toEqual({ nullable: true, type: 'string' }) + expect(Object.getPrototypeOf(properties)).toBe(Object.prototype) + expect('nullable' in {}).toBe(false) + }) +}) + +describe('copies', () => { + it('never mutates the input schema', () => { + const input: OpenAPIV3_1.SchemaObject = { + $ref: '#/c/s', + allOf: [{ type: 'string' }], + const: null, + examples: ['a'], + exclusiveMinimum: 5, + minimum: 3, + prefixItems: [{ type: 'string' }], + properties: { a: { type: ['string', 'null'] } }, + type: ['object', 'null'], + } + const before = structuredClone(input) + downgradeSchemaV31ToV30(input) + expect(input).toEqual(before) + }) + + it('returns a fresh copy on every call', () => { + const schema: OpenAPIV3_1.SchemaObject = { properties: { a: { type: 'string' } }, type: 'object' } + expect(downgradeSchemaV31ToV30(schema)).not.toBe(downgradeSchemaV31ToV30(schema)) + }) + + it('converts deeply nested schemas without overflowing the stack', () => { + let deep: OpenAPIV3_1.SchemaObject = { type: 'string' } + for (let index = 0; index < 1000; index += 1) { + deep = { items: deep, type: 'array' } + } + expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow() + }) +}) + +describe('object graphs', () => { + // A dereferenced schema can contain itself. The output keeps the same + // shape: the cycle points at the converted ancestor. + it('converts a cyclic schema, pointing the cycle at the converted ancestor', () => { + const properties: Record = {} + const node: Record = { properties, type: ['object', 'null'] } + properties.self = node + properties.children = { items: node, type: 'array' } + const result = convert(node) as Record + expect(result.type).toBe('object') + expect(result.nullable).toBe(true) + expect(dig(result, 'properties', 'self')).toBe(result) + expect(dig(result, 'properties', 'children', 'items')).toBe(result) + expect(node.type).toEqual(['object', 'null']) + }) + + it('converts a cycle that closes several levels down', () => { + const grandchild: Record = { type: ['string', 'null'] } + const child = { properties: { grandchild }, type: 'object' } + grandchild.items = child + const result = convert({ properties: { child }, type: 'object' }) + const convertedChild = dig(result, 'properties', 'child') + expect(dig(convertedChild, 'properties', 'grandchild', 'items')).toBe(convertedChild) + }) + + // The `items` of a multi-type schema moves into the array branch, and the + // cycle it closes points at the converted schema that holds that branch. + it('points the array branch of a cyclic multi-type schema at the converted schema', () => { + const node: Record = { type: ['array', 'object'] } + node.items = node + const result = convert(node) as Record + expect(result).not.toHaveProperty('items') + expect(dig(result, 'anyOf', '0', 'items')).toBe(result) + expect(dig(result, 'anyOf', '1')).toEqual({ type: 'object' }) + expect(node.items).toBe(node) + }) + + // A diamond (two properties sharing one subschema) doubles the number of + // paths per level: 2^64 here. Converting each shared object once keeps the + // work linear. + it('converts a schema reached along many paths once', () => { + let node: OpenAPIV3_1.SchemaObject = { type: ['string', 'null'] } + for (let index = 0; index < 64; index += 1) { + node = { properties: { left: node, right: node }, type: 'object' } + } + const result = convert(node) + expect(dig(result, 'properties', 'left')).toBe(dig(result, 'properties', 'right')) + let leaf = result + for (let index = 0; index < 64; index += 1) { + leaf = dig(leaf, 'properties', 'left') + } + expect(leaf).toEqual({ nullable: true, type: 'string' }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts new file mode 100644 index 0000000..7ef92c5 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts @@ -0,0 +1,79 @@ +// Removing a keyword with no 3.0 form (see removed-keywords.test.ts) makes a +// schema accept more values: it is "loosened". A looser schema never rejects +// a value the original accepted, which is the safe direction for a +// conversion. Two applicators flip that direction: +// - `not` rejects what its operand accepts, so a looser operand rejects +// more: https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.4 +// Such a `not` is removed instead, which loosens the enclosing schema. +// - `oneOf` rejects a value that more than one branch accepts, so a looser +// branch can start to overlap another and reject valid values: +// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.3 +// Such a `oneOf` becomes `anyOf`, which accepts any overlap. +// Loosening propagates up through `properties`, `items`, +// `additionalProperties`, `allOf`, and `anyOf`, and a cut recursion or an +// object cycle counts as loosened too. + +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('not', () => { + it.each([ + ['removes a not whose operand lost a keyword', { not: { patternProperties: { a: {} } } }, {}], + ['removes a not whose operand is loosened deeper down', { not: { properties: { a: { if: {} } } } }, {}], + ['removes a not whose operand lost an empty enum', { not: { enum: [] } }, {}], + ['removes a not whose operand is a cut recursion', { $defs: { a: { not: { $ref: '#/$defs/a' } } }, $ref: '#/$defs/a' }, { allOf: [{}] }], + ['removes a not whose null-only type has a malformed enum', { not: { enum: 'junk', type: 'null' } }, {}], + // `const: 1` with `enum: [2]` accepts nothing, but `enum: [1]` accepts 1. + ['removes a not whose const falls outside its enum', { not: { const: 1, enum: [2] } }, {}], + ['removes both nots of a loosened double negation', { not: { not: { prefixItems: [] } } }, {}], + [ + 'follows loosening through items, additionalProperties, allOf, and anyOf', + { not: { allOf: [{ anyOf: [{ additionalProperties: { items: { contains: {} } } }] }] } }, + {}, + ], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + it.each([ + ['keeps a not whose const lies inside its enum', { not: { const: 1, enum: [1, 2] } }, { not: { enum: [1] } }], + ['keeps a not whose operand converts exactly', { not: { type: ['string', 'null'] } }, { not: { nullable: true, type: 'string' } }], + ['keeps a not whose null-only operand matches nothing exactly', { not: { const: 'a', type: 'null' } }, { not: { enum: ['a'], not: {} } }], + ['keeps a not over a boolean schema', { not: false }, { not: { not: {} } }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('oneOf', () => { + it.each([ + ['turns a oneOf with a loosened branch into anyOf', { oneOf: [{ prefixItems: [] }, { type: 'string' }] }, { anyOf: [{}, { type: 'string' }] }], + [ + 'nests that anyOf in allOf beside an existing anyOf', + { anyOf: [{ type: 'string' }], oneOf: [{ unevaluatedProperties: false }] }, + { allOf: [{ anyOf: [{}] }], anyOf: [{ type: 'string' }] }, + ], + ['keeps a oneOf whose branches convert exactly', { oneOf: [{ type: ['integer', 'null'] }, { type: 'string' }] }, { oneOf: [{ nullable: true, type: 'integer' }, { type: 'string' }] }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('object cycles', () => { + // A dereferenced schema can contain itself. The cycle is kept as a cycle, + // but it cannot be proven exact, so it counts as loosened. + it('treats a cycle of the input graph as loosened under not and oneOf', () => { + const negated: any = { not: { properties: {} }, patternProperties: { '^x': { type: 'string' } } } + negated.not.properties.p = negated + expect(convert(negated)).toEqual({}) + + const tree: any = { oneOf: [{ required: ['value'], type: 'object' }], unevaluatedProperties: false } + tree.oneOf.push({ properties: { children: { items: tree, type: 'array' } }, type: 'object' }) + const out = convert(tree) as any + expect(out.oneOf).toBeUndefined() + expect(out.anyOf[1].properties.children.items).toBe(out) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts new file mode 100644 index 0000000..eada4a0 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts @@ -0,0 +1,36 @@ +// JSON Schema 2020-12 `exclusiveMinimum` / `exclusiveMaximum` are numbers, +// bounds of their own: +// https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.5 +// In 3.0 (draft Wright-00) they are booleans that make `minimum` / `maximum` +// exclusive: https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords +// https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#update-exclusiveminimum-and-exclusivemaximum +// 3.1 allows both an inclusive and an exclusive bound at once, while 3.0 has +// one bound per side, so the tighter of the two is kept. At a tie the +// exclusive one is tighter. + +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +it.each([ + ['turns a numeric exclusiveMinimum into minimum plus the flag', { exclusiveMinimum: 3 }, { exclusiveMinimum: true, minimum: 3 }], + ['keeps a tighter inclusive minimum', { exclusiveMinimum: 3, minimum: 5 }, { minimum: 5 }], + ['replaces a looser inclusive minimum', { exclusiveMinimum: 5, minimum: 3 }, { exclusiveMinimum: true, minimum: 5 }], + ['prefers the exclusive form for equal minimums', { exclusiveMinimum: 3, minimum: 3 }, { exclusiveMinimum: true, minimum: 3 }], + ['turns a numeric exclusiveMaximum into maximum plus the flag', { exclusiveMaximum: 10 }, { exclusiveMaximum: true, maximum: 10 }], + ['keeps a tighter inclusive maximum', { exclusiveMaximum: 10, maximum: 5 }, { maximum: 5 }], + ['replaces a looser inclusive maximum', { exclusiveMaximum: 5, maximum: 10 }, { exclusiveMaximum: true, maximum: 5 }], + ['prefers the exclusive form for equal maximums', { exclusiveMaximum: 5, maximum: 5 }, { exclusiveMaximum: true, maximum: 5 }], + ['converts both sides at once', { exclusiveMaximum: 9, exclusiveMinimum: 1 }, { exclusiveMaximum: true, exclusiveMinimum: true, maximum: 9, minimum: 1 }], +])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) +}) + +it.each([ + ['a 3.0-style boolean exclusiveMinimum', { exclusiveMinimum: true, minimum: 3 }], + ['a 3.0-style boolean exclusiveMaximum', { exclusiveMaximum: false, maximum: 3 }], +])('passes %s through', (_name, input) => { + expect(convert(input)).toEqual(input) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts new file mode 100644 index 0000000..8e5af93 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts @@ -0,0 +1,94 @@ +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('$ref with sibling keywords', () => { + // A 3.0 Reference Object "cannot be extended with additional properties, + // and any properties added SHALL be ignored": + // https://spec.openapis.org/oas/v3.0.4.html#reference-object + // In 3.1 a `$ref` applies beside its siblings, like one more `allOf` entry: + // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1 + // Moving the `$ref` into `allOf` keeps both applying in 3.0. It goes + // first, so existing `allOf` entries keep their relative order. + it.each([ + ['moves the $ref into allOf', { $ref: '#/c/s', minLength: 1 }, { allOf: [{ $ref: '#/c/s' }], minLength: 1 }], + ['prepends the $ref to an existing allOf', { $ref: '#/c/s', allOf: [{ type: 'string' }] }, { allOf: [{ $ref: '#/c/s' }, { type: 'string' }] }], + ['nests a malformed allOf instead of discarding it', { $ref: '#/c/s', allOf: 'junk' }, { allOf: [{ $ref: '#/c/s' }, { allOf: 'junk' }] }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + it('keeps a lone $ref as a bare Reference Object, wherever it points', () => { + const input = { $ref: '#/components/schemas/Pet' } + const result = convert(input) + expect(result).toEqual(input) + expect(result).not.toBe(input) + expect(convert({ $ref: 'https://example.com/pet.json' })).toEqual({ $ref: 'https://example.com/pet.json' }) + }) + + it('passes a non-string $ref through', () => { + expect(convert({ $ref: 123, type: 'string' })).toEqual({ $ref: 123, type: 'string' }) + expect(convert({ $ref: 123 })).toEqual({ $ref: 123 }) + }) +}) + +describe('references into removed keywords', () => { + // `$defs` has no 3.0 form, and 3.0 schemas are reused through + // `components.schemas` instead. A standalone schema has no components, so + // each `$ref` into `$defs` is replaced by the converted definition. + // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.4 + it('inlines $refs into $defs', () => { + expect(convert({ $defs: { a: { type: ['string', 'null'] } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ + items: { nullable: true, type: 'string' }, + type: 'array', + }) + }) + + // Inlining a recursive definition would never end. The recursion is cut + // at its first repeat with `{}`, the schema that accepts anything, so the + // result can only be looser than the original, never stricter. + it('cuts recursion into {}', () => { + expect(convert({ + $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, + $ref: '#/$defs/node', + })).toEqual({ allOf: [{ properties: { next: {} }, type: 'object' }] }) + }) + + it('inlines a definition that is itself an external reference', () => { + expect(convert({ + $defs: { pet: { $ref: './schemas/pet.yaml' } }, + properties: { pet: { $ref: '#/$defs/pet' } }, + })).toEqual({ properties: { pet: { $ref: './schemas/pet.yaml' } } }) + }) + + // The `items` beside `prefixItems` is removed, and an `items: {}` + // placeholder takes its place so the array stays valid 3.0. A `$ref` to + // the original `items` must get the original schema, not the placeholder. + it('inlines a $ref to items removed beside prefixItems instead of the placeholder that replaced them', () => { + expect(convert({ + properties: { + cell: { $ref: '#/properties/row/items' }, + notCell: { not: { $ref: '#/properties/row/items' } }, + row: { items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' }, + }, + })).toEqual({ + properties: { + cell: { type: 'integer' }, + notCell: { not: { type: 'integer' } }, + row: { items: {}, type: 'array' }, + }, + }) + }) + + // 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', () => { + const schema = { + discriminator: { mapping: { a: '#/webhooks/newPet/post/requestBody/content/application~1json/schema' }, propertyName: 'kind' }, + properties: { a: { $ref: '#/webhooks/newPet/post/requestBody/content/application~1json/schema' } }, + } + expect(convert(schema)).toEqual(schema) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts new file mode 100644 index 0000000..45de9db --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts @@ -0,0 +1,73 @@ +// 3.0 supports a fixed subset of JSON Schema, and "additional keywords +// defined by the JSON Schema specification that are not mentioned here are +// strictly unsupported": https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords +// The official 3.0 schema rejects them (`additionalProperties: false`), so +// JSON Schema 2020-12 keywords without a 3.0 form are removed. + +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +it('removes every keyword with no 3.0 equivalent', () => { + expect(convert({ + $anchor: 'a', + $comment: 'c', + $defs: { D: { type: 'string' } }, + $dynamicAnchor: 'da', + $dynamicRef: '#dr', + $id: 'https://example.com/s', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $vocabulary: { 'https://example.com/v': true }, + contains: { type: 'string' }, + contentSchema: { type: 'string' }, + dependentRequired: { a: ['b'] }, + dependentSchemas: { a: { type: 'object' } }, + else: { title: 'e' }, + if: { title: 'i' }, + maxContains: 2, + minContains: 1, + patternProperties: { '^x': { type: 'string' } }, + prefixItems: [{ type: 'string' }], + propertyNames: { pattern: '^a' }, + then: { title: 't' }, + type: 'string', + unevaluatedItems: false, + unevaluatedProperties: false, + })).toEqual({ type: 'string' }) +}) + +// Some keywords only mean something together with a removed neighbor: +// - Beside `prefixItems`, `items` applies to the items after the prefix +// only: https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1.2 +// Kept alone, it would wrongly constrain the prefix items too. +// - Beside `patternProperties`, `additionalProperties` skips the +// properties the patterns match: https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.3 +// Kept alone, it would wrongly constrain those properties too. +it.each([ + ['removes items together with prefixItems', { items: { type: 'integer' }, prefixItems: [{ type: 'string' }] }, {}], + [ + 'removes a boolean additionalProperties together with patternProperties', + { additionalProperties: false, patternProperties: { '^x-': {} }, properties: { name: { type: 'string' } }, type: 'object' }, + { properties: { name: { type: 'string' } }, type: 'object' }, + ], + [ + 'removes a schema additionalProperties together with patternProperties', + { additionalProperties: { type: 'integer' }, patternProperties: { '^x-': {} }, type: 'object' }, + { type: 'object' }, + ], +])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) +}) + +// An array keeps its `type`, so it needs an `items` again once the removed +// `prefixItems` took the original one with it. +it('gives an array that lost its items an empty one', () => { + expect(convert({ items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' })).toEqual({ items: {}, type: 'array' }) +}) + +it('keeps extensions and unknown keywords', () => { + const input = { 'customKeyword': 'v', 'title': 't', 'x-foo': { a: 1 } } + expect(convert(input)).toEqual(input) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts new file mode 100644 index 0000000..93b0895 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts @@ -0,0 +1,54 @@ +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('boolean schemas', () => { + // `true` and `false` are schemas in JSON Schema 2020-12 that accept + // everything and nothing: https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 + // 3.0 needs Schema Objects, so they become `{}` and `{ not: {} }`. + it('converts the boolean schemas', () => { + expect(downgradeSchemaV31ToV30(true)).toEqual({}) + expect(downgradeSchemaV31ToV30(false)).toEqual({ not: {} }) + }) + + // `additionalProperties` is the one place 3.0 still takes a boolean: + // https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords + it.each([ + ['keeps a boolean additionalProperties', { additionalProperties: false }, { additionalProperties: false }], + ['converts a boolean property schema', { properties: { a: true } }, { properties: { a: {} } }], + ['converts a true items schema', { items: true }, { items: {} }], + ['converts a false items schema', { items: false }, { items: { not: {} } }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('nested schemas', () => { + it.each([ + [ + 'converts property schemas', + { properties: { a: { type: ['string', 'null'] }, b: true }, type: 'object' }, + { properties: { a: { nullable: true, type: 'string' }, b: {} }, type: 'object' }, + ], + ['converts a schema additionalProperties', { additionalProperties: { type: ['string', 'null'] } }, { additionalProperties: { nullable: true, type: 'string' } }], + [ + 'converts allOf, anyOf, oneOf, and not', + { allOf: [true], anyOf: [{ const: 1 }], not: false, oneOf: [{ type: ['integer', 'null'] }] }, + { allOf: [{}], anyOf: [{ enum: [1] }], not: { not: {} }, oneOf: [{ nullable: true, type: 'integer' }] }, + ], + ['converts items', { items: { type: ['string', 'null'] } }, { items: { nullable: true, type: 'string' } }], + ['passes a malformed allOf through', { allOf: 'junk' }, { allOf: 'junk' }], + ['passes malformed properties through', { properties: 5 }, { properties: 5 }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // `const`, `default`, and `enum` hold instance data: a value there that + // looks like a schema is copied as is. + it('does not convert schema-like values outside subschema positions', () => { + const data = { type: ['string', 'null'] } + expect(convert({ 'default': data, 'enum': [data], 'x-data': data })).toEqual({ 'default': data, 'enum': [data], 'x-data': data }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts new file mode 100644 index 0000000..e0be279 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts @@ -0,0 +1,142 @@ +// In 3.0, `type` MUST be a single string and `"null"` is not a type: +// https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords +// https://spec.openapis.org/oas/v3.0.4.html#data-types +// Null is allowed with `nullable: true` instead, which only takes effect +// beside an explicit `type`: https://spec.openapis.org/oas/v3.0.4.html#schema-nullable +// The official guide shows the same mapping in the other direction: +// https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#replace-nullable-with-type-arrays + +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +function convert(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} + +describe('a single type', () => { + it.each([ + ['keeps a single type', { type: 'string' }, { type: 'string' }], + ['turns a type and null into nullable', { type: ['string', 'null'] }, { nullable: true, type: 'string' }], + ['deduplicates entries', { type: ['string', 'string'] }, { type: 'string' }], + ['ignores non-string entries beside valid ones', { type: ['string', 42] }, { type: 'string' }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('only null', () => { + // `nullable` does nothing without a `type` beside it, so a schema that + // accepts only null becomes an enum of the single value null. + it.each([ + ['turns type: "null" into a null enum', { type: 'null' }, { enum: [null] }], + ['turns type: ["null"] into a null enum', { type: ['null'] }, { enum: [null] }], + ['narrows an existing enum that allows null', { enum: ['a', null], type: ['null'] }, { enum: [null] }], + ['converts a null-only anyOf branch', { anyOf: [{ type: 'string' }, { type: 'null' }] }, { anyOf: [{ type: 'string' }, { enum: [null] }] }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // Here the 3.1 schema accepts nothing at all: the value must be null and + // also one of the enum values, none of which is null. `not: {}` keeps + // that meaning, since `{}` accepts everything. + it('matches nothing when the enum of a null-only type excludes null', () => { + expect(convert({ enum: ['a'], type: ['null'] })).toEqual({ enum: ['a'], not: {} }) + }) + + it('drops the type beside a malformed enum', () => { + expect(convert({ enum: 'junk', type: ['null'] })).toEqual({ enum: 'junk' }) + }) +}) + +describe('several types', () => { + // A union of types has no 3.0 `type` form, so each type becomes its own + // `anyOf` branch. When null was listed, every branch is nullable. + it.each([ + ['turns several types into anyOf branches', { type: ['string', 'integer'] }, { anyOf: [{ type: 'string' }, { type: 'integer' }] }], + [ + 'makes every branch nullable when null was listed', + { type: ['string', 'integer', 'null'] }, + { anyOf: [{ nullable: true, type: 'string' }, { nullable: true, type: 'integer' }] }, + ], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // 3.0 requires `items` wherever `type` is `"array"`, so the array branch + // takes the sibling `items`, or an empty one when there is none. Other + // types ignore `items`, so it moves rather than being copied. + it.each([ + ['gives the array branch an empty items', { type: ['array', 'string'] }, { anyOf: [{ items: {}, type: 'array' }, { type: 'string' }] }], + [ + 'moves a sibling items into the array branch', + { items: { type: 'integer' }, type: ['array', 'string', 'null'] }, + { anyOf: [{ items: { type: 'integer' }, nullable: true, type: 'array' }, { nullable: true, type: 'string' }] }, + ], + [ + 'leaves items in place when no branch is an array', + { items: { type: 'integer' }, type: ['object', 'string'] }, + { anyOf: [{ type: 'object' }, { type: 'string' }], items: { type: 'integer' } }, + ], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // An existing `anyOf` must keep applying too, so the type union joins + // `allOf` rather than replacing or merging into it. + it.each([ + [ + 'wraps the union into allOf when anyOf already exists', + { anyOf: [{ minLength: 1 }], type: ['string', 'integer'] }, + { allOf: [{ anyOf: [{ type: 'string' }, { type: 'integer' }] }], anyOf: [{ minLength: 1 }] }, + ], + [ + 'appends the union to an existing allOf', + { allOf: [{ title: 't' }], anyOf: [{ minLength: 1 }], type: ['string', 'integer'] }, + { allOf: [{ title: 't' }, { anyOf: [{ type: 'string' }, { type: 'integer' }] }], anyOf: [{ minLength: 1 }] }, + ], + [ + 'nests a malformed allOf beside the union', + { allOf: 'junk', anyOf: [{ type: 'string' }], items: { type: 'integer' }, type: ['array', 'string'] }, + { + allOf: [{ allOf: 'junk' }, { anyOf: [{ items: { type: 'integer' }, type: 'array' }, { type: 'string' }] }], + anyOf: [{ type: 'string' }], + }, + ], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) + + // Each level moves `items` into one branch instead of copying it into + // several, so nested unions grow linearly rather than doubling per level. + it('keeps nested multi-type arrays linear instead of doubling per level', () => { + let input: unknown = { type: 'string' } + let expected: unknown = { type: 'string' } + for (let index = 0; index < 10; index += 1) { + input = { items: input, type: ['array', 'object'] } + expected = { anyOf: [{ items: expected, type: 'array' }, { type: 'object' }] } + } + expect(convert(input)).toEqual(expected) + }) +}) + +describe('arrays', () => { + // "`items` MUST be present if `type` is `"array"`": + // https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords + // `{}` accepts every item, which is what an absent `items` means in 3.1. + it.each([ + ['adds an empty items to an array without one', { type: 'array' }, { items: {}, type: 'array' }], + ['adds an empty items to a nullable array without one', { type: ['array', 'null'] }, { items: {}, nullable: true, type: 'array' }], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) + +describe('malformed type values', () => { + it.each([ + ['passes an array of non-string entries through', { type: [42] }, { type: [42] }], + ['passes a number through', { type: 42 }, { type: 42 }], + ['passes an object through', { type: { a: 1 } }, { type: { a: 1 } }], + ['drops an empty array', { type: [] }, {}], + ])('%s', (_name, input, expected) => { + expect(convert(input)).toEqual(expected) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap b/packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap new file mode 100644 index 0000000..6ca311d --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap @@ -0,0 +1,398 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`official examples > converts the tictactoe example 1`] = ` +{ + "components": { + "parameters": { + "columnParam": { + "description": "Board column (horizontal coordinate)", + "in": "path", + "name": "column", + "required": true, + "schema": { + "$ref": "#/components/schemas/coordinate", + }, + }, + "rowParam": { + "description": "Board row (vertical coordinate)", + "in": "path", + "name": "row", + "required": true, + "schema": { + "$ref": "#/components/schemas/coordinate", + }, + }, + }, + "schemas": { + "board": { + "items": { + "items": { + "$ref": "#/components/schemas/mark", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "coordinate": { + "example": 1, + "maximum": 3, + "minimum": 1, + "type": "integer", + }, + "errorMessage": { + "description": "A text message describing an error", + "maxLength": 256, + "type": "string", + }, + "mark": { + "description": "Possible values for a board square. \`.\` means empty square.", + "enum": [ + ".", + "X", + "O", + ], + "example": ".", + "type": "string", + }, + "status": { + "properties": { + "board": { + "$ref": "#/components/schemas/board", + }, + "winner": { + "$ref": "#/components/schemas/winner", + }, + }, + "type": "object", + }, + "winner": { + "description": "Winner of the game. \`.\` means nobody has won yet.", + "enum": [ + ".", + "X", + "O", + ], + "example": ".", + "type": "string", + }, + }, + "securitySchemes": { + "app2AppOauth": { + "flows": { + "clientCredentials": { + "scopes": { + "board:read": "Read the board", + }, + "tokenUrl": "https://learn.openapis.org/oauth/2.0/token", + }, + }, + "type": "oauth2", + }, + "basicHttpAuthentication": { + "description": "Basic HTTP Authentication", + "scheme": "Basic", + "type": "http", + }, + "bearerHttpAuthentication": { + "bearerFormat": "JWT", + "description": "Bearer token using a JWT", + "scheme": "Bearer", + "type": "http", + }, + "defaultApiKey": { + "description": "API key provided in console", + "in": "header", + "name": "api-key", + "type": "apiKey", + }, + "user2AppOauth": { + "flows": { + "authorizationCode": { + "authorizationUrl": "https://learn.openapis.org/oauth/2.0/auth", + "scopes": { + "board:read": "Read the board", + "board:write": "Write to the board", + }, + "tokenUrl": "https://learn.openapis.org/oauth/2.0/token", + }, + }, + "type": "oauth2", + }, + }, + }, + "info": { + "description": "This API allows writing down marks on a Tic Tac Toe board +and requesting the state of the board or of individual squares. +", + "title": "Tic Tac Toe", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/board": { + "get": { + "description": "Retrieves the current state of the board and the winner.", + "operationId": "get-board", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status", + }, + }, + }, + "description": "OK", + }, + }, + "security": [ + { + "defaultApiKey": [], + }, + { + "app2AppOauth": [ + "board:read", + ], + }, + ], + "summary": "Get the whole board", + "tags": [ + "Gameplay", + ], + }, + }, + "/board/{row}/{column}": { + "get": { + "description": "Retrieves the requested square.", + "operationId": "get-square", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/mark", + }, + }, + }, + "description": "OK", + }, + "400": { + "content": { + "text/html": { + "example": "Illegal coordinates", + "schema": { + "$ref": "#/components/schemas/errorMessage", + }, + }, + }, + "description": "The provided parameters are incorrect", + }, + }, + "security": [ + { + "bearerHttpAuthentication": [], + }, + { + "user2AppOauth": [ + "board:read", + ], + }, + ], + "summary": "Get a single board square", + "tags": [ + "Gameplay", + ], + }, + "parameters": [ + { + "$ref": "#/components/parameters/rowParam", + }, + { + "$ref": "#/components/parameters/columnParam", + }, + ], + "put": { + "description": "Places a mark on the board and retrieves the whole board and the winner (if any).", + "operationId": "put-square", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/mark", + }, + }, + }, + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status", + }, + }, + }, + "description": "OK", + }, + "400": { + "content": { + "text/html": { + "examples": { + "illegalCoordinates": { + "value": "Illegal coordinates.", + }, + "invalidMark": { + "value": "Invalid Mark (X or O).", + }, + "notEmpty": { + "value": "Square is not empty.", + }, + }, + "schema": { + "$ref": "#/components/schemas/errorMessage", + }, + }, + }, + "description": "The provided parameters are incorrect", + }, + }, + "security": [ + { + "bearerHttpAuthentication": [], + }, + { + "user2AppOauth": [ + "board:write", + ], + }, + ], + "summary": "Set a single board square", + "tags": [ + "Gameplay", + ], + }, + }, + }, + "tags": [ + { + "name": "Gameplay", + }, + ], +} +`; + +exports[`official examples > empties the roles on the non-OAuth scheme of the non-OAuth-scopes example 1`] = ` +{ + "components": { + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "jwt", + "description": "note: non-oauth scopes are not defined at the securityScheme level", + "scheme": "bearer", + "type": "http", + }, + }, + }, + "info": { + "title": "Non-oAuth Scopes example", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/users": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + }, + }, + }, +} +`; + +exports[`official examples > removes the 3.1-only constructs and the mutualTLS scheme of the mega document 1`] = ` +{ + "components": { + "schemas": { + "Foo": { + "properties": { + "type": { + "enum": [ + "foo", + ], + }, + }, + "type": "object", + }, + }, + "securitySchemes": {}, + }, + "info": { + "license": { + "name": "Apache 2.0", + }, + "title": "My API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/": { + "get": { + "parameters": [], + "responses": { + "default": { + "description": "", + }, + }, + }, + }, + "/{pathTest}": {}, + }, +} +`; + +exports[`official examples > removes the webhooks of the webhook example, leaving empty paths 1`] = ` +{ + "components": { + "schemas": { + "Pet": { + "properties": { + "id": { + "format": "int64", + "type": "integer", + }, + "name": { + "type": "string", + }, + "tag": { + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "type": "object", + }, + }, + }, + "info": { + "title": "Webhook Example", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": {}, +} +`; 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 new file mode 100644 index 0000000..b69cffb --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts @@ -0,0 +1,72 @@ +import { dig } from '../../helpers' +import { convertComponent, convertSpec } from './helpers' + +it('removes pathItems and keeps the other component maps', () => { + const result = convertSpec({ + components: { + pathItems: { Reusable: { get: { summary: 's' } } }, + schemas: { S: { type: 'string' } }, + }, + }) + expect(result.components).toEqual({ schemas: { S: { type: 'string' } } }) + expect(result.components).not.toHaveProperty('x-pathItems') +}) + +it('converts component callbacks and schemas, including boolean schemas', () => { + expect(convertSpec({ + components: { + 'callbacks': { + junkCallback: 42, + realCallback: { + 'x-note': { '{$expr}': { get: {} } }, + '{$request.body#/url}': { post: { summary: 's' } }, + }, + }, + 'schemas': { S: { type: ['string', 'null'] }, T: true }, + 'x-extra': { keep: true }, + }, + }).components).toEqual({ + 'callbacks': { + junkCallback: 42, + realCallback: { + 'x-note': { '{$expr}': { get: {} } }, + '{$request.body#/url}': { post: { responses: { default: { description: '' } }, summary: 's' } }, + }, + }, + 'schemas': { S: { nullable: true, type: 'string' }, T: {} }, + 'x-extra': { keep: true }, + }) +}) + +it('strips the overrides from a callback reference to a missing path item', () => { + expect(convertComponent('callbacks', { $ref: '#/components/pathItems/Reusable', summary: 's' })).toEqual({ + $ref: '#/components/pathItems/Reusable', + }) +}) + +it('keeps examples as they are, since 3.0 examples have the same fields', () => { + expect(convertComponent('examples', { description: 'd', summary: 's', value: { a: 1 } })).toEqual({ + description: 'd', + summary: 's', + value: { a: 1 }, + }) +}) + +it('clones a malformed components value unchanged', () => { + expect(convertSpec({ components: 'junk' }).components).toBe('junk') +}) + +// A single object can sit in positions of different kinds, for example as +// both an Operation and a Schema. Each position gets its own conversion, +// whichever it meets first. +it('converts an object shared between an operation and a schema as each', () => { + const shared = {} + for (const fields of [ + { components: { schemas: { S: shared } }, paths: { '/a': { get: shared } } }, + { paths: { '/a': { get: shared } }, components: { schemas: { S: shared } } }, + ]) { + const result = convertSpec(fields) + expect(dig(result, 'paths', '/a', 'get')).toEqual({ responses: { default: { description: '' } } }) + expect(dig(result, 'components', 'schemas', 'S')).toEqual({}) + } +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts new file mode 100644 index 0000000..27b8585 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts @@ -0,0 +1,309 @@ +// The official 3.1 documents, from OAI/learn.openapis.org and from the +// `tests/schema/pass` folder of OAI/OpenAPI-Specification (see +// packages/types/tests/README.md). Each one must downgrade to a document the +// official 3.0 JSON Schema accepts, without leaving a reference dangling that +// resolved before. + +import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' + +import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' + +import { doc as nonOauthScopesExample } from '../../../../types/tests/examples/non-oauth-scopes-3-1' +import { doc as petstore } from '../../../../types/tests/examples/petstore-3-0' +import { doc as tictactoe } from '../../../../types/tests/examples/tictactoe-3-1' +import { doc as webhookExampleDoc } from '../../../../types/tests/examples/webhook-example-3-1' +import { doc as callbackObjectExamples } from '../../../../types/tests/schema-tests-3.1/callback-object-examples' +import { doc as compPathitems } from '../../../../types/tests/schema-tests-3.1/comp-pathitems' +import { doc as componentsObjectExample } from '../../../../types/tests/schema-tests-3.1/components-object-example' +import { doc as exampleObjectExamples } from '../../../../types/tests/schema-tests-3.1/example-object-examples' +import { doc as headerObjectExamples } from '../../../../types/tests/schema-tests-3.1/header-object-examples' +import { doc as infoObjectExample } from '../../../../types/tests/schema-tests-3.1/info-object-example' +import { doc as infoSummary } from '../../../../types/tests/schema-tests-3.1/info-summary' +import { doc as jsonSchemaDialect } from '../../../../types/tests/schema-tests-3.1/json-schema-dialect' +import { doc as licenseIdentifier } from '../../../../types/tests/schema-tests-3.1/license-identifier' +import { doc as linkObjectExamples } from '../../../../types/tests/schema-tests-3.1/link-object-examples' +import { doc as mediaTypeExamples } from '../../../../types/tests/schema-tests-3.1/media-type-examples' +import { doc as mega } from '../../../../types/tests/schema-tests-3.1/mega' +import { doc as minimalComp } from '../../../../types/tests/schema-tests-3.1/minimal-comp' +import { doc as minimalHooks } from '../../../../types/tests/schema-tests-3.1/minimal-hooks' +import { doc as minimalPaths } from '../../../../types/tests/schema-tests-3.1/minimal-paths' +import { doc as nonOauthScopes } from '../../../../types/tests/schema-tests-3.1/non-oauth-scopes' +import { doc as operationObjectExample } from '../../../../types/tests/schema-tests-3.1/operation-object-example' +import { doc as parameterObjectExamples } from '../../../../types/tests/schema-tests-3.1/parameter-object-examples' +import { doc as parameterObjectQueryAllowReserved } from '../../../../types/tests/schema-tests-3.1/parameter-object-query-allow-reserved' +import { doc as pathItemObjectExample } from '../../../../types/tests/schema-tests-3.1/path-item-object-example' +import { doc as pathItemServersParameters } from '../../../../types/tests/schema-tests-3.1/path-item-servers-parameters' +import { doc as pathNoResponse } from '../../../../types/tests/schema-tests-3.1/path-no-response' +import { doc as pathVarEmptyPathitem } from '../../../../types/tests/schema-tests-3.1/path-var-empty-pathitem' +import { doc as pathsObjectExample } from '../../../../types/tests/schema-tests-3.1/paths-object-example' +import { doc as requestBodyExamples } from '../../../../types/tests/schema-tests-3.1/request-body-examples' +import { doc as responseObjectExamples } from '../../../../types/tests/schema-tests-3.1/response-object-examples' +import { doc as schema } from '../../../../types/tests/schema-tests-3.1/schema' +import { doc as schemaObjectDeprecatedExampleKeyword } from '../../../../types/tests/schema-tests-3.1/schema-object-deprecated-example-keyword' +import { doc as servers } from '../../../../types/tests/schema-tests-3.1/servers' +import { doc as specificationExtensions } from '../../../../types/tests/schema-tests-3.1/specification-extensions' +import { doc as tagObjectExample } from '../../../../types/tests/schema-tests-3.1/tag-object-example' +import { doc as validSchemaTypes } from '../../../../types/tests/schema-tests-3.1/valid-schema-types' +import { doc as webhookExample } from '../../../../types/tests/schema-tests-3.1/webhook-example' +import { expectNoNewDanglingRefs, expectValidAs } from '../../helpers' + +// Left out: +// - security-scheme-object-examples, whose external `$ref` the validator +// cannot resolve +// - style-defaults, which puts an `x-comment` in an Encoding Object; the +// official 3.0 schema rejects extensions there +const corpus: readonly (readonly [name: string, doc: OpenAPIV3_1.OpenAPIObject])[] = [ + ['examples/non-oauth-scopes-3-1', nonOauthScopesExample], + ['examples/tictactoe-3-1', tictactoe], + ['examples/webhook-example-3-1', webhookExampleDoc], + ['callback-object-examples', callbackObjectExamples], + ['comp-pathitems', compPathitems], + ['components-object-example', componentsObjectExample], + ['example-object-examples', exampleObjectExamples], + ['header-object-examples', headerObjectExamples], + ['info-object-example', infoObjectExample], + ['info-summary', infoSummary], + ['json-schema-dialect', jsonSchemaDialect], + ['license-identifier', licenseIdentifier], + ['link-object-examples', linkObjectExamples], + ['media-type-examples', mediaTypeExamples], + ['mega', mega], + ['minimal-comp', minimalComp], + ['minimal-hooks', minimalHooks], + ['minimal-paths', minimalPaths], + ['non-oauth-scopes', nonOauthScopes], + ['operation-object-example', operationObjectExample], + ['parameter-object-examples', parameterObjectExamples], + ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReserved], + ['path-item-object-example', pathItemObjectExample], + ['path-item-servers-parameters', pathItemServersParameters], + ['path-no-response', pathNoResponse], + ['path-var-empty-pathitem', pathVarEmptyPathitem], + ['paths-object-example', pathsObjectExample], + ['request-body-examples', requestBodyExamples], + ['response-object-examples', responseObjectExamples], + ['schema', schema], + ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeyword], + ['servers', servers], + ['specification-extensions', specificationExtensions], + ['tag-object-example', tagObjectExample], + ['valid-schema-types', validSchemaTypes], + ['webhook-example', webhookExample], +] + +describe('official corpus', () => { + it.each(corpus)('converts %s to a valid 3.0 document without new dangling references or mutating the input', async (_name, doc) => { + await expectValidAs(doc, '3.1') + const before = structuredClone(doc) + const v30 = downgradeSpecV31ToV30(doc) + expect(v30.openapi).toBe('3.0.4') + await expectValidAs(v30, '3.0') + expectNoNewDanglingRefs(doc, v30) + expect(doc).toEqual(before) + }) +}) + +describe('official examples', () => { + it('converts the tictactoe example', () => { + expect(downgradeSpecV31ToV30(tictactoe)).toMatchSnapshot() + }) + + it('removes the webhooks of the webhook example, leaving empty paths', () => { + const v30 = downgradeSpecV31ToV30(webhookExampleDoc) + expect(v30).not.toHaveProperty('webhooks') + expect(v30.paths).toEqual({}) + expect(v30.components).toHaveProperty(['schemas', 'Pet']) + expect(v30).toMatchSnapshot() + }) + + it('empties the roles on the non-OAuth scheme of the non-OAuth-scopes example', () => { + const v30 = downgradeSpecV31ToV30(nonOauthScopesExample) + expect(v30.paths['/users']?.get?.security).toEqual([{ bearerAuth: [] }]) + expect(v30.paths['/users']?.get?.responses).toEqual({ default: { description: '' } }) + expect(v30).toMatchSnapshot() + }) + + it('removes the 3.1-only constructs and the mutualTLS scheme of the mega document', () => { + const v30 = downgradeSpecV31ToV30(mega) + expect(v30.info).toEqual({ license: { name: 'Apache 2.0' }, title: 'My API', version: '1.0.0' }) + expect(v30.components).not.toHaveProperty('pathItems') + expect(v30.components?.securitySchemes).toEqual({}) + expect(JSON.stringify(v30)).not.toContain('#/components/pathItems/') + expect(v30).toMatchSnapshot() + }) + + // A document that only uses what 3.0 already had comes out unchanged, + // apart from the version. + it('passes the 3.0 petstore example through apart from the version', () => { + expect(downgradeSpecV31ToV30(petstore as any)).toEqual({ ...structuredClone(petstore), openapi: '3.0.4' }) + }) +}) + +describe('hand-written documents', () => { + it('inlines references into webhooks and components.pathItems into a valid 3.0 document', async () => { + const petSchema = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' + const doc: OpenAPIV3_1.OpenAPIObject = { + components: { + pathItems: { + item: { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], + }, + }, + schemas: { Pet: { $ref: petSchema } }, + }, + info: { title: 'Webhook references', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/items': { $ref: '#/components/pathItems/item' }, + '/pets': { + get: { + parameters: [ + { $ref: '#/webhooks/newPet/post/parameters/0' }, + { $ref: '#/components/pathItems/item/parameters/0', description: 'Filter' }, + ], + responses: { + 200: { + content: { 'application/json': { schema: { items: { $ref: petSchema }, type: 'array' } } }, + description: 'ok', + links: { + hook: { operationRef: '#/webhooks/newPet/post' }, + item: { operationRef: '#/components/pathItems/item/get' }, + }, + }, + 201: { $ref: '#/webhooks/newPet/post/responses/200' }, + }, + }, + }, + }, + webhooks: { + newPet: { + post: { + operationId: 'newPetHook', + parameters: [{ in: 'header', name: 'X-Signature', schema: { type: 'string' } }], + requestBody: { + content: { + 'application/json': { + schema: { properties: { name: { type: 'string' }, parent: { $ref: petSchema } }, type: 'object' }, + }, + }, + }, + responses: { 200: { description: 'received' } }, + }, + }, + }, + } + await expectValidAs(doc, '3.1') + const v30 = downgradeSpecV31ToV30(doc) + const pet = { properties: { name: { type: 'string' }, parent: {} }, type: 'object' } + expect(v30.components).toEqual({ schemas: { Pet: pet } }) + expect(v30.paths).toEqual({ + '/items': { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], + }, + '/pets': { + get: { + parameters: [ + { in: 'header', name: 'X-Signature', schema: { type: 'string' } }, + { in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }, + ], + responses: { + 200: { content: { 'application/json': { schema: { items: pet, type: 'array' } } }, description: 'ok', links: {} }, + 201: { description: 'received' }, + }, + }, + }, + }) + await expectValidAs(v30, '3.0') + }) + + it('converts raw and encoded binary bodies into a valid 3.0 document', async () => { + const doc: OpenAPIV3_1.OpenAPIObject = { + info: { title: 'Uploads', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/avatar': { + put: { + requestBody: { + content: { + 'image/png': { schema: { contentMediaType: 'image/png' } }, + 'text/plain': { schema: { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' } }, + }, + }, + responses: { 204: { description: 'saved' } }, + }, + }, + }, + } + const v30 = downgradeSpecV31ToV30(doc) + expect(v30.paths['/avatar']?.put?.requestBody).toEqual({ + content: { + 'image/png': { schema: { format: 'binary', type: 'string' } }, + 'text/plain': { schema: { format: 'byte', type: 'string' } }, + }, + }) + await expectValidAs(v30, '3.0') + }) + + it('keeps untyped multipart parts sent as application/octet-stream in a valid 3.0 document', async () => { + const schema: OpenAPIV3_1.SchemaObject = { + properties: { + addresses: { items: { type: 'object' }, type: 'array' }, + file: { items: {}, type: 'array' }, + id: { format: 'uuid', type: 'string' }, + profileImage: {}, + }, + type: 'object', + } + const headers = { 'X-Rate-Limit-Limit': { schema: { type: 'integer' } } } as const + const doc: OpenAPIV3_1.OpenAPIObject = { + info: { title: 'Uploads', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/profile': { + post: { + requestBody: { content: { 'multipart/form-data': { encoding: { profileImage: { headers } }, schema } } }, + responses: { 204: { description: 'saved' } }, + }, + }, + }, + } + const v30 = downgradeSpecV31ToV30(doc) + expect(v30.paths['/profile']?.post?.requestBody).toEqual({ + content: { + 'multipart/form-data': { + encoding: { + file: { contentType: 'application/octet-stream' }, + profileImage: { contentType: 'application/octet-stream', headers }, + }, + schema, + }, + }, + }) + await expectValidAs(v30, '3.0') + }) + + // `defaultMapping` is a 3.2 field that can reach a 3.1 document written by + // hand or by a lenient tool. The 3.0 schema tolerates unknown + // discriminator fields, so it is kept. + it('keeps a discriminator defaultMapping, which the 3.0 schema tolerates', async () => { + const doc = { + components: { + schemas: { + Cat: { properties: { kind: { type: 'string' } }, required: ['kind'], type: 'object' }, + Pet: { + discriminator: { defaultMapping: 'Cat', mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }, + oneOf: [{ $ref: '#/components/schemas/Cat' }], + }, + }, + }, + info: { title: 'Discriminated', version: '1.0.0' }, + openapi: '3.1.0', + paths: {}, + } + const v30 = downgradeSpecV31ToV30(doc as any) + expect(v30).toHaveProperty(['components', 'schemas', 'Pet', 'discriminator'], doc.components.schemas.Pet.discriminator) + await expectValidAs(v30, '3.0') + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts new file mode 100644 index 0000000..4711755 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts @@ -0,0 +1,109 @@ +import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' + +import { convertSpec, empty, info } from './helpers' + +describe('openapi and paths', () => { + it('stamps 3.0.4, the latest 3.0 patch release', () => { + expect(downgradeSpecV31ToV30({ info, openapi: '3.1.1', paths: {} })).toEqual(empty) + }) + + // 3.1 made `paths` optional (a document may hold only webhooks or + // components): https://spec.openapis.org/oas/v3.1.2.html#oas-paths + // In 3.0 it is REQUIRED: https://spec.openapis.org/oas/v3.0.4.html#oas-paths + // An empty Paths Object is valid and describes no operations. + it('adds the version and an empty paths object when they are missing', () => { + expect(downgradeSpecV31ToV30({ info } as any)).toEqual(empty) + }) + + it('clones non-object input unchanged', () => { + expect(downgradeSpecV31ToV30(null as any)).toBeNull() + expect(downgradeSpecV31ToV30(42 as any)).toBe(42) + expect(downgradeSpecV31ToV30('spec' as any)).toBe('spec') + const list = [1, { a: 1 }] + const result = downgradeSpecV31ToV30(list as any) + expect(result).toEqual(list) + expect(result).not.toBe(list) + }) + + it('keeps unknown top-level keys and extensions', () => { + expect(convertSpec({ 'future': { a: 1 }, 'x-root': true })).toEqual({ ...empty, 'future': { a: 1 }, 'x-root': true }) + }) +}) + +describe('3.1-only root fields', () => { + // `jsonSchemaDialect` and `webhooks` are new in 3.1: + // https://spec.openapis.org/oas/v3.1.2.html#oas-json-schema-dialect + // https://spec.openapis.org/oas/v3.1.2.html#oas-webhooks + // 3.0 can only describe requests the API sends as callbacks of one of its + // operations, not as standalone webhooks, and an `x-` extension would + // only hide them from tools, so webhooks are removed. + // References into them are inlined (see removed-parts.test.ts). + it('removes jsonSchemaDialect and webhooks without leaving extensions behind', () => { + const result = convertSpec({ + jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', + webhooks: { newPet: { post: { summary: 's' } } }, + }) + expect(result).toEqual(empty) + expect(result).not.toHaveProperty('x-webhooks') + }) +}) + +describe('info', () => { + // `info.summary` and `license.identifier` (an SPDX expression) are new in + // 3.1: https://spec.openapis.org/oas/v3.1.2.html#info-summary + // https://spec.openapis.org/oas/v3.1.2.html#license-identifier + it('removes summary and license.identifier and keeps the other fields', () => { + expect(convertSpec({ + info: { + license: { identifier: 'MIT', name: 'MIT', url: 'https://opensource.org/license/mit' }, + summary: 'short', + title: 't', + version: '1', + }, + }).info).toEqual({ + license: { name: 'MIT', url: 'https://opensource.org/license/mit' }, + title: 't', + version: '1', + }) + }) + + it('clones malformed info and license values unchanged', () => { + expect(convertSpec({ info: 42 }).info).toBe(42) + expect(convertSpec({ info: { license: 'MIT', title: 't', version: '1' } }).info).toEqual({ license: 'MIT', title: 't', version: '1' }) + }) +}) + +describe('paths', () => { + // Paths Object keys are templates that start with a slash; any other key + // can only be a specification extension: https://spec.openapis.org/oas/v3.0.4.html#paths-object + it('converts path items and clones non-path keys', () => { + expect(convertSpec({ + paths: { + '/a': { get: { summary: 's' } }, + 'x-note': { get: { summary: 's' } }, + }, + }).paths).toEqual({ + '/a': { get: { responses: { default: { description: '' } }, summary: 's' } }, + 'x-note': { get: { summary: 's' } }, + }) + }) + + it('clones malformed paths, path items, operations, and nested objects unchanged', () => { + expect(convertSpec({ paths: 'junk' }).paths).toBe('junk') + const paths = { + '/a': { + get: { requestBody: 42, responses: { 200: 'junk', 201: { description: 'ok', links: 'junk' } } }, + parameters: [42], + }, + '/b': { + post: { + requestBody: { content: { 'application/json': 'junk', 'multipart/form-data': { encoding: { field: 'junk' } } } }, + responses: {}, + }, + }, + '/c': { get: 'junk' }, + '/junk': 'junk', + } + expect(convertSpec({ paths }).paths).toEqual(paths) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts new file mode 100644 index 0000000..5259900 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts @@ -0,0 +1,191 @@ +// In `multipart` and `application/x-www-form-urlencoded` bodies, a part +// without an Encoding Object `contentType` gets a default that depends on +// its schema, and the two versions derive it differently. +// +// 3.1 (https://spec.openapis.org/oas/v3.1.2.html#encoding-content-type): +// no `type` → application/octet-stream +// `string` with `contentEncoding` → application/octet-stream +// `string` without it → text/plain, `object` → application/json, +// `array` → the default of its `items` +// 3.0 (https://spec.openapis.org/oas/v3.0.4.html#encoding-content-type): +// `string` with `format: binary` or `byte` → application/octet-stream +// other strings → text/plain, `object` → application/json, +// `array` → the default of its `items`, and nothing for a missing `type` +// +// The schema conversion keeps most of these aligned, but not an untyped +// part (`{}` has no 3.0 default) or a `contentEncoding` that `format: byte` +// cannot express (such as base64url, which becomes a plain string and so +// text/plain). For those parts the 3.1 default, application/octet-stream, +// is written into the Encoding Object so the wire format stays the same. +// +// An Encoding Object that sets `style`, `explode`, or `allowReserved` +// switches the part to RFC6570-style serialization, where `contentType` +// does not apply: https://spec.openapis.org/oas/v3.1.2.html#fixed-fields-for-rfc6570-style-serialization +// Such entries, and ones that already set `contentType`, are left alone. + +import { dig } from '../../helpers' +import { convertComponent, convertSpec } from './helpers' + +const octetStream = { contentType: 'application/octet-stream' } + +function convertForm(mediaType: unknown, type = 'multipart/form-data'): unknown { + const result = convertSpec({ + components: { + requestBodies: { X: { content: { [type]: mediaType } } }, + schemas: { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} }, + }, + }) + return dig(result, 'components', 'requestBodies', 'X', 'content', type) +} + +describe('parts that need the 3.1 default written out', () => { + it.each([ + ['a schema without type', {}], + ['a true schema', true], + ['raw binary', { contentMediaType: 'image/png' }], + ['a base64 string', { contentEncoding: 'base64', type: 'string' }], + ['a string with a contentEncoding that no 3.0 format expresses', { contentEncoding: 'base64url', type: 'string' }], + ['a nullable string with a contentEncoding', { contentEncoding: 'base64url', type: ['string', 'null'] }], + ['an array of untyped items', { items: {}, type: 'array' }], + ['an array of raw binary', { items: { contentMediaType: 'image/png' }, type: 'array' }], + ['an array without items', { type: 'array' }], + ['untyped anyOf branches', { anyOf: [{ contentMediaType: 'image/png' }, { contentMediaType: 'image/jpeg' }] }], + ['a reference to an untyped schema', { $ref: '#/components/schemas/Raw' }], + ])('sets contentType: application/octet-stream on %s', (_name, part) => { + expect(convertForm({ schema: { properties: { part } } })).toEqual({ + encoding: { part: octetStream }, + schema: { properties: { part: expect.anything() } }, + }) + }) + + it('writes the same Encoding Object whether the body schema is inline or a reference', () => { + const result = convertSpec({ + components: { + requestBodies: { + Inline: { content: { 'multipart/form-data': { schema: { properties: { img: { contentMediaType: 'image/png' } } } } } }, + Referenced: { content: { 'multipart/form-data': { schema: { $ref: '#/components/schemas/Upload' } } } }, + }, + schemas: { Upload: { properties: { img: { contentMediaType: 'image/png' } } } }, + }, + }) + for (const name of ['Inline', 'Referenced']) { + expect(dig(result, 'components', 'requestBodies', name, 'content', 'multipart/form-data', 'encoding')).toEqual({ img: octetStream }) + } + }) + + // The parts of a form are the properties of its schema, including ones + // reached through `allOf`, `anyOf`, `oneOf`, and local `$ref`s. + it('finds parts through references and allOf in the body schema', () => { + expect(convertForm({ schema: { $ref: '#/components/schemas/Form' } })).toEqual({ + encoding: { a: octetStream, b: octetStream }, + schema: { $ref: '#/components/schemas/Form' }, + }) + }) + + it('writes a part named like an Object.prototype member as an own key', () => { + const encoding = dig(convertForm({ schema: { properties: JSON.parse('{"__proto__":{}}') } }), 'encoding') as object + expect(Object.getPrototypeOf(encoding)).toBe(Object.prototype) + expect(Object.getOwnPropertyDescriptor(encoding, '__proto__')?.value).toEqual(octetStream) + }) +}) + +describe('parts whose 3.0 default already matches', () => { + it.each([ + ['a string', { format: 'uuid', type: 'string' }], + ['an object', { type: 'object' }], + ['a type found through allOf', { allOf: [{ $ref: '#/components/schemas/Pet' }] }], + ['a null type', { type: 'null' }], + ['several types', { type: ['string', 'integer'] }], + ['branches of different types', { anyOf: [{ type: 'string' }, { type: 'integer' }] }], + ['typed prefixItems', { prefixItems: [{ type: 'string' }], type: 'array' }], + ['nested arrays, which have no multipart form', { items: { items: {}, type: 'array' }, type: 'array' }], + ['an external reference', { $ref: 'other.yaml#/File' }], + ['a missing reference', { $ref: '#/components/schemas/Missing' }], + ['a false schema', false], + ])('adds no Encoding Object for %s', (_name, part) => { + expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') + }) + + it('adds no Encoding Object for an array whose items loop back to it', () => { + const part: Record = { type: 'array' } + part.items = part + expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') + }) +}) + +describe('existing Encoding Objects', () => { + it('keeps entries that set contentType or RFC6570-style fields, and adds contentType beside headers', () => { + const headers = { 'X-Id': { schema: { type: 'string' } } } + const schema = { properties: { exploded: {}, explicit: {}, headed: {}, junk: {}, reserved: {}, styled: {} } } + expect(convertForm({ + encoding: { + exploded: { explode: true }, + explicit: { contentType: 'image/png' }, + headed: { headers }, + junk: 'junk', + reserved: { allowReserved: true }, + styled: { style: 'form' }, + }, + schema, + })).toEqual({ + encoding: { + exploded: { explode: true }, + explicit: { contentType: 'image/png' }, + headed: { ...octetStream, headers }, + junk: 'junk', + reserved: { allowReserved: true }, + styled: { style: 'form' }, + }, + schema, + }) + }) + + it('leaves an Encoding Object shared with another part unchanged', () => { + const entry = { headers: { 'X-Id': { schema: { type: 'string' } } } } + const result = dig(convertComponent('requestBodies', { + content: { + 'multipart/form-data': { encoding: { part: entry }, schema: { properties: { part: {} } } }, + 'multipart/mixed': { encoding: { part: entry }, schema: { properties: { part: { type: 'string' } } } }, + }, + }), 'content') + expect(dig(result, 'multipart/form-data', 'encoding', 'part')).toEqual({ ...entry, ...octetStream }) + expect(dig(result, 'multipart/mixed', 'encoding', 'part')).toEqual(entry) + }) + + it('leaves a malformed encoding value alone', () => { + expect(convertForm({ encoding: 'junk', schema: { properties: { file: {} } } })).toEqual({ + encoding: 'junk', + schema: { properties: { file: {} } }, + }) + }) +}) + +describe('where it applies', () => { + // Encoding Objects only apply to request bodies of these media types + // (https://spec.openapis.org/oas/v3.1.2.html#media-type-encoding); media + // type names are case-insensitive and may carry parameters. + it('applies to multipart and URL-encoded request bodies only', () => { + const mediaType = { schema: { properties: { file: {} } } } + for (const type of ['multipart/mixed', 'Application/X-WWW-Form-Urlencoded; charset=utf-8']) { + expect(convertForm(mediaType, type)).toEqual({ ...mediaType, encoding: { file: octetStream } }) + } + for (const type of ['application/json', 'application/x-www-form-urlencoded-v2']) { + expect(convertForm(mediaType, type)).toEqual(mediaType) + } + const content = { 'multipart/form-data': mediaType } + expect(convertComponent('responses', { content, description: 'd' })).toEqual({ content, description: 'd' }) + expect(convertComponent('parameters', { content, in: 'query', name: 'q' })).toEqual({ content, in: 'query', name: 'q' }) + }) + + it('converts a media type shared between a form body and a response as each', () => { + const mediaType = { schema: { properties: { file: {} } } } + const result = convertSpec({ + components: { + requestBodies: { B: { content: { 'multipart/form-data': mediaType } } }, + responses: { R: { content: { 'multipart/form-data': mediaType }, description: 'd' } }, + }, + }) + expect(dig(result, 'components', 'requestBodies', 'B', 'content', 'multipart/form-data')).toEqual({ ...mediaType, encoding: { file: octetStream } }) + expect(dig(result, 'components', 'responses', 'R', 'content', 'multipart/form-data')).toEqual(mediaType) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts new file mode 100644 index 0000000..7afee65 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts @@ -0,0 +1,29 @@ +import type * as OpenAPIV3_0 from '@openapi-spec/types/v3.0' + +import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' + +import { dig } from '../../helpers' + +export const info = { title: 't', version: '1' } + +/** The converted form of a document holding nothing but `info`. */ +export const empty = { info, openapi: '3.0.4', paths: {} } + +/** + * Converts a 3.1 document built from `fields`. The input is typed loosely on + * purpose: many tests feed partial or malformed documents to check that the + * conversion tolerates them. + */ +export function convertSpec(fields: Record): OpenAPIV3_0.OpenAPIObject { + return downgradeSpecV31ToV30({ info, openapi: '3.1.0', paths: {}, ...fields } as any) +} + +/** Converts `pathItem` as the only entry of `paths` and returns it. */ +export function convertPathItem(pathItem: unknown, components?: Record): unknown { + return dig(convertSpec({ ...(components && { components }), paths: { '/a': pathItem } }), 'paths', '/a') +} + +/** Converts `value` as the entry `X` of the `kind` component map and returns it. */ +export function convertComponent(kind: string, value: unknown, components: Record = {}): unknown { + return dig(convertSpec({ components: { ...components, [kind]: { X: value } } }), 'components', kind, 'X') +} diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts new file mode 100644 index 0000000..10b0423 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts @@ -0,0 +1,138 @@ +// A document is usually parsed JSON or YAML, but it can also come from a +// dereferencing tool that turns every `$ref` into a shared JavaScript object, +// possibly with cycles. These tests pin down how the conversion treats the +// input as an object graph rather than as text. + +import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' + +import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' + +import { dig } from '../../helpers' +import { convertPathItem, convertSpec, info } from './helpers' + +describe('the input document', () => { + it('is never mutated', () => { + const input: OpenAPIV3_1.OpenAPIObject = { + components: { + pathItems: { Reusable: { get: { summary: 's' } } }, + schemas: { S: { $ref: '#/c/s', type: ['string', 'null'] } }, + securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } }, + }, + info: { license: { identifier: 'MIT', name: 'MIT' }, summary: 'short', title: 't', version: '1' }, + jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', + openapi: '3.1.0', + paths: { + '/a': { + get: { + parameters: [{ $ref: '#/c/p', summary: 's' }], + security: [{ mtls: [] }, { api: ['read'] }], + }, + }, + '/b': { $ref: '#/components/pathItems/Reusable' }, + }, + security: [{ mtls: [] }], + webhooks: { newPet: { post: { summary: 's' } } }, + } + const before = structuredClone(input) + downgradeSpecV31ToV30(input) + expect(input).toEqual(before) + }) + + it('returns a fresh copy on every call', () => { + const spec: OpenAPIV3_1.OpenAPIObject = { info, openapi: '3.1.0', paths: {} } + const first = downgradeSpecV31ToV30(spec) + expect(first).toEqual(downgradeSpecV31ToV30(spec)) + expect(first).not.toBe(downgradeSpecV31ToV30(spec)) + expect(first.info).not.toBe(spec.info) + }) + + // Some parsers build objects without a prototype so that keys such as + // `__proto__` or `constructor` cannot collide with Object.prototype. + it('accepts objects with a null prototype, as some parsers produce', () => { + const schema = Object.assign(Object.create(null), { type: ['string', 'null'] }) + const operation = Object.assign(Object.create(null), { parameters: [{ in: 'path', name: 'id', schema }] }) + expect(convertPathItem({ get: operation })).toEqual({ + get: { + parameters: [{ in: 'path', name: 'id', required: true, schema: { nullable: true, type: 'string' } }], + responses: { default: { description: '' } }, + }, + }) + }) + + // Values such as a Date or a Map cannot come from JSON or YAML. They are + // not walked into, and are kept as the same instance. + it('keeps values that are not plain objects or arrays by reference', () => { + const date = new Date(0) + const result = convertSpec({ 'components': { schemas: { S: { default: date } } }, 'x-date': date }) + expect(dig(result, 'x-date')).toBe(date) + expect(dig(result, 'components', 'schemas', 'S', 'default')).toBe(date) + }) +}) + +describe('keys', () => { + it('keeps the key order of the input', () => { + const result = downgradeSpecV31ToV30({ 'paths': {}, 'x-first': 1, 'info': { version: '1', title: 't' }, 'openapi': '3.1.0' } as any) + expect(Object.keys(result)).toEqual(['paths', 'x-first', 'info', 'openapi']) + expect(Object.keys(result.info)).toEqual(['version', 'title']) + }) + + // JSON.parse creates a real own `__proto__` key. Assigning it with `=` + // would instead replace the prototype of the output object. + it('copies a __proto__ key as a plain own property without polluting prototypes', () => { + const spec = JSON.parse('{"openapi":"3.1.0","paths":{},"x-data":{"__proto__":{"polluted":true}},"components":{"schemas":{"__proto__":{"type":["string","null"]}}}}') + const result = downgradeSpecV31ToV30(spec) + const data = dig(result, 'x-data') as object + const schemas = dig(result, 'components', 'schemas') as object + expect(Object.getPrototypeOf(data)).toBe(Object.prototype) + expect(Object.getOwnPropertyDescriptor(data, '__proto__')?.value).toEqual({ polluted: true }) + expect(Object.getOwnPropertyDescriptor(schemas, '__proto__')?.value).toEqual({ nullable: true, type: 'string' }) + expect('polluted' in {}).toBe(false) + }) +}) + +describe('shared objects and cycles', () => { + it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { + const callback: Record = {} + const pathItem: Record = { get: { callbacks: { cb: callback } } } + callback.expr = pathItem + const result = convertPathItem(pathItem) + expect(dig(result, 'get', 'responses')).toEqual({ default: { description: '' } }) + expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) + }) + + it('converts a dereferenced schema shared across the document once', () => { + const pet = { properties: { name: { type: ['string', 'null'] } }, type: 'object' } + const result = convertSpec({ + components: { schemas: { Pet: pet } }, + paths: { '/pets': { get: { responses: { 200: { content: { 'application/json': { schema: pet } }, description: 'ok' } } } } }, + }) + const schema = dig(result, 'components', 'schemas', 'Pet') + expect(schema).toEqual({ properties: { name: { nullable: true, type: 'string' } }, type: 'object' }) + expect(dig(result, 'paths', '/pets', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toBe(schema) + }) + + it('keeps cycles and sharing inside values it only copies', () => { + const node: Record = { name: 'root' } + node.self = node + const list: unknown[] = [1] + list.push(list) + const result = convertSpec({ 'x-list': list, 'x-node': node, 'x-same': node }) + expect(dig(result, 'x-node', 'self')).toBe(dig(result, 'x-node')) + expect(dig(result, 'x-same')).toBe(dig(result, 'x-node')) + expect(dig(result, 'x-list', '1')).toBe(dig(result, 'x-list')) + }) + + // `#/webhooks/%68ook` percent-decodes to `#/webhooks/hook`, so both name + // the same target and share one converted copy. + it('converts a target inlined from several places once, however its pointer is spelled', () => { + const result = convertSpec({ + paths: { '/a': { $ref: '#/webhooks/hook' }, '/b': { $ref: '#/webhooks/%68ook' } }, + webhooks: { hook: { get: { parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }] } } }, + }) + expect(dig(result, 'paths', '/a', 'get')).toEqual({ + parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], + responses: { default: { description: '' } }, + }) + expect(dig(result, 'paths', '/b', 'get')).toBe(dig(result, 'paths', '/a', 'get')) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts new file mode 100644 index 0000000..a1ca77b --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts @@ -0,0 +1,141 @@ +// A Link's `operationRef` and a discriminator `mapping` value are references +// too: https://spec.openapis.org/oas/v3.0.4.html#link-operation-ref +// https://spec.openapis.org/oas/v3.0.4.html#discriminator-mapping +// One that points into `webhooks` or `components.pathItems` cannot be kept, +// since its target is removed, and cannot be inlined either, since both +// fields must hold a pointer. So it is removed, along with any Reference +// Object that resolves to such a Link. + +import { dig } from '../../helpers' +import { convertSpec } from './helpers' + +const item = { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], +} + +describe('links', () => { + it('removes links whose operationRef points into the removed parts, together with references to them', () => { + const result = convertSpec({ + components: { + callbacks: { Hook: { '{$url}': { $ref: '#/webhooks/callbackHook' } } }, + links: { + ByComponentCallback: { operationRef: '#/webhooks/callbackHook/post' }, + Gone: { operationRef: '#/webhooks/orphan/post' }, + Kept: { description: 'kept', operationRef: '#/webhooks/newPet/post' }, + }, + pathItems: { Item: item, NoId: { get: { responses: {} } } }, + }, + paths: { + '/a': { + get: { + callbacks: { cb: { '{$request.body#/url}': { $ref: '#/components/pathItems/Item' } } }, + responses: { + 200: { + description: 'ok', + links: { + both: { operationId: 'stale', operationRef: '#/webhooks/newPet/post' }, + byCallback: { operationRef: '#/components/pathItems/Item/get', parameters: { id: '$response.body#/id' } }, + byId: { operationId: 'orphanHook' }, + byPath: { operationRef: '#/paths/~1b/post' }, + external: { $ref: 'https://example.com/links.json#/Kept' }, + inlined: { $ref: '#/webhooks/newPet/post/responses/200/links/self' }, + missing: { operationRef: '#/webhooks/missing/post' }, + noId: { operationRef: '#/components/pathItems/NoId/get' }, + refGone: { $ref: '#/components/links/Gone' }, + refKept: { $ref: '#/components/links/Kept' }, + refUnknown: { $ref: '#/components/links/Unknown' }, + }, + }, + }, + }, + }, + '/b': { $ref: '#/webhooks/newPet' }, + '/c': { $ref: '#/components/pathItems/NoId' }, + '/d': { $ref: '#/webhooks/newPet' }, + '/junk': 'junk', + 'x-orphan': { post: { operationId: 'orphanHook' } }, + }, + webhooks: { + callbackHook: { post: { operationId: 'callbackHookOp', responses: {} } }, + newPet: { + post: { + operationId: 'newPetHook', + responses: { 200: { description: 'ok', links: { self: { operationRef: '#/webhooks/newPet/post' } } } }, + }, + }, + orphan: { post: { operationId: 'orphanHook', responses: {} } }, + }, + }) + expect(result.components).toEqual({ + callbacks: { Hook: { '{$url}': { post: { operationId: 'callbackHookOp', responses: {} } } } }, + links: {}, + }) + // `byId` names its operation by `operationId`, which is not a pointer, + // and is kept even though that operation is gone (a known limitation). + expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'links')).toEqual({ + byId: { operationId: 'orphanHook' }, + byPath: { operationRef: '#/paths/~1b/post' }, + external: { $ref: 'https://example.com/links.json#/Kept' }, + refUnknown: { $ref: '#/components/links/Unknown' }, + }) + expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({}) + expect(JSON.stringify(result)).not.toMatch(/#\/(?:webhooks|components\/pathItems)/) + }) + + // `/a` inlines the webhook but defines its own `post`, which wins, so the + // webhook's `post` does not survive anywhere in the output. + it('removes a link to an operation that an own field of the referencing path item replaces', () => { + expect(convertSpec({ + components: { links: { L: { operationRef: '#/webhooks/w/post' } } }, + paths: { '/a': { $ref: '#/webhooks/w', post: { responses: {} } } }, + webhooks: { w: { post: { operationId: 'hidden', responses: {} } } }, + }).components).toEqual({ links: {} }) + }) + + it('removes a link to a removed operation in a document without components', () => { + expect(convertSpec({ + paths: { + '/a': { + get: { + callbacks: { junk: 42 }, + responses: { 200: { description: 'ok', links: { l: { operationRef: '#/webhooks/w/post' } } } }, + }, + }, + }, + webhooks: { w: { post: { operationId: 'hook', responses: {} } } }, + }).paths).toEqual({ + '/a': { get: { callbacks: { junk: 42 }, responses: { 200: { description: 'ok', links: {} } } } }, + }) + }) +}) + +describe('discriminator mappings', () => { + // A mapping value is either a schema name or a reference. Names and + // references that stay valid are kept. + it('removes mapping entries that point into the removed parts', () => { + expect(convertSpec({ + components: { + schemas: { + Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, + Pet: { + discriminator: { + mapping: { + cat: '#/components/schemas/Cat', + dog: '#/webhooks/newPet/post/requestBody/content/application~1json/schema', + fish: 'Fish', + hamster: '#/components/pathItems/Item', + }, + propertyName: 'kind', + }, + }, + }, + }, + }).components).toEqual({ + schemas: { + Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, + Pet: { discriminator: { mapping: { cat: '#/components/schemas/Cat', fish: 'Fish' }, propertyName: 'kind' } }, + }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts new file mode 100644 index 0000000..86ec813 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts @@ -0,0 +1,122 @@ +import { convertComponent, convertPathItem } from './helpers' + +describe('responses', () => { + // 3.1 made `responses` optional: https://spec.openapis.org/oas/v3.1.2.html#operation-responses + // In 3.0 it is REQUIRED: https://spec.openapis.org/oas/v3.0.4.html#operation-responses + // A `default` response with an empty description says nothing about the + // responses, just as the missing field did. + it('adds a minimal default response when an operation has none', () => { + expect(convertPathItem({ get: { operationId: 'getA' } })).toEqual({ + get: { operationId: 'getA', responses: { default: { description: '' } } }, + }) + }) + + it('keeps x- entries of a responses map unconverted', () => { + const responses = { '200': { description: 'ok' }, 'x-note': { $ref: '#/c/r', summary: 's' } } + expect(convertPathItem({ get: { responses } })).toEqual({ get: { responses } }) + }) +}) + +describe('parameters', () => { + it('converts parameter schemas, content, and examples', () => { + expect(convertPathItem({ + get: { + parameters: [ + { examples: { e: { $ref: '#/c/e', summary: 's' } }, in: 'query', name: 'p', schema: { type: ['string', 'null'] } }, + { content: { 'text/plain': { schema: { type: ['integer', 'null'] } } }, in: 'query', name: 'q' }, + ], + responses: {}, + }, + })).toEqual({ + get: { + parameters: [ + { examples: { e: { $ref: '#/c/e' } }, in: 'query', name: 'p', schema: { nullable: true, type: 'string' } }, + { content: { 'text/plain': { schema: { nullable: true, type: 'integer' } } }, in: 'query', name: 'q' }, + ], + responses: {}, + }, + }) + }) + + // Both versions say a path parameter's `required` "is REQUIRED and its + // value MUST be true": https://spec.openapis.org/oas/v3.1.2.html#parameter-required + // The official 3.1 JSON Schema only checks it beside `schema`, so a valid + // 3.1 document can lack it on a `content` parameter. The official 3.0 + // schema always checks it, so it is added. + it('adds required: true to path parameters that lack it', () => { + expect(convertPathItem({ + get: { + parameters: [ + { content: { 'text/plain': { schema: { type: 'string' } } }, in: 'path', name: 'id' }, + { in: 'query', name: 'q', schema: {} }, + ], + responses: {}, + }, + })).toEqual({ + get: { + parameters: [ + { content: { 'text/plain': { schema: { type: 'string' } } }, in: 'path', name: 'id', required: true }, + { in: 'query', name: 'q', schema: {} }, + ], + responses: {}, + }, + }) + }) +}) + +describe('request bodies', () => { + it('converts request body content, media type encoding, and encoding headers', () => { + expect(convertComponent('requestBodies', { + content: { + 'multipart/form-data': { + encoding: { + field: { + contentType: 'text/plain', + headers: { H: { $ref: '#/c/h', summary: 's' }, H2: { schema: { type: ['string', 'null'] } } }, + }, + }, + example: { field: 'v' }, + schema: { type: 'object' }, + }, + }, + description: 'body', + required: true, + })).toEqual({ + content: { + 'multipart/form-data': { + encoding: { + field: { + contentType: 'text/plain', + headers: { H: { $ref: '#/c/h' }, H2: { schema: { nullable: true, type: 'string' } } }, + }, + }, + example: { field: 'v' }, + schema: { type: 'object' }, + }, + }, + description: 'body', + required: true, + }) + }) +}) + +describe('callbacks', () => { + // Callback Object keys are runtime expressions; only `x-` keys are + // extensions: https://spec.openapis.org/oas/v3.0.4.html#callback-object + it('converts inline callbacks, cloning x- keys and malformed entries', () => { + expect(convertPathItem({ + get: { + callbacks: { inline: { 'expr': { get: {} }, 'x-k': { expr: { get: {} } } }, junk: 7 }, + responses: {}, + }, + })).toEqual({ + get: { + callbacks: { + inline: { 'expr': { get: { responses: { default: { description: '' } } } }, 'x-k': { expr: { get: {} } } }, + junk: 7, + }, + responses: {}, + }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts new file mode 100644 index 0000000..899a62d --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts @@ -0,0 +1,184 @@ +// `components.pathItems` is new in 3.1: https://spec.openapis.org/oas/v3.1.2.html#components-path-items +// 3.0 components cannot hold Path Items, so the map is removed and every +// Path Item `$ref` into it is replaced by the converted Path Item. +// +// The spec leaves a field defined both beside the `$ref` and in its target +// undefined, but says `$ref` will move toward Reference Object behavior, where +// the referencing side's fields override the target's: +// https://spec.openapis.org/oas/v3.1.2.html#path-item-ref +// So when a Path Item `$ref` is inlined, its own fields win. + +import { convertPathItem, convertSpec } from './helpers' + +const reusable = { + get: { responses: { 200: { description: 'ok' } } }, + parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], + summary: 'Reusable', +} +const inlined = { + get: { responses: { 200: { description: 'ok' } } }, + parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], + summary: 'Reusable', +} + +function convertWithPathItems(paths: unknown, pathItems: unknown, extra: Record = {}) { + return convertSpec({ components: { pathItems, ...extra }, paths }) +} + +describe('inlining', () => { + it('inlines the converted entry and lets the referencing fields win', () => { + const result = convertWithPathItems( + { + '/a': { $ref: '#/components/pathItems/Reusable' }, + '/b': { $ref: '#/components/pathItems/Reusable', description: 'own', summary: 'Own summary' }, + }, + { Reusable: reusable }, + ) + expect(result.components).toEqual({}) + expect(result.paths).toEqual({ + '/a': inlined, + '/b': { ...inlined, description: 'own', summary: 'Own summary' }, + }) + }) + + // Each hop of a chain adds the fields the hops before it did not set. + it('follows chains of path item references, merging the fields of every hop', () => { + expect(convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Alias', summary: 'Own' } }, + { + Alias: { $ref: '#/components/pathItems/Reusable', description: 'alias' }, + Reusable: reusable, + }, + ).paths).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) + }) + + // The target is an external reference, which stays valid, so the result is + // that reference with the referencing Path Item's fields beside it. + it('inlines an entry that references an external file', () => { + expect(convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/External', summary: 'Own' } }, + { External: { $ref: './paths/a.yaml' } }, + ).paths).toEqual({ '/a': { $ref: './paths/a.yaml', summary: 'Own' } }) + }) + + it('inlines references inside callbacks', () => { + expect(convertWithPathItems( + { + '/a': { + post: { + callbacks: { onEvent: { '{$request.body#/url}': { $ref: '#/components/pathItems/Reusable' } } }, + responses: {}, + }, + }, + }, + { Reusable: reusable }, + ).paths).toEqual({ + '/a': { post: { callbacks: { onEvent: { '{$request.body#/url}': inlined } }, responses: {} } }, + }) + }) + + it('converts the inlined path item like any other, removing mutualTLS requirements', () => { + const result = convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Secured' } }, + { Secured: { get: { responses: {}, security: [{ mtls: [] }, { api: ['r'] }] } } }, + { securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } } }, + ) + expect(result.paths).toEqual({ '/a': { get: { responses: {}, security: [{ api: [] }] } } }) + }) +}) + +describe('references left as written', () => { + // Only a pointer to an existing Path Item is inlined. A pointer that + // resolves to nothing, or to something that is not an object, dangles + // already, and there is nothing to inline. + it.each([ + ['an unknown entry', '#/components/pathItems/Missing', { Reusable: reusable }], + ['an empty name', '#/components/pathItems/', { Reusable: reusable }], + ['a malformed entry', '#/components/pathItems/Junk', { Junk: 42 }], + ['a prototype member', '#/components/pathItems/hasOwnProperty', {}], + ['a malformed pathItems map', '#/components/pathItems/Reusable', 'junk'], + ])('leaves a reference to %s untouched', (_name, ref, pathItems) => { + expect(convertWithPathItems({ '/a': { $ref: ref, summary: 's' } }, pathItems).paths).toEqual({ '/a': { $ref: ref, summary: 's' } }) + }) + + // `#/components/pathItems/Reusable/get` resolves to an Operation. A Path + // Item `$ref` must point at a Path Item, so this one is not merged; it is + // left as written, like a reference to any other invalid target. + it('leaves a reference to something that is not a path item untouched', () => { + expect(convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }, + { Reusable: reusable }, + ).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }) + }) + + it('leaves a reference untouched when components.pathItems is missing', () => { + expect(convertPathItem({ $ref: '#/components/pathItems/Reusable' })).toEqual({ $ref: '#/components/pathItems/Reusable' }) + }) + + it('leaves a chain that loops without reaching a path item as written', () => { + expect(convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, + { + Ping: { $ref: '#/components/pathItems/Pong', description: 'ping' }, + Pong: { $ref: '#/components/pathItems/Ping' }, + }, + ).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }) + }) +}) + +describe('recursion', () => { + // A Path Item that reaches itself through its callbacks would inline + // forever. The inner reference keeps only its own fields instead, since a + // Path Item has no "accept anything" form like the `{}` schema. + it('cuts a path item that reaches itself through its callbacks down to its own fields', () => { + expect(convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Self' } }, + { + Self: { + post: { + callbacks: { + loop: { + bare: { $ref: '#/components/pathItems/Self' }, + own: { $ref: '#/components/pathItems/Self', summary: 'own' }, + }, + }, + responses: {}, + }, + }, + }, + ).paths).toEqual({ + '/a': { post: { callbacks: { loop: { bare: {}, own: { summary: 'own' } } }, responses: {} } }, + }) + }) + + it('keeps the fields of every hop when it cuts a recursive chain', () => { + const result = convertSpec({ + components: { + pathItems: { + A: { post: { callbacks: { cb: { expr: { $ref: '#/components/pathItems/Alias', summary: 'outer' } } }, responses: {} } }, + Alias: { $ref: '#/components/pathItems/A', description: 'alias' }, + }, + }, + paths: { '/a': { $ref: '#/components/pathItems/A' } }, + }) + expect(result.paths?.['/a']).toEqual({ + post: { callbacks: { cb: { expr: { description: 'alias', summary: 'outer' } } }, responses: {} }, + }) + }) + + it('cuts fields inherited from a later hop that lead back into it', () => { + const responses = { 200: { description: 'ok' } } + const result = convertSpec({ + components: { + pathItems: { + A: { $ref: '#/components/pathItems/T', post: { callbacks: { c: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses } }, + T: { summary: 't' }, + }, + }, + paths: { '/p': { $ref: '#/components/pathItems/A' } }, + }) + expect(result.paths).toEqual({ + '/p': { post: { callbacks: { c: { '{$url}': { summary: 't' } } }, responses }, summary: 't' }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts new file mode 100644 index 0000000..b7375b4 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts @@ -0,0 +1,148 @@ +// Inlining a target that refers back to itself would never end, and the +// result must stay a plain acyclic JSON value. The recursion is cut at its +// first repeat: +// - in a schema, the inner reference becomes `{}`, which accepts anything, +// so validation can only get looser +// - on a Path Item, the inner reference keeps only its own fields +// - anywhere else, the inner Reference Object is removed + +import { dig } from '../../helpers' +import { convertSpec } from './helpers' + +const schemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' +const responses = { 200: { description: 'ok' } } + +it('cuts recursion into {} for schemas and into own fields for path items, keeping the output acyclic', () => { + const tree = '#/webhooks/tree/post/requestBody/content/application~1json/schema' + const result = convertSpec({ + components: { schemas: { Tree: { $ref: tree } } }, + paths: { '/ping': { $ref: '#/webhooks/ping' }, '/tree': { $ref: '#/webhooks/tree' } }, + webhooks: { + ping: { + post: { + callbacks: { + pong: { $ref: '#/webhooks/ping/post/callbacks/self' }, + self: { '{$request.body#/url}': { $ref: '#/webhooks/ping' } }, + }, + responses: {}, + }, + }, + tree: { + post: { + requestBody: { + content: { + 'application/json': { + schema: { properties: { children: { items: { $ref: tree }, type: 'array' } }, type: 'object' }, + }, + }, + }, + responses: {}, + }, + }, + }, + }) + expect(result.components).toEqual({ schemas: { Tree: { properties: { children: { items: {}, type: 'array' } }, type: 'object' } } }) + expect(dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema')).toEqual(dig(result, 'components', 'schemas', 'Tree')) + expect(dig(result, 'paths', '/ping', 'post', 'callbacks')).toEqual({ + pong: { '{$request.body#/url}': {} }, + self: { '{$request.body#/url}': {} }, + }) + expect(JSON.parse(JSON.stringify(result))).toEqual(result) +}) + +it('cuts callbacks that reach back into an enclosing callback', () => { + const result = convertSpec({ + components: { + pathItems: { + Item: { + post: { + callbacks: { + A: { '{$url}': { post: { callbacks: { toB: { $ref: '#/components/pathItems/Item/post/callbacks/B' } }, responses } } }, + B: { '{$url}': { post: { callbacks: { toA: { $ref: '#/components/pathItems/Item/post/callbacks/A' } }, responses } } }, + }, + responses, + }, + }, + }, + }, + paths: { '/item': { $ref: '#/components/pathItems/Item' }, '/self': { $ref: '#/webhooks/w' } }, + webhooks: { + w: { + post: { + callbacks: { cb: { '{$url}': { post: { callbacks: { again: { $ref: '#/webhooks/w/post/callbacks/cb' } }, responses } } } }, + responses, + }, + }, + }, + }) + expect(dig(result, 'paths', '/self', 'post', 'callbacks', 'cb', '{$url}', 'post', 'callbacks')).toEqual({ again: {} }) + expect(dig(result, 'paths', '/item', 'post', 'callbacks', 'A', '{$url}', 'post', 'callbacks', 'toB', '{$url}', 'post', 'callbacks')).toEqual({ toA: {} }) + expect(JSON.parse(JSON.stringify(result))).toEqual(result) +}) + +it('cuts a callback that reaches back into the path item that contains it', () => { + expect(dig(convertSpec({ + components: { callbacks: { C: { $ref: '#/webhooks/ping/post/callbacks/self' } } }, + webhooks: { ping: { post: { callbacks: { self: { expr: { $ref: '#/webhooks/ping' } } }, responses: {} } } }, + }), 'components', 'callbacks', 'C')).toEqual({ + expr: { post: { callbacks: { self: {} }, responses: {} } }, + }) +}) + +it('cuts own fields that lead back into a path item still being converted', () => { + const loop = { + $ref: '#/components/pathItems/T', + get: { callbacks: { d: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses }, + } + const result = convertSpec({ + components: { + callbacks: { C: { '{$url}': { $ref: '#/components/pathItems/A/post/callbacks/c/{$url}' } } }, + pathItems: { A: { post: { callbacks: { c: { '{$url}': loop } }, responses } }, T: { summary: 't' } }, + }, + }) + expect(dig(result, 'components', 'callbacks', 'C', '{$url}', 'get', 'callbacks', 'd', '{$url}', 'post', 'callbacks')).toEqual({ + c: { '{$url}': { summary: 't' } }, + }) + expect(JSON.parse(JSON.stringify(result))).toEqual(result) +}) + +describe('object cycles of the input', () => { + // A cycle the input graph already has is kept as a cycle. Only the copy + // that inlining makes of it is cut, since a copy cannot point back into + // the original. + it('keeps an object cycle that an inlined target also reaches', () => { + const a: Record = { properties: {}, type: 'object' } + const b = { properties: { back: a }, type: 'object' } + a.properties = { hook: { $ref: schemaPointer }, b } + const result = convertSpec({ + components: { schemas: { A: a } }, + webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b }, type: 'object' } } } } } } }, + }) + const converted = dig(result, 'components', 'schemas', 'A') + expect(dig(converted, 'properties', 'b', 'properties', 'back')).toBe(converted) + expect(dig(converted, 'properties', 'hook', 'properties', 'b', 'properties', 'back')).toEqual({}) + }) + + it('cuts a reference that comes back to an object shared within the input', () => { + const shared: Record = { properties: { a: { $ref: schemaPointer } }, type: 'object' } + expect(dig(convertSpec({ + components: { schemas: { S: shared } }, + webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b: shared }, type: 'object' } } } } } } }, + }), 'components', 'schemas', 'S')).toEqual({ + properties: { a: { properties: { b: {} }, type: 'object' } }, + type: 'object', + }) + }) + + it('inlines into a cyclic input graph, preserving its cycle', () => { + const node: Record = { type: 'object' } + node.properties = { hook: { $ref: schemaPointer }, self: node } + const result = convertSpec({ + components: { schemas: { Node: node } }, + webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } } } } } } }, + }) + const converted = dig(result, 'components', 'schemas', 'Node') + expect(dig(converted, 'properties', 'self')).toBe(converted) + expect(dig(converted, 'properties', 'hook')).toEqual({ properties: { name: { type: 'string' } }, type: 'object' }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts new file mode 100644 index 0000000..5b5050f --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts @@ -0,0 +1,75 @@ +// 3.1 Reference Objects may carry `summary` and `description` overrides: +// https://spec.openapis.org/oas/v3.1.2.html#reference-object +// A 3.0 Reference Object "cannot be extended with additional properties, +// and any properties added SHALL be ignored": +// https://spec.openapis.org/oas/v3.0.4.html#reference-object +// Since 3.0 tools would ignore them anyway, they are removed, together with +// any other field beside `$ref`. + +import { convertComponent, convertPathItem, convertSpec } from './helpers' + +it('strips reference overrides across components maps', () => { + expect(convertSpec({ + components: { + callbacks: { C: { $ref: '#/c/cb', summary: 's' } }, + examples: { E: { $ref: '#/c/e', description: 'd' } }, + headers: { H: { $ref: '#/c/h', summary: 's' } }, + links: { L: { '$ref': '#/c/l', 'description': 'd', 'x-note': 'n' } }, + parameters: { P: { $ref: '#/c/p', description: 'd', summary: 's' } }, + requestBodies: { B: { $ref: '#/c/b', summary: 's' } }, + responses: { R: { $ref: '#/c/r', description: 'd' } }, + securitySchemes: { S: { $ref: '#/c/s', description: 'd' } }, + }, + }).components).toEqual({ + callbacks: { C: { $ref: '#/c/cb' } }, + examples: { E: { $ref: '#/c/e' } }, + headers: { H: { $ref: '#/c/h' } }, + links: { L: { $ref: '#/c/l' } }, + parameters: { P: { $ref: '#/c/p' } }, + requestBodies: { B: { $ref: '#/c/b' } }, + responses: { R: { $ref: '#/c/r' } }, + securitySchemes: { S: { $ref: '#/c/s' } }, + }) +}) + +it('strips reference overrides inside operations and path items', () => { + expect(convertPathItem({ + get: { + callbacks: { cb: { $ref: '#/c/cb', summary: 's' } }, + parameters: [{ $ref: '#/c/p', description: 'd' }], + requestBody: { $ref: '#/c/b', summary: 's' }, + responses: { 200: { $ref: '#/c/r', summary: 's' } }, + }, + parameters: [{ $ref: '#/c/pp', summary: 's' }], + })).toEqual({ + get: { + callbacks: { cb: { $ref: '#/c/cb' } }, + parameters: [{ $ref: '#/c/p' }], + requestBody: { $ref: '#/c/b' }, + responses: { 200: { $ref: '#/c/r' } }, + }, + parameters: [{ $ref: '#/c/pp' }], + }) +}) + +it('strips reference overrides in response headers, links, and media type examples', () => { + expect(convertComponent('responses', { + content: { 'application/json': { examples: { e: { $ref: '#/c/e', summary: 's' } }, schema: { type: ['string', 'null'] } } }, + description: 'ok', + headers: { H: { $ref: '#/c/h', summary: 's' } }, + links: { l: { $ref: '#/c/l', description: 'd' } }, + })).toEqual({ + content: { 'application/json': { examples: { e: { $ref: '#/c/e' } }, schema: { nullable: true, type: 'string' } } }, + description: 'ok', + headers: { H: { $ref: '#/c/h' } }, + links: { l: { $ref: '#/c/l' } }, + }) +}) + +// A Path Item `$ref` is not a Reference Object: its sibling fields are part +// of the Path Item in both versions (https://spec.openapis.org/oas/v3.0.4.html#path-item-ref). +it('keeps the fields beside a Path Item $ref that it leaves as written, whatever the $ref value', () => { + expect(convertPathItem({ $ref: '#/paths/~1other', summary: 's' })).toEqual({ $ref: '#/paths/~1other', summary: 's' }) + expect(convertPathItem({ $ref: 'https://example.com/paths.json#/a' })).toEqual({ $ref: 'https://example.com/paths.json#/a' }) + expect(convertPathItem({ $ref: 42 })).toEqual({ $ref: 42 }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts new file mode 100644 index 0000000..2f45af3 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts @@ -0,0 +1,505 @@ +// `webhooks` and `components.pathItems` have no 3.0 form and are removed, +// which would leave every local `$ref` into them dangling. Instead, such a +// reference is replaced by a converted copy of its target (inlined), +// following the reference chain until it leaves the removed parts. + +import { dig } from '../../helpers' +import { convertSpec } from './helpers' + +const removedPointer = /#\/(?:webhooks|components\/pathItems)/ +const schemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' +const hook = { + post: { + operationId: 'newPetHook', + parameters: [{ description: 'orig', in: 'header', name: 'X-Hook', schema: { type: ['string', 'null'] } }], + requestBody: { content: { 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } } } }, + responses: { 200: { description: 'ok' } }, + }, +} +const hookParameter = { description: 'orig', in: 'header', name: 'X-Hook', schema: { nullable: true, type: 'string' } } +const item = { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], +} + +describe('inlining', () => { + it('inlines references into webhooks and components.pathItems without mutating the input', () => { + const input = { + components: { pathItems: { Item: item }, schemas: { Pet: { $ref: schemaPointer } } }, + paths: { + '/a': { + get: { + parameters: [{ $ref: '#/webhooks/newPet/post/parameters/0' }, { $ref: '#/components/pathItems/Item/parameters/0' }], + responses: { 200: { $ref: '#/webhooks/newPet/post/responses/200' } }, + }, + }, + '/b': { $ref: '#/webhooks/newPet' }, + }, + webhooks: { newPet: hook }, + } + const before = structuredClone(input) + const result = convertSpec(input) + expect(result.components).toEqual({ schemas: { Pet: { properties: { name: { type: 'string' } }, type: 'object' } } }) + expect(result.paths).toEqual({ + '/a': { + get: { + parameters: [hookParameter, { in: 'query', name: 'q', schema: { enum: ['x'] } }], + responses: { 200: { description: 'ok' } }, + }, + }, + '/b': { post: { ...hook.post, parameters: [hookParameter] } }, + }) + expect(JSON.stringify(result)).not.toMatch(removedPointer) + expect(input).toEqual(before) + }) + + // The same target converts differently depending on what it is used as: + // a callback's path items get default responses, a parameter in the path + // becomes required, schemas lose their 3.1-only keywords, and so on. + it('converts each inlined target for its position, in every component map', () => { + const pointer = (path: string) => `#/webhooks/full/post/${path}` + const response = { + content: { 'application/json': { examples: { e: { value: 1 } } } }, + description: 'ok', + headers: { H: { schema: { const: 1 } } }, + } + const result = convertSpec({ + components: { + callbacks: { C: { $ref: pointer('callbacks/cb') } }, + examples: { E: { $ref: pointer('responses/200/content/application~1json/examples/e') } }, + headers: { H: { $ref: pointer('responses/200/headers/H') } }, + parameters: { P: { $ref: pointer('parameters/0') } }, + requestBodies: { B: { $ref: pointer('requestBody') } }, + responses: { R: { $ref: pointer('responses/200') } }, + securitySchemes: { S: { $ref: pointer('x-scheme') } }, + }, + webhooks: { + full: { + post: { + 'callbacks': { cb: { '{$url}': { get: {} } } }, + 'parameters': [{ in: 'path', name: 'id' }], + 'requestBody': { content: { 'application/json': { schema: { type: ['string', 'null'] } } } }, + 'responses': { 200: response }, + 'x-scheme': { in: 'header', name: 'k', type: 'apiKey' }, + }, + }, + }, + }) + expect(result.components).toEqual({ + callbacks: { C: { '{$url}': { get: { responses: { default: { description: '' } } } } } }, + examples: { E: { value: 1 } }, + headers: { H: { schema: { enum: [1] } } }, + parameters: { P: { in: 'path', name: 'id', required: true } }, + requestBodies: { B: { content: { 'application/json': { schema: { nullable: true, type: 'string' } } } } }, + responses: { R: { ...response, headers: { H: { schema: { enum: [1] } } } } }, + securitySchemes: { S: { in: 'header', name: 'k', type: 'apiKey' } }, + }) + }) + + it('inlines references in operation, path item, media type, parameter, and encoding positions', () => { + const pointer = (path: string) => `#/webhooks/full/post/${path}` + expect(convertSpec({ + paths: { + '/a': { + get: { + parameters: [{ examples: { e: { $ref: pointer('x-example') } }, in: 'query', name: 'q' }], + requestBody: { $ref: pointer('requestBody') }, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: { $ref: pointer('x-header') } } } }, + examples: { e: { $ref: pointer('x-example') } }, + }, + }, + description: 'ok', + }, + }, + }, + parameters: [{ $ref: pointer('x-parameter') }], + }, + }, + webhooks: { + full: { + post: { + 'requestBody': { content: { 'text/plain': { schema: { const: 'x' } } } }, + 'x-example': { value: 1 }, + 'x-header': { schema: { type: ['string', 'null'] } }, + 'x-parameter': { in: 'path', name: 'id' }, + }, + }, + }, + }).paths).toEqual({ + '/a': { + get: { + parameters: [{ examples: { e: { value: 1 } }, in: 'query', name: 'q' }], + requestBody: { content: { 'text/plain': { schema: { enum: ['x'] } } } }, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: { schema: { nullable: true, type: 'string' } } } } }, + examples: { e: { value: 1 } }, + }, + }, + description: 'ok', + }, + }, + }, + parameters: [{ in: 'path', name: 'id', required: true }], + }, + }) + }) + + // 3.0 Reference Objects cannot carry overrides (see reference-objects.test.ts), + // and the inlined object replaces the reference, so the overrides go. + it('ignores summary and description overrides when it inlines a reference', () => { + expect(convertSpec({ + components: { + callbacks: { C: { $ref: '#/webhooks/newPet/x-callback', description: 'ignored' } }, + examples: { E: { $ref: '#/webhooks/newPet/x-example', summary: 'outer' } }, + parameters: { P: { $ref: '#/webhooks/newPet/x-alias', description: 'outer' } }, + }, + webhooks: { + newPet: { + ...hook, + 'x-alias': { $ref: '#/webhooks/newPet/post/parameters/0', description: 'inner' }, + 'x-callback': { '{$url}': { summary: 's' } }, + 'x-example': { description: 'd', summary: 's', value: 1 }, + }, + }, + }).components).toEqual({ + callbacks: { C: { '{$url}': { summary: 's' } } }, + examples: { E: { description: 'd', summary: 's', value: 1 } }, + parameters: { P: hookParameter }, + }) + }) +}) + +describe('schema references', () => { + // A lone `$ref` is replaced by the target. Beside other keywords, the + // target joins `allOf`, which is how 3.0 combines a reference with + // siblings (see schema/references.test.ts). A boolean target converts + // like any boolean schema. + it('inlines Schema $refs with or without siblings and converts boolean targets', () => { + const pointer = (path: string) => `#/components/pathItems/Schemas/x-schemas/${path}` + const result = convertSpec({ + components: { + pathItems: { + Schemas: { + 'x-schemas': { + alias: { $ref: pointer('nullable') }, + never: false, + nullable: { type: ['string', 'null'] }, + withSiblings: { $ref: pointer('nullable'), description: 'wrapped' }, + }, + }, + }, + schemas: { + Alias: { $ref: pointer('alias') }, + Never: { $ref: pointer('never') }, + NotNever: { not: { $ref: pointer('never') } }, + Siblings: { $ref: pointer('nullable'), description: 'd' }, + WithSiblings: { $ref: pointer('withSiblings') }, + }, + }, + }) + const nullable = { nullable: true, type: 'string' } + expect(result.components).toEqual({ + schemas: { + Alias: nullable, + Never: { not: {} }, + NotNever: { not: { not: {} } }, + Siblings: { allOf: [nullable], description: 'd' }, + WithSiblings: { allOf: [nullable], description: 'wrapped' }, + }, + }) + }) + + // A diamond of references (two properties pointing at the same target, 64 + // levels deep) has 2^64 paths. Each target is converted once and shared. + it('converts a target reached through many references once', () => { + const pointer = (index: number) => `#/webhooks/w${index}/post/requestBody/content/application~1json/schema` + const leaf = { content: { 'application/json': { schema: { type: ['string', 'null'] } } } } + const webhooks: Record = { w64: { post: { requestBody: leaf } } } + for (let index = 0; index < 64; index++) { + const schema = { properties: { a: { $ref: pointer(index + 1) }, b: { $ref: pointer(index + 1) } }, type: 'object' } + webhooks[`w${index}`] = { post: { requestBody: { content: { 'application/json': { schema } } } } } + } + let node = dig(convertSpec({ components: { schemas: { Root: { $ref: pointer(0) } } }, webhooks }), 'components', 'schemas', 'Root') + for (let index = 0; index < 64; index++) { + expect(dig(node, 'properties', 'a')).toBe(dig(node, 'properties', 'b')) + node = dig(node, 'properties', 'a') + } + expect(node).toEqual({ nullable: true, type: 'string' }) + }) + + it('converts path items and headers reached through many references once', () => { + const webhooks: Record = { w30: { 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } } } + for (let index = 0; index < 30; index++) { + const next = { $ref: `#/webhooks/w${index + 1}` } + const header = { $ref: `#/webhooks/w${index + 1}/x-header` } + webhooks[`w${index}`] = { + 'get': { callbacks: { a: { expr: next }, b: { expr: next } }, responses: {} }, + 'x-header': { content: { 'text/plain': { encoding: { e: { headers: { a: header, b: header } } } } } }, + } + } + const result = convertSpec({ + components: { headers: { H: { $ref: '#/webhooks/w0/x-header' } } }, + paths: { '/a': { $ref: '#/webhooks/w0' } }, + webhooks, + }) + let pathItem = dig(result, 'paths', '/a') + let header = dig(result, 'components', 'headers', 'H') + for (let index = 0; index < 30; index++) { + expect(dig(pathItem, 'get', 'callbacks', 'a', 'expr')).toBe(dig(pathItem, 'get', 'callbacks', 'b', 'expr')) + pathItem = dig(pathItem, 'get', 'callbacks', 'a', 'expr') + const headers = dig(header, 'content', 'text/plain', 'encoding', 'e', 'headers') + expect(dig(headers, 'a')).toBe(dig(headers, 'b')) + header = dig(headers, 'a') + } + expect(pathItem).toEqual({ 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } }) + expect(header).toEqual({ schema: { type: 'string' } }) + }) +}) + +describe('reference chains', () => { + it('follows chains through the removed parts and keeps the reference where a chain leaves them', () => { + const result = convertSpec({ + components: { + parameters: { Shared: { in: 'query', name: 'shared' } }, + pathItems: { Deep: { parameters: [{ in: 'query', name: 'deep' }] } }, + schemas: { + Exit: { $ref: '#/webhooks/chain/post/requestBody/content/application~1json/schema' }, + Name: { type: 'string' }, + }, + }, + paths: { + '/a': { + post: { + parameters: [ + { $ref: '#/webhooks/chain/post/parameters/0' }, + { $ref: '#/webhooks/chain/post/parameters/1', description: 'dropped' }, + ], + responses: {}, + }, + }, + '/b': { $ref: '#/webhooks/alias', description: 'own' }, + }, + webhooks: { + alias: { $ref: '#/paths/~1a', summary: 'alias' }, + chain: { + post: { + parameters: [{ $ref: '#/components/pathItems/Deep/parameters/0' }, { $ref: '#/components/parameters/Shared' }], + requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Name' } } } }, + }, + }, + }, + }) + expect(result.components?.schemas?.Exit).toEqual({ $ref: '#/components/schemas/Name' }) + expect(result.paths).toEqual({ + '/a': { post: { parameters: [{ in: 'query', name: 'deep' }, { $ref: '#/components/parameters/Shared' }], responses: {} } }, + '/b': { $ref: '#/paths/~1a', description: 'own', summary: 'alias' }, + }) + }) + + // Chains are followed with loops rather than recursion, so their length is + // not bounded by the call stack. + it('follows long chains without growing the stack', () => { + const webhooks: Record = { w10000: { get: { responses: {} } } } + for (let index = 0; index < 10_000; index++) { + webhooks[`w${index}`] = { $ref: `#/webhooks/w${index + 1}` } + } + expect(convertSpec({ paths: { '/a': { $ref: '#/webhooks/w0' } }, webhooks }).paths).toEqual({ '/a': { get: { responses: {} } } }) + }) + + it('removes thousands of aliases chained to a removed security scheme', () => { + const securitySchemes: Record = { s0: { type: 'mutualTLS' } } + for (let index = 1; index <= 5000; index++) { + securitySchemes[`s${index}`] = { $ref: `#/components/securitySchemes/s${index - 1}` } + } + const result = convertSpec({ components: { securitySchemes }, security: [{ s5000: [] }] }) + expect(result.components).toEqual({ securitySchemes: {} }) + expect(result).not.toHaveProperty('security') + }) +}) + +describe('pointers', () => { + // Pointer fragments are percent-decoded (https://www.rfc-editor.org/rfc/rfc3986#section-2.1) + // before `~1` and `~0` are unescaped (https://www.rfc-editor.org/rfc/rfc6901#section-4). + it('resolves percent-encoded and tilde-escaped pointers', () => { + expect(convertSpec({ + paths: { + '/a': { + get: { + parameters: [ + { $ref: '#/webhooks/new%20pet/post/parameters/0' }, + { $ref: '#/webhooks/a~0b~1c/post/parameters/0' }, + { $ref: '#%2Fwebhooks%2Fnew%20pet%2Fpost%2Fparameters%2F1' }, + ], + responses: {}, + }, + }, + '/b': { $ref: '#/webhooks/new%20pet/post/callbacks/cb/%7B$request.body%23~1url%7D' }, + }, + webhooks: { + 'a~b/c': { post: { parameters: [{ in: 'query', name: 'tilde' }] } }, + 'new pet': { + post: { + callbacks: { cb: { '{$request.body#/url}': { summary: 'callback' } } }, + parameters: [{ in: 'query', name: 'space' }, { in: 'query', name: 'encoded' }], + }, + }, + }, + }).paths).toEqual({ + '/a': { + get: { + parameters: [{ in: 'query', name: 'space' }, { in: 'query', name: 'tilde' }, { in: 'query', name: 'encoded' }], + responses: {}, + }, + }, + '/b': { summary: 'callback' }, + }) + }) + + // Array tokens must be canonical indices ("0", not "00", "-", or + // "length"), and object tokens must be own keys, never inherited members + // such as `constructor`: https://www.rfc-editor.org/rfc/rfc6901#section-4 + it('resolves pointer tokens only against keys and indices the document owns', () => { + const refs = [ + '#/webhooks/__proto__/post/parameters/0', + '#/webhooks/__proto__/post/parameters/length', + '#/webhooks/__proto__/post/parameters/00', + '#/webhooks/__proto__/post/parameters/-', + '#/webhooks/constructor', + '#/webhooks/hasOwnProperty', + ] + const result = convertSpec({ + paths: { '/a': { get: { parameters: refs.map($ref => ({ $ref })), responses: {} } } }, + webhooks: JSON.parse('{"__proto__":{"post":{"parameters":[{"in":"query","name":"own"}]}}}'), + }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([{ in: 'query', name: 'own' }, ...refs.slice(1).map($ref => ({ $ref }))]) + }) +}) + +describe('path Item references', () => { + // A Path Item `$ref` is merged only when its pointer names a Path Item: + // an entry of `paths`, `webhooks`, `components.pathItems`, or a Callback + // Object. A schema property or an extension that happens to be called + // `callbacks` does not count, and neither does an `x-` key. + it.each([ + ['a schema property named callbacks', '#/webhooks/w/post/requestBody/content/a~1b/schema/properties/callbacks/properties/x'], + ['a webhook named callbacks', '#/webhooks/callbacks/get/responses'], + ['a callback extension', '#/webhooks/w/post/callbacks/c/x-note'], + ])('leaves a Path Item $ref to %s as written', (_name, ref) => { + expect(convertSpec({ + paths: { '/a': { $ref: ref } }, + webhooks: { + callbacks: { get: { responses: { 200: { description: 'ok' } } } }, + w: { + post: { + callbacks: { c: { 'x-note': { get: {} } } }, + requestBody: { content: { 'a/b': { schema: { properties: { callbacks: { properties: { x: { get: 'prop', type: 'string' } } } } } } } }, + }, + }, + }, + }).paths).toEqual({ '/a': { $ref: ref } }) + }) + + it('inlines a Path Item $ref to a callback nested in another callback', () => { + expect(convertSpec({ + paths: { '/a': { $ref: '#/webhooks/w/post/callbacks/c/{$url}/get/callbacks/d/{$url}' } }, + webhooks: { w: { post: { callbacks: { c: { '{$url}': { get: { callbacks: { d: { '{$url}': { summary: 'nested' } } } } } } } } } }, + }).paths).toEqual({ '/a': { summary: 'nested' } }) + }) +}) + +describe('references left as written', () => { + // Nothing can be inlined for a target that is missing, is not an object, + // or never ends. These references already dangle or loop in the input, + // and are left as written wherever they appear (only stripped of the + // overrides 3.0 ignores). + it.each([ + ['a missing target', '#/webhooks/newPet/post/parameters/9'], + ['a non-object target', '#/webhooks/newPet/post/operationId'], + ['a looping chain', '#/components/pathItems/Loop/parameters/0'], + ['a malformed percent escape', '#/webhooks/%E0%A4%A'], + ])('leaves a reference to %s as written, in every position', (_name, ref) => { + const reference = { $ref: ref, description: 'd' } + const bare = { $ref: ref } + const result = convertSpec({ + components: { + callbacks: { C: reference }, + examples: { E: reference }, + headers: { H: reference }, + links: { L: reference }, + parameters: { P: reference }, + pathItems: { + Loop: { parameters: [{ $ref: '#/components/pathItems/Loop/parameters/1' }, { $ref: '#/components/pathItems/Loop/parameters/0' }] }, + }, + requestBodies: { B: reference }, + responses: { R: reference }, + schemas: { S: bare, T: { $ref: ref, type: 'string' } }, + securitySchemes: { S: reference }, + }, + paths: { + '/a': { + get: { + callbacks: { cb: reference }, + parameters: [reference, { in: 'query', name: 'kept' }], + requestBody: reference, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: reference } } }, + examples: { e: reference }, + schema: bare, + }, + }, + description: 'ok', + headers: { H: reference }, + links: { l: reference }, + }, + 201: reference, + }, + }, + parameters: [reference], + }, + '/b': reference, + }, + webhooks: { newPet: hook }, + }) + expect(result.components).toEqual({ + callbacks: { C: bare }, + examples: { E: bare }, + headers: { H: bare }, + links: { L: bare }, + parameters: { P: bare }, + requestBodies: { B: bare }, + responses: { R: bare }, + schemas: { S: bare, T: { allOf: [bare], type: 'string' } }, + securitySchemes: { S: bare }, + }) + expect(result.paths).toEqual({ + '/a': { + get: { + callbacks: { cb: bare }, + parameters: [bare, { in: 'query', name: 'kept' }], + requestBody: bare, + responses: { + 200: { + content: { 'application/json': { encoding: { f: { headers: { H: bare } } }, examples: { e: bare }, schema: bare } }, + description: 'ok', + headers: { H: bare }, + links: { l: bare }, + }, + 201: bare, + }, + }, + parameters: [bare], + }, + '/b': reference, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts new file mode 100644 index 0000000..bcff65a --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts @@ -0,0 +1,140 @@ +import { dig } from '../../helpers' +import { convertSpec } from './helpers' + +const apiKey = { in: 'header', name: 'k', type: 'apiKey' } + +describe('mutualTLS', () => { + // The `mutualTLS` scheme type is new in 3.1: https://spec.openapis.org/oas/v3.1.2.html#security-scheme-type + // 3.0 cannot describe it, so the scheme is removed together with its name + // in every Security Requirement. A requirement left empty is removed too: + // an empty requirement `{}` means "no security needed" + // (https://spec.openapis.org/oas/v3.0.4.html#security-requirement-object), + // which would open up an endpoint that required a client certificate. + it('removes mutualTLS schemes and requirements that become empty', () => { + const result = convertSpec({ + components: { securitySchemes: { api: apiKey, mtls: { type: 'mutualTLS' } } }, + security: [{ mtls: [] }, { api: [], mtls: [] }, {}], + }) + expect(result.components).toEqual({ securitySchemes: { api: apiKey } }) + expect(result.security).toEqual([{ api: [] }, {}]) + }) + + // An empty operation `security` list would remove all security from the + // operation (https://spec.openapis.org/oas/v3.0.4.html#operation-security), + // so an emptied list is removed instead. The operation then falls back to + // the root `security`. + it('removes a security list that became empty instead of making it public', () => { + const result = convertSpec({ + components: { securitySchemes: { mtls: { type: 'mutualTLS' } } }, + paths: { '/admin': { get: { responses: {}, security: [{ mtls: [] }] } } }, + security: [{ mtls: [] }], + }) + expect(result.paths).toEqual({ '/admin': { get: { responses: {} } } }) + expect(result).not.toHaveProperty('security') + }) + + it('keeps a security list that was empty in the input', () => { + const result = convertSpec({ paths: { '/a': { get: { responses: {}, security: [] } } }, security: [] }) + expect(result.paths).toEqual({ '/a': { get: { responses: {}, security: [] } } }) + expect(result.security).toEqual([]) + }) + + it('converts operation-level security lists', () => { + expect(convertSpec({ + components: { securitySchemes: { api: apiKey, mtls: { type: 'mutualTLS' } } }, + paths: { '/a': { get: { responses: {}, security: [{ mtls: [] }, { api: ['read'] }] } } }, + }).paths).toEqual({ '/a': { get: { responses: {}, security: [{ api: [] }] } } }) + }) + + // A scheme can be a Reference Object. It is judged by the scheme at the + // end of its reference chain. + it('removes reference aliases of mutualTLS schemes and their requirements', () => { + const result = convertSpec({ + components: { + securitySchemes: { + api: apiKey, + clientCert: { $ref: '#/components/securitySchemes/mtlsBase' }, + mtlsBase: { type: 'mutualTLS' }, + }, + }, + security: [{ clientCert: [] }, { api: [] }], + }) + expect(result.components).toEqual({ securitySchemes: { api: apiKey } }) + expect(result.security).toEqual([{ api: [] }]) + }) + + it('removes schemes aliased into the removed parts by the type of their target', () => { + const result = convertSpec({ + components: { + securitySchemes: { + 'Escaped': { $ref: '#/components/securitySchemes/m~1tls' }, + 'Http': { $ref: '#/webhooks/w/x-http' }, + 'm/tls': { type: 'mutualTLS' }, + 'Tls': { $ref: '#/webhooks/w/x-tls' }, + }, + }, + paths: { '/a': { get: { responses: {}, security: [{ Tls: [] }, { Escaped: [] }, { Http: ['read'] }] } } }, + security: [{ Tls: [] }], + webhooks: { w: { 'x-http': { scheme: 'bearer', type: 'http' }, 'x-tls': { type: 'mutualTLS' } } }, + }) + expect(result.components).toEqual({ securitySchemes: { Http: { scheme: 'bearer', type: 'http' } } }) + expect(result.security).toBeUndefined() + expect(dig(result, 'paths', '/a', 'get', 'security')).toEqual([{ Http: [] }]) + }) + + it('survives cyclic, dangling, external, and malformed scheme aliases', () => { + const securitySchemes = { + dangling: { $ref: '#/components/securitySchemes/missing' }, + external: { $ref: 'https://example.com/s.json#/schemes/a' }, + junk: 42, + nested: { $ref: '#/components/securitySchemes/a/b' }, + ping: { $ref: '#/components/securitySchemes/pong' }, + pong: { $ref: '#/components/securitySchemes/ping' }, + } + const security = [{ dangling: ['a'], external: ['b'], junk: ['c'], nested: ['d'], ping: ['e'] }] + expect(convertSpec({ components: { securitySchemes }, security })).toMatchObject({ components: { securitySchemes }, security }) + }) +}) + +describe('scopes of non-OAuth schemes', () => { + // 3.1 lets a requirement list role names for any scheme type: + // https://spec.openapis.org/oas/v3.1.2.html#security-requirements-name + // In 3.0, "for other security scheme types, the array MUST be empty": + // https://spec.openapis.org/oas/v3.0.4.html#security-requirements-name + // The roles are not exchanged in-band, so dropping them does not change + // what a client sends. + it('empties the list on apiKey and http schemes and keeps it elsewhere', () => { + expect(convertSpec({ + components: { + securitySchemes: { + api: apiKey, + basic: { scheme: 'basic', type: 'http' }, + oauth: { flows: {}, type: 'oauth2' }, + oidc: { openIdConnectUrl: 'https://x', type: 'openIdConnect' }, + }, + }, + security: [ + { api: ['read'], basic: ['admin'] }, + { oauth: ['read'], oidc: ['a'], unknownScheme: ['s'] }, + ], + }).security).toEqual([ + { api: [], basic: [] }, + { oauth: ['read'], oidc: ['a'], unknownScheme: ['s'] }, + ]) + }) +}) + +describe('malformed input', () => { + it('clones malformed security values and scheme maps unchanged', () => { + expect(convertSpec({ security: [{ api: [] }, 'junk', 42] }).security).toEqual([{ api: [] }, 'junk', 42]) + expect(convertSpec({ security: { api: [] } }).security).toEqual({ api: [] }) + expect(convertSpec({ components: { securitySchemes: 'junk' } }).components).toEqual({ securitySchemes: 'junk' }) + }) + + it('keeps non-array scopes as they are', () => { + expect(convertSpec({ + components: { securitySchemes: { api: apiKey } }, + security: [{ api: 'read' }], + }).security).toEqual([{ api: 'read' }]) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts new file mode 100644 index 0000000..d916c34 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts @@ -0,0 +1,104 @@ +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' + +import { dig } from '../../helpers' + +describe('input shapes', () => { + // `true` and `false` are complete schemas in JSON Schema 2020-12, and 3.1 + // accepts them as they are: https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 + it('passes boolean schemas through', () => { + expect(downgradeSchemaV32ToV31(true)).toBe(true) + expect(downgradeSchemaV32ToV31(false)).toBe(false) + }) + + it('passes non-schema input through', () => { + expect(downgradeSchemaV32ToV31('junk' as any)).toBe('junk') + expect(downgradeSchemaV32ToV31(null as any)).toBeNull() + expect(downgradeSchemaV32ToV31([{ type: 'string' }] as any)).toEqual([{ type: 'string' }]) + }) +}) + +describe('copies', () => { + it('deep-clones the schema, sharing nothing with the input', () => { + const source = { + allOf: [{ discriminator: { defaultMapping: 'Dog' } }, true], + discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, + items: { xml: { nodeType: 'cdata' } }, + properties: { a: { xml: { name: 'a' } } }, + } + const result = downgradeSchemaV32ToV31(source as any) + expect(result).toEqual({ + allOf: [{ discriminator: {} }, true], + discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, + items: { xml: {} }, + properties: { a: { xml: { name: 'a' } } }, + }) + expect(result).not.toBe(source) + for (const path of [['allOf'], ['allOf', '0'], ['discriminator'], ['discriminator', 'mapping'], ['items'], ['properties'], ['properties', 'a'], ['properties', 'a', 'xml']]) { + expect(dig(result, ...path)).not.toBe(dig(source, ...path)) + } + }) + + it('returns a fresh copy on every call', () => { + const schema: OpenAPIV3_2.SchemaObject = { properties: { a: { type: 'string' } }, type: 'object' } + expect(downgradeSchemaV32ToV31(schema)).not.toBe(downgradeSchemaV32ToV31(schema)) + }) + + it('never mutates the input schema', () => { + const schema: OpenAPIV3_2.SchemaObject = { + discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, + properties: { a: { xml: { nodeType: 'attribute' } } }, + type: 'object', + } + const before = structuredClone(schema) + downgradeSchemaV32ToV31(schema) + expect(schema).toEqual(before) + }) + + it('keeps the key order of the input', () => { + expect(Object.keys(downgradeSchemaV32ToV31({ type: 'object', required: ['a'], properties: {}, description: 'd' }))).toEqual([ + 'type', + 'required', + 'properties', + 'description', + ]) + }) + + // JSON.parse creates a real own `__proto__` key, here a property name. + it('copies a __proto__ property as a plain own key without polluting prototypes', () => { + const result = downgradeSchemaV32ToV31(JSON.parse('{"properties":{"__proto__":{"xml":{"nodeType":"attribute"}}}}')) + const properties = dig(result, 'properties') as object + expect(Object.getPrototypeOf(properties)).toBe(Object.prototype) + expect(Object.getOwnPropertyDescriptor(properties, '__proto__')?.value).toEqual({ xml: { attribute: true } }) + expect('xml' in {}).toBe(false) + }) +}) + +describe('object graphs', () => { + // A dereferenced schema can contain itself. The output keeps the same + // shape: the cycle points at the converted ancestor. + it('converts a cyclic schema, pointing the cycle at the converted ancestor', () => { + const node: Record = { discriminator: { defaultMapping: 'A', propertyName: 'kind' }, type: 'object' } + node.properties = { self: node, children: { items: node, type: 'array' } } + const result = downgradeSchemaV32ToV31(node) + expect(dig(result, 'discriminator')).toEqual({ propertyName: 'kind' }) + expect(dig(result, 'properties', 'self')).toBe(result) + expect(dig(result, 'properties', 'children', 'items')).toBe(result) + }) + + it('converts a subschema shared by several positions once', () => { + const shared = { xml: { nodeType: 'attribute' } } + const result = downgradeSchemaV32ToV31({ properties: { a: shared, b: shared } } as any) + expect(dig(result, 'properties', 'a')).toEqual({ xml: { attribute: true } }) + expect(dig(result, 'properties', 'b')).toBe(dig(result, 'properties', 'a')) + }) + + it('converts deeply nested schemas without overflowing the stack', () => { + let deep: OpenAPIV3_2.SchemaObject = { type: 'string' } + for (let index = 0; index < 1000; index += 1) { + deep = { items: deep, type: 'array' } + } + expect(() => downgradeSchemaV32ToV31(deep)).not.toThrow() + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts new file mode 100644 index 0000000..00c065e --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts @@ -0,0 +1,76 @@ +// Both versions use JSON Schema 2020-12, so a Schema Object only changes in +// the two OpenAPI-specific keywords that 3.2 extended: `xml` and +// `discriminator`. Every other keyword passes through as is. + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' + +describe('xml.nodeType', () => { + // 3.2 replaces the `attribute` and `wrapped` flags with `nodeType`, one of + // `element`, `attribute`, `text`, `cdata`, or `none`: + // https://spec.openapis.org/oas/v3.2.0.html#xml-node-type + // 3.1 can express two of them: + // - `attribute` as `attribute: true`: https://spec.openapis.org/oas/v3.1.2.html#xml-attribute + // - `element` on an array as `wrapped: true`, since arrays default to + // `none` (unwrapped) and an explicit `element` wraps them: + // https://spec.openapis.org/oas/v3.2.0.html#modeling-element-lists + // https://spec.openapis.org/oas/v3.1.2.html#xml-wrapped + // `element` on any other schema is the default anyway, and `text`, + // `cdata`, and `none` have no 3.1 form, so those are removed. + it.each([ + ['maps an attribute node to attribute: true', { xml: { name: 'n', nodeType: 'attribute' } }, { xml: { attribute: true, name: 'n' } }], + ['maps an element node on an array to wrapped: true', { type: 'array', xml: { nodeType: 'element' } }, { type: 'array', xml: { wrapped: true } }], + ['maps an element node on a nullable array to wrapped: true', { type: ['array', 'null'], xml: { nodeType: 'element' } }, { type: ['array', 'null'], xml: { wrapped: true } }], + ['removes an element node elsewhere, where it is the default', { type: 'object', xml: { nodeType: 'element' } }, { type: 'object', xml: {} }], + ['removes a text node', { xml: { nodeType: 'text' } }, { xml: {} }], + ['removes a cdata node', { xml: { nodeType: 'cdata' } }, { xml: {} }], + ['removes a none node', { xml: { nodeType: 'none' } }, { xml: {} }], + ['keeps an xml object without nodeType', { xml: { name: 'n', prefix: 'p' } }, { xml: { name: 'n', prefix: 'p' } }], + ['passes a malformed xml value through', { xml: 'junk' }, { xml: 'junk' }], + ])('%s', (_name, input, expected) => { + expect(downgradeSchemaV32ToV31(input as any)).toEqual(expected) + }) +}) + +describe('discriminator.defaultMapping', () => { + // 3.2 lets a discriminator name the schema to use when the property is + // absent or its value is unmapped: + // https://spec.openapis.org/oas/v3.2.0.html#discriminator-default-mapping + // 3.1 has no fallback, so the field is removed and `mapping` is kept. + it('removes defaultMapping and keeps mapping and propertyName', () => { + expect(downgradeSchemaV32ToV31({ + discriminator: { defaultMapping: 'Dog', mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, + oneOf: [{ $ref: '#/components/schemas/Dog' }], + })).toEqual({ + discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, + oneOf: [{ $ref: '#/components/schemas/Dog' }], + }) + }) + + it('passes a malformed discriminator or mapping through', () => { + expect(downgradeSchemaV32ToV31({ discriminator: 'junk' } as any)).toEqual({ discriminator: 'junk' }) + expect(downgradeSchemaV32ToV31({ discriminator: { mapping: 'junk', propertyName: 'kind' } } as any)).toEqual({ + discriminator: { mapping: 'junk', propertyName: 'kind' }, + }) + }) +}) + +describe('other keywords', () => { + it('keeps JSON Schema keywords, unknown keywords, and extensions unchanged', () => { + const schema = { + '$comment': 'c', + '$defs': { a: { type: 'string' } }, + '$id': 'https://example.com/s', + '$schema': 'https://spec.openapis.org/oas/3.1/dialect/base', + 'const': 1, + 'customKeyword': { nested: true }, + 'examples': [1], + 'externalDocs': { url: 'https://example.com' }, + 'maximum': 5, + 'type': 'number', + 'x-note': 'kept', + } + expect(downgradeSchemaV32ToV31(schema as OpenAPIV3_2.SchemaObject)).toEqual(schema) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts new file mode 100644 index 0000000..8e63746 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts @@ -0,0 +1,37 @@ +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' + +// Converting a standalone schema removes nothing a `$ref` could point at: +// `$defs`, `$id`, and `$anchor` all exist in 3.1. So every reference stays as +// written, wherever it points. +it('leaves every $ref as written', () => { + const schema: OpenAPIV3_2.SchemaObject = { + $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, + $ref: '#/$defs/node', + properties: { + anchor: { $ref: '#node' }, + external: { $ref: 'https://example.com/pet.json' }, + missing: { $ref: '#/$defs/missing' }, + sibling: { $ref: '#/$defs/node', description: 'with a sibling' }, + }, + } + expect(downgradeSchemaV32ToV31(schema)).toEqual(schema) +}) + +// References into the parts the conversion changes: the `xml` object +// survives, so a `$ref` to it stays valid. `defaultMapping` is removed, but +// its value is a string rather than a schema, so there is nothing to inline +// and the reference is left as written, like any other dangling one. +it('leaves references into converted keywords as written', () => { + const schema = { + discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, + properties: { a: { $ref: '#/xml' }, b: { $ref: '#/discriminator/defaultMapping' } }, + xml: { nodeType: 'attribute' }, + } + expect(downgradeSchemaV32ToV31(schema as any)).toEqual({ + ...schema, + discriminator: { propertyName: 'kind' }, + xml: { attribute: true }, + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts new file mode 100644 index 0000000..fa53b8f --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts @@ -0,0 +1,42 @@ +import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' + +const inner = { discriminator: { defaultMapping: 'A', propertyName: 'kind' } } +const converted = { discriminator: { propertyName: 'kind' } } + +// Every JSON Schema 2020-12 keyword that takes a schema, a list of schemas, or +// a map of schemas: https://json-schema.org/draft/2020-12/json-schema-core#section-10 +const single = ['additionalProperties', 'contains', 'contentSchema', 'else', 'if', 'items', 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties'] +const lists = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] +const maps = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'] + +it('converts nested schemas at every subschema position', () => { + expect(downgradeSchemaV32ToV31({ + ...Object.fromEntries(single.map(key => [key, inner])), + ...Object.fromEntries(lists.map(key => [key, [inner, true]])), + ...Object.fromEntries(maps.map(key => [key, { a: inner }])), + } as any)).toEqual({ + ...Object.fromEntries(single.map(key => [key, converted])), + ...Object.fromEntries(lists.map(key => [key, [converted, true]])), + ...Object.fromEntries(maps.map(key => [key, { a: converted }])), + }) +}) + +// `const`, `default`, `enum`, and `examples` hold instance data, and +// extensions hold anything. A value there that looks like a schema is not +// one, so it is copied as is. +it('does not convert schema-like values outside subschema positions', () => { + const schema = { 'const': inner, 'default': inner, 'enum': [inner], 'examples': [inner], 'x-extension': inner } + expect(downgradeSchemaV32ToV31(schema as any)).toEqual(schema) +}) + +it('converts nested schemas at any depth', () => { + expect(downgradeSchemaV32ToV31({ + items: { properties: { a: { anyOf: [{ xml: { nodeType: 'attribute' } }] } } }, + })).toEqual({ + items: { properties: { a: { anyOf: [{ xml: { attribute: true } }] } } }, + }) +}) + +it('clones malformed subschema containers through', () => { + expect(downgradeSchemaV32ToV31({ allOf: 'junk', properties: 5 } as any)).toEqual({ allOf: 'junk', properties: 5 }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap b/packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap new file mode 100644 index 0000000..fded8fc --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap @@ -0,0 +1,182 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`official examples > flattens the tag hierarchy of the tags example 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/flights": { + "get": { + "summary": "List all flights", + "tags": [ + "flights", + ], + }, + }, + "/flights/delayed": { + "get": { + "summary": "Get delayed flights", + "tags": [ + "delays", + ], + }, + }, + "/flights/domestic": { + "get": { + "summary": "List domestic flights", + "tags": [ + "domestic", + ], + }, + }, + "/flights/international": { + "get": { + "summary": "List international flights", + "tags": [ + "international", + ], + }, + }, + }, + "tags": [ + { + "description": "Core flight operations", + "name": "flights", + }, + { + "description": "Flights that cross country borders", + "name": "international", + }, + { + "description": "Flights within a single country", + "name": "domestic", + }, + { + "description": "Information about flight delays", + "externalDocs": { + "description": "Delay compensation policies", + "url": "https://docs.example.com/delay-policies", + }, + "name": "delays", + }, + ], +} +`; + +exports[`official examples > removes the QUERY operation of the query example, leaving an empty path item 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/flights/search": {}, + }, +} +`; + +exports[`official examples > removes the discriminator defaultMapping of the mega document 1`] = ` +{ + "components": { + "pathItems": { + "myPathItem": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Foo", + }, + ], + "discriminator": { + "mapping": { + "foo": "Foo", + }, + "propertyName": "type", + "x-extension": true, + }, + "externalDocs": { + "description": "More docs!", + "url": "https://example.com/elsewhere.html", + }, + "myArbitraryKeyword": true, + "properties": { + "arr": { + "$comment": "Array without items keyword", + "type": "array", + }, + "either": { + "type": [ + "string", + "null", + ], + }, + "int": { + "exclusiveMaximum": 100, + "exclusiveMinimum": 0, + "type": "integer", + }, + "none": { + "type": "null", + }, + "type": { + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "required": true, + }, + }, + }, + }, + "schemas": { + "Foo": { + "properties": { + "type": { + "const": "foo", + }, + }, + "type": "object", + }, + }, + "securitySchemes": { + "mtls": { + "type": "mutualTLS", + }, + }, + }, + "info": { + "license": { + "identifier": "Apache-2.0", + "name": "Apache 2.0", + }, + "summary": "My API's summary", + "title": "My API", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/": { + "get": { + "parameters": [], + }, + }, + "/{pathTest}": {}, + }, + "webhooks": { + "myWebhook": { + "$ref": "#/components/pathItems/myPathItem", + "description": "Overriding description", + }, + }, +} +`; diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts new file mode 100644 index 0000000..faee838 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts @@ -0,0 +1,44 @@ +import { convertComponent, convertSpec } from './helpers' + +describe('component maps', () => { + it('converts inline objects and keeps references to surviving ones in every map', () => { + expect(convertSpec({ + components: { + examples: { E: { dataValue: 1 }, ERef: { $ref: '#/components/examples/E' } }, + headers: { H: { style: 'cookie' }, HRef: { $ref: '#/components/headers/H' } }, + links: { junkLink: 42 }, + parameters: { P: { in: 'querystring', name: 'q' }, PRef: { $ref: '#/components/parameters/P' } }, + requestBodies: { + B: { content: { 'application/json': { itemSchema: { type: 'string' } } } }, + BRef: { $ref: '#/components/requestBodies/B' }, + }, + responses: { R: { summary: 'ok' }, RRef: { $ref: '#/components/responses/R' } }, + }, + }).components).toEqual({ + examples: { E: { value: 1 }, ERef: { $ref: '#/components/examples/E' } }, + headers: { H: {}, HRef: { $ref: '#/components/headers/H' } }, + links: { junkLink: 42 }, + parameters: {}, + requestBodies: { + B: { content: { 'application/json': { schema: { items: { type: 'string' }, type: 'array' } } } }, + BRef: { $ref: '#/components/requestBodies/B' }, + }, + responses: { R: { description: 'ok' }, RRef: { $ref: '#/components/responses/R' } }, + }) + }) + + it('converts components.schemas entries', () => { + expect(convertComponent('schemas', { + discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, + xml: { nodeType: 'attribute' }, + })).toEqual({ + discriminator: { propertyName: 'kind' }, + xml: { attribute: true }, + }) + }) + + it('clones unknown component keys and passes a non-object components value through', () => { + expect(convertSpec({ components: { custom: { anything: true } } }).components).toEqual({ custom: { anything: true } }) + expect(convertSpec({ components: 'junk' }).components).toBe('junk') + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts new file mode 100644 index 0000000..58f2821 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts @@ -0,0 +1,294 @@ +// 3.2 lets a content map hold Reference Objects and adds `components.mediaTypes` +// for reusable Media Type Objects: +// https://spec.openapis.org/oas/v3.2.0.html#request-body-content +// https://spec.openapis.org/oas/v3.2.0.html#components-media-types +// A 3.1 content map holds Media Type Objects only +// (https://spec.openapis.org/oas/v3.1.2.html#request-body-content), so each +// reference is replaced by the converted Media Type Object it resolves to. +// A reference that cannot be resolved inside the document (external, +// missing, or looping) cannot be kept either, so its entry is removed. + +import { dig } from '../../helpers' +import { convertContent, convertPathItem, convertSpec } from './helpers' + +describe('inlining', () => { + it('replaces a reference with the converted media type', () => { + expect(convertContent( + { 'application/jsonl': { $ref: '#/components/mediaTypes/Stream' } }, + { mediaTypes: { Stream: { itemSchema: { type: 'object' } } } }, + )).toEqual({ 'application/jsonl': { schema: { items: { type: 'object' }, type: 'array' } } }) + }) + + it('inlines in request body, response, parameter, and header content maps', () => { + const reference = { 'application/json': { $ref: '#/components/mediaTypes/Json' } } + const inlined = { 'application/json': { schema: { type: 'string' } } } + expect(convertPathItem( + { + get: { + parameters: [{ content: reference, in: 'query', name: 'q' }], + responses: { 200: { content: reference, description: 'ok', headers: { 'X-H': { content: reference } } } }, + }, + }, + { mediaTypes: { Json: { schema: { type: 'string' } } } }, + )).toEqual({ + get: { + parameters: [{ content: inlined, in: 'query', name: 'q' }], + responses: { 200: { content: inlined, description: 'ok', headers: { 'X-H': { content: inlined } } } }, + }, + }) + }) + + it('follows chains of references down to the final media type', () => { + expect(convertContent( + { 'application/json': { $ref: '#/components/mediaTypes/A' } }, + { mediaTypes: { A: { $ref: '#/components/mediaTypes/B' }, B: { schema: { type: 'number' } } } }, + )).toEqual({ 'application/json': { schema: { type: 'number' } } }) + }) + + it('follows long acyclic chains', () => { + const links = Array.from({ length: 40 }, (_, index) => [`m${index}`, { $ref: `#/components/mediaTypes/m${index + 1}` }]) + const mediaTypes = Object.fromEntries([...links, ['m40', { schema: { type: 'string' } }]]) + expect(convertContent({ 'application/json': { $ref: '#/components/mediaTypes/m0' } }, { mediaTypes })).toEqual({ + 'application/json': { schema: { type: 'string' } }, + }) + }) + + // Any local pointer to a Media Type Object works, not only ones into + // `components.mediaTypes`. Pointer tokens escape `/` as `~1`: https://www.rfc-editor.org/rfc/rfc6901#section-4 + it('inlines references to any local media type, decoding escaped names', () => { + expect(convertSpec({ + components: { + mediaTypes: { 'a/b': { schema: { type: 'string' } } }, + requestBodies: { + Json: { content: { 'application/json': { schema: { type: 'number' } } } }, + Reuse: { + content: { + 'application/json': { $ref: '#/components/requestBodies/Json/content/application~1json' }, + 'text/plain': { $ref: '#/components/mediaTypes/a~1b' }, + }, + }, + }, + }, + }).components?.requestBodies).toEqual({ + Json: { content: { 'application/json': { schema: { type: 'number' } } } }, + Reuse: { + content: { + 'application/json': { schema: { type: 'number' } }, + 'text/plain': { schema: { type: 'string' } }, + }, + }, + }) + }) + + it('removes the mediaTypes map from components', () => { + expect(convertSpec({ + components: { mediaTypes: { Json: { schema: {} } }, schemas: { S: { type: 'string' } } }, + }).components).toEqual({ schemas: { S: { type: 'string' } } }) + }) +}) + +describe('references that cannot be inlined', () => { + it('removes entries whose chain loops', () => { + expect(convertContent( + { + 'application/json': { $ref: '#/components/mediaTypes/Loop' }, + 'application/xml': { $ref: '#/components/mediaTypes/Ping' }, + }, + { + mediaTypes: { + Loop: { $ref: '#/components/mediaTypes/Loop' }, + Ping: { $ref: '#/components/mediaTypes/Pong' }, + Pong: { $ref: '#/components/mediaTypes/Ping' }, + }, + }, + )).toEqual({}) + }) + + it('removes entries with external, missing, and unparseable references', () => { + expect(convertContent( + { + 'a/1': { $ref: '#/components/schemas/Foo' }, + 'a/2': { $ref: '#/components/mediaTypes/nested/name' }, + 'a/3': { $ref: '#/components/mediaTypes/' }, + 'a/4': { $ref: 'https://example.com/other.json#/mediaTypes/A' }, + 'a/5': { $ref: '#/components/mediaTypes/Unknown' }, + 'a/6': { $ref: '#/components/mediaTypes/Known' }, + }, + { mediaTypes: { Known: { example: 1 } } }, + )).toEqual({ 'a/6': { example: 1 } }) + }) + + it('does not resolve names through the prototype chain', () => { + expect(convertContent({ 'application/json': { $ref: '#/components/mediaTypes/hasOwnProperty' } }, { mediaTypes: {} })).toEqual({}) + }) + + it('removes entries when components.mediaTypes is missing or malformed', () => { + const content = { 'application/json': { $ref: '#/components/mediaTypes/A' } } + expect(convertSpec({ paths: { '/a': { post: { requestBody: { content }, responses: {} } } } })).toEqual({ + openapi: '3.1.2', + paths: { '/a': { post: { requestBody: { content: {} }, responses: {} } } }, + }) + expect(convertContent(content, { mediaTypes: 'junk' })).toEqual({}) + }) + + it('clones a non-object content value through', () => { + expect(convertContent('junk')).toBe('junk') + }) +}) + +describe('parameters and headers left without content', () => { + // With `content`, a parameter or header MUST hold exactly one entry + // (https://spec.openapis.org/oas/v3.1.2.html#parameter-content), and it + // has no `schema` to fall back on. Once its only entry is removed it no + // longer describes anything, so it is removed as well. + const missing = { 'application/json': { $ref: '#/components/mediaTypes/Missing' } } + + it('removes a parameter whose only content entry could not be inlined', () => { + expect(convertPathItem({ + get: { + parameters: [{ content: missing, in: 'query', name: 'q' }, { in: 'query', name: 'keep', schema: {} }], + responses: {}, + }, + })).toEqual({ + get: { parameters: [{ in: 'query', name: 'keep', schema: {} }], responses: {} }, + }) + }) + + it('keeps a parameter when part of its content could be inlined', () => { + expect(convertPathItem( + { + get: { + parameters: [{ content: { ...missing, 'application/xml': { $ref: '#/components/mediaTypes/Known' } }, in: 'query', name: 'q' }], + responses: {}, + }, + }, + { mediaTypes: { Known: { example: 1 } } }, + )).toEqual({ + get: { parameters: [{ content: { 'application/xml': { example: 1 } }, in: 'query', name: 'q' }], responses: {} }, + }) + }) + + it('removes headers and component parameters whose entire content could not be inlined', () => { + const result = convertSpec({ + components: { + headers: { Broken: { content: missing }, Keep: { schema: {} } }, + parameters: { Broken: { content: missing, in: 'query', name: 'q' } }, + }, + paths: { + '/a': { + get: { responses: { 200: { description: 'ok', headers: { 'X-Broken': { content: missing }, 'X-Keep': { schema: {} } } } } }, + }, + }, + }) + expect(result.components).toEqual({ headers: { Keep: { schema: {} } }, parameters: {} }) + expect(result.paths).toEqual({ + '/a': { get: { responses: { 200: { description: 'ok', headers: { 'X-Keep': { schema: {} } } } } } }, + }) + }) + + it('removes references to removed parameters and headers, following alias chains', () => { + const result = convertSpec({ + components: { + headers: { + Broken: { content: { 'text/plain': { $ref: '#/components/mediaTypes/Loop' } } }, + BrokenAlias: { $ref: '#/components/headers/Broken' }, + }, + mediaTypes: { Loop: { $ref: '#/components/mediaTypes/Loop' } }, + parameters: { + Broken: { content: missing, in: 'query', name: 'q' }, + BrokenAlias: { $ref: '#/components/parameters/Broken' }, + }, + }, + paths: { + '/a': { + get: { + parameters: [{ $ref: '#/components/parameters/Broken' }, { $ref: '#/components/parameters/BrokenAlias' }], + responses: { + 200: { + description: 'ok', + headers: { + 'X-Broken': { $ref: '#/components/headers/Broken' }, + 'X-BrokenAlias': { $ref: '#/components/headers/BrokenAlias' }, + }, + }, + }, + }, + }, + }, + }) + expect(result.components).toEqual({ headers: {}, parameters: {} }) + expect(result.paths).toEqual({ + '/a': { get: { parameters: [], responses: { 200: { description: 'ok', headers: {} } } } }, + }) + }) + + it('removes a reference to such a header through any pointer', () => { + expect(convertSpec({ + components: { headers: { H: { $ref: '#/paths/~1a/get/responses/200/headers/X-Broken' } } }, + paths: { '/a': { get: { responses: { 200: { description: 'ok', headers: { 'X-Broken': { content: missing } } } } } } }, + }).components).toEqual({ headers: {} }) + }) +}) + +describe('recursive media types', () => { + // A schema that reaches the media type it is inlined from would make the + // output an infinite (circular) object. The recursion is cut instead: the + // inner `$ref` becomes `{}`, the schema that accepts anything, which can + // only loosen validation, never tighten it. + it('cuts a recursive schema reached through a content map instead of emitting a circular object', () => { + const result = convertSpec({ + components: { + mediaTypes: { + Tree: { + schema: { + properties: { children: { items: { $ref: '#/components/mediaTypes/Tree/schema' }, type: 'array' } }, + type: 'object', + }, + }, + }, + }, + paths: { + '/a': { + get: { responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Tree' } }, description: 'ok' } } }, + }, + }, + }) + expect(() => JSON.stringify(result)).not.toThrow() + expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toEqual({ + properties: { children: { items: {}, type: 'array' } }, + type: 'object', + }) + }) + + // Here the header is reachable both on its own and from inside the media + // type it contains. The copy reached from inside is cut, but the header + // itself survives, so references to it stay valid and are kept. + it('keeps a header alias whose target is only cut by a media type cycle', () => { + const result = convertSpec({ + components: { + headers: { A: { $ref: '#/components/mediaTypes/M/encoding/e/headers/h' } }, + mediaTypes: { + M: { + encoding: { e: { headers: { h: { content: { 'a/b': { $ref: '#/components/mediaTypes/M' } } }, x: { $ref: '#/components/headers/A' } } } }, + schema: { type: 'string' }, + }, + }, + }, + paths: { + '/p': { + get: { + responses: { + 200: { + content: { 'a/b': { $ref: '#/components/mediaTypes/M' } }, + description: 'ok', + headers: { X: { $ref: '#/components/headers/A' } }, + }, + }, + }, + }, + }, + }) + expect(dig(result, 'paths', '/p', 'get', 'responses', '200', 'headers')).toEqual({ X: { $ref: '#/components/headers/A' } }) + expect(dig(result, 'components', 'headers', 'A', 'content', 'a/b', 'schema')).toEqual({ type: 'string' }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts new file mode 100644 index 0000000..0cbb16c --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts @@ -0,0 +1,273 @@ +// The official 3.2 documents, from OAI/learn.openapis.org and from the +// `tests/schema/pass` folder of OAI/OpenAPI-Specification (see +// packages/types/tests/README.md). Each one must downgrade to a document the +// official 3.1 JSON Schema accepts, without leaving a reference dangling that +// resolved before. + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { doc as queryExample } from '../../../../types/tests/examples/3-2-query-example' +import { doc as tagsExample } from '../../../../types/tests/examples/3-2-tags-example' +import { doc as callbackObjectExamples } from '../../../../types/tests/schema-tests-3.2/callback-object-examples' +import { doc as compPathitems } from '../../../../types/tests/schema-tests-3.2/comp-pathitems' +import { doc as componentsObjectExample } from '../../../../types/tests/schema-tests-3.2/components-object-example' +import { doc as exampleObjectExamples } from '../../../../types/tests/schema-tests-3.2/example-object-examples' +import { doc as headerObjectExamples } from '../../../../types/tests/schema-tests-3.2/header-object-examples' +import { doc as infoObjectExample } from '../../../../types/tests/schema-tests-3.2/info-object-example' +import { doc as infoSummary } from '../../../../types/tests/schema-tests-3.2/info-summary' +import { doc as jsonSchemaDialect } from '../../../../types/tests/schema-tests-3.2/json-schema-dialect' +import { doc as licenseIdentifier } from '../../../../types/tests/schema-tests-3.2/license-identifier' +import { doc as linkObjectExamples } from '../../../../types/tests/schema-tests-3.2/link-object-examples' +import { doc as mediaTypeExamples } from '../../../../types/tests/schema-tests-3.2/media-type-examples' +import { doc as mega } from '../../../../types/tests/schema-tests-3.2/mega' +import { doc as minimalComp } from '../../../../types/tests/schema-tests-3.2/minimal-comp' +import { doc as minimalHooks } from '../../../../types/tests/schema-tests-3.2/minimal-hooks' +import { doc as minimalPaths } from '../../../../types/tests/schema-tests-3.2/minimal-paths' +import { doc as nonOauthScopes } from '../../../../types/tests/schema-tests-3.2/non-oauth-scopes' +import { doc as operationObjectExample } from '../../../../types/tests/schema-tests-3.2/operation-object-example' +import { doc as parameterObjectCookieFormAllowReserved } from '../../../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved' +import { doc as parameterObjectExamples } from '../../../../types/tests/schema-tests-3.2/parameter-object-examples' +import { doc as parameterObjectPathAllowReserved } from '../../../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved' +import { doc as parameterObjectQueryAllowReserved } from '../../../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved' +import { doc as pathItemObjectExample } from '../../../../types/tests/schema-tests-3.2/path-item-object-example' +import { doc as pathItemServersParameters } from '../../../../types/tests/schema-tests-3.2/path-item-servers-parameters' +import { doc as pathNoResponse } from '../../../../types/tests/schema-tests-3.2/path-no-response' +import { doc as pathVarEmptyPathitem } from '../../../../types/tests/schema-tests-3.2/path-var-empty-pathitem' +import { doc as pathsObjectExample } from '../../../../types/tests/schema-tests-3.2/paths-object-example' +import { doc as requestBodyExamples } from '../../../../types/tests/schema-tests-3.2/request-body-examples' +import { doc as responseObjectExamples } from '../../../../types/tests/schema-tests-3.2/response-object-examples' +import { doc as schema } from '../../../../types/tests/schema-tests-3.2/schema' +import { doc as schemaObjectDeprecatedExampleKeyword } from '../../../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword' +import { doc as servers } from '../../../../types/tests/schema-tests-3.2/servers' +import { doc as specificationExtensions } from '../../../../types/tests/schema-tests-3.2/specification-extensions' +import { doc as styleDefaults } from '../../../../types/tests/schema-tests-3.2/style-defaults' +import { doc as tagObjectExample } from '../../../../types/tests/schema-tests-3.2/tag-object-example' +import { doc as validSchemaTypes } from '../../../../types/tests/schema-tests-3.2/valid-schema-types' +import { doc as webhookExample } from '../../../../types/tests/schema-tests-3.2/webhook-example' +import { expectNoNewDanglingRefs, expectValidAs } from '../../helpers' + +// Left out: security-scheme-object-examples, whose external `$ref` the +// validator cannot resolve. +const corpus: readonly (readonly [name: string, doc: OpenAPIV3_2.OpenAPIObject])[] = [ + ['examples/3-2-query-example', queryExample], + ['examples/3-2-tags-example', tagsExample], + ['callback-object-examples', callbackObjectExamples], + ['comp-pathitems', compPathitems], + ['components-object-example', componentsObjectExample], + ['example-object-examples', exampleObjectExamples], + ['header-object-examples', headerObjectExamples], + ['info-object-example', infoObjectExample], + ['info-summary', infoSummary], + ['json-schema-dialect', jsonSchemaDialect], + ['license-identifier', licenseIdentifier], + ['link-object-examples', linkObjectExamples], + ['media-type-examples', mediaTypeExamples], + ['mega', mega], + ['minimal-comp', minimalComp], + ['minimal-hooks', minimalHooks], + ['minimal-paths', minimalPaths], + ['non-oauth-scopes', nonOauthScopes], + ['operation-object-example', operationObjectExample], + ['parameter-object-cookie-form-allow-reserved', parameterObjectCookieFormAllowReserved], + ['parameter-object-examples', parameterObjectExamples], + ['parameter-object-path-allow-reserved', parameterObjectPathAllowReserved], + ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReserved], + ['path-item-object-example', pathItemObjectExample], + ['path-item-servers-parameters', pathItemServersParameters], + ['path-no-response', pathNoResponse], + ['path-var-empty-pathitem', pathVarEmptyPathitem], + ['paths-object-example', pathsObjectExample], + ['request-body-examples', requestBodyExamples], + ['response-object-examples', responseObjectExamples], + ['schema', schema], + ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeyword], + ['servers', servers], + ['specification-extensions', specificationExtensions], + ['style-defaults', styleDefaults], + ['tag-object-example', tagObjectExample], + ['valid-schema-types', validSchemaTypes], + ['webhook-example', webhookExample], +] + +describe('official corpus', () => { + it.each(corpus)('converts %s to a valid 3.1 document without new dangling references or mutating the input', async (_name, doc) => { + await expectValidAs(doc, '3.2') + const before = structuredClone(doc) + const v31 = downgradeSpecV32ToV31(doc) + expect(v31.openapi).toBe('3.1.2') + await expectValidAs(v31, '3.1') + expectNoNewDanglingRefs(doc, v31) + expect(doc).toEqual(before) + }) +}) + +describe('official examples', () => { + it('removes the QUERY operation of the query example, leaving an empty path item', () => { + const v31 = downgradeSpecV32ToV31(queryExample) + expect(v31.paths?.['/flights/search']).toEqual({}) + expect(v31).toMatchSnapshot() + }) + + it('flattens the tag hierarchy of the tags example', () => { + const v31 = downgradeSpecV32ToV31(tagsExample) + expect(v31.tags).toEqual([ + { description: 'Core flight operations', name: 'flights' }, + { description: 'Flights that cross country borders', name: 'international' }, + { description: 'Flights within a single country', name: 'domestic' }, + { + description: 'Information about flight delays', + externalDocs: { description: 'Delay compensation policies', url: 'https://docs.example.com/delay-policies' }, + name: 'delays', + }, + ]) + expect(v31).toMatchSnapshot() + }) + + it('removes the discriminator defaultMapping of the mega document', () => { + const v31 = downgradeSpecV32ToV31(mega) + const discriminator = ['components', 'pathItems', 'myPathItem', 'post', 'requestBody', 'content', 'application/json', 'schema', 'discriminator'] + expect(v31).not.toHaveProperty([...discriminator, 'defaultMapping']) + expect(v31).toHaveProperty([...discriminator, 'propertyName'], 'type') + expect(v31).toMatchSnapshot() + }) +}) + +describe('kitchen sink', () => { + it('converts every 3.2-only construct into a valid 3.1 document', async () => { + const kitchenSink: OpenAPIV3_2.OpenAPIObject = { + $self: 'https://api.example.com/openapi.json', + components: { + mediaTypes: { JsonPayload: { schema: { items: { type: 'string' }, type: 'array' } } }, + parameters: { + filter: { + content: { 'application/json': { schema: { properties: { term: { type: 'string' } }, type: 'object' } } }, + in: 'querystring', + name: 'filter', + }, + page: { in: 'query', name: 'page', schema: { type: 'integer' } }, + }, + securitySchemes: { + deviceAuth: { + deprecated: true, + flows: { + deviceAuthorization: { + deviceAuthorizationUrl: 'https://auth.example.com/device', + scopes: { 'events:read': 'Read events' }, + tokenUrl: 'https://auth.example.com/token', + }, + }, + oauth2MetadataUrl: 'https://auth.example.com/.well-known/oauth', + type: 'oauth2', + }, + }, + }, + info: { title: 'Kitchen Sink', version: '1.0.0' }, + openapi: '3.2.0', + paths: { + '/events': { + get: { + operationId: 'streamEvents', + responses: { + 200: { + content: { + 'application/json': { itemSchema: { type: 'object' }, schema: { items: { type: 'object' }, type: 'array' } }, + 'application/jsonl': { itemSchema: { properties: { kind: { type: 'string' } }, type: 'object' } }, + }, + summary: 'Event stream', + }, + 204: {}, + }, + }, + }, + '/search': { + get: { + operationId: 'searchEvents', + parameters: [ + { + content: { 'application/json': { schema: { properties: { term: { type: 'string' } }, type: 'object' } } }, + in: 'querystring', + name: 'filter', + }, + { + examples: { + kept: { serializedValue: 'sid=1', value: 'sid=1' }, + linked: { dataValue: { sid: 2 }, externalValue: 'https://example.com/session.json' }, + promoted: { dataValue: 'sid=3' }, + }, + in: 'cookie', + name: 'session', + schema: { type: 'string' }, + style: 'cookie', + }, + ], + responses: { + 200: { + content: { 'application/json': { $ref: '#/components/mediaTypes/JsonPayload' } }, + description: 'Search results', + summary: 'Results', + }, + }, + }, + }, + }, + security: [{ deviceAuth: ['events:read'] }], + servers: [{ name: 'production', url: 'https://api.example.com' }], + } + const before = structuredClone(kitchenSink) + const v31 = downgradeSpecV32ToV31(kitchenSink) + expect(v31).toEqual({ + components: { + parameters: { page: { in: 'query', name: 'page', schema: { type: 'integer' } } }, + securitySchemes: { deviceAuth: { flows: {}, type: 'oauth2' } }, + }, + info: { title: 'Kitchen Sink', version: '1.0.0' }, + openapi: '3.1.2', + paths: { + '/events': { + get: { + operationId: 'streamEvents', + responses: { + 200: { + content: { + 'application/json': { schema: { items: { type: 'object' }, type: 'array' } }, + 'application/jsonl': { schema: { items: { properties: { kind: { type: 'string' } }, type: 'object' }, type: 'array' } }, + }, + description: 'Event stream', + }, + 204: { description: '' }, + }, + }, + }, + '/search': { + get: { + operationId: 'searchEvents', + parameters: [ + { + examples: { + kept: { value: 'sid=1' }, + linked: { externalValue: 'https://example.com/session.json' }, + promoted: { value: 'sid=3' }, + }, + in: 'cookie', + name: 'session', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + content: { 'application/json': { schema: { items: { type: 'string' }, type: 'array' } } }, + description: 'Search results', + }, + }, + }, + }, + }, + security: [{ deviceAuth: ['events:read'] }], + servers: [{ url: 'https://api.example.com' }], + }) + await expectValidAs(v31, '3.1') + expect(kitchenSink).toEqual(before) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts new file mode 100644 index 0000000..bced2e2 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts @@ -0,0 +1,70 @@ +import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { convertSpec } from './helpers' + +describe('openapi', () => { + it('stamps 3.1.2, the latest 3.1 patch release', () => { + expect(downgradeSpecV32ToV31({ info: { title: 't', version: '1.0.0' }, openapi: '3.2.0' })).toEqual({ + info: { title: 't', version: '1.0.0' }, + openapi: '3.1.2', + }) + }) + + it('adds the version when the input has none', () => { + expect(downgradeSpecV32ToV31({} as any)).toEqual({ openapi: '3.1.2' }) + }) +}) + +describe('$self', () => { + // `$self` gives the document its own URI, which then serves as the base URI + // for its relative references: https://spec.openapis.org/oas/v3.2.0.html#oas-self + // 3.1 has no such field, so it is removed. References that relied on it + // are left as written (a known limitation listed in the README). + it('removes $self', () => { + expect(convertSpec({ $self: 'https://example.com/api.json' })).toEqual({ openapi: '3.1.2' }) + }) +}) + +describe('jsonSchemaDialect', () => { + // `jsonSchemaDialect` sets the default `$schema` of every Schema Object: + // https://spec.openapis.org/oas/v3.1.2.html#oas-json-schema-dialect + // 3.2 publishes its own OAS dialects under https://spec.openapis.org/oas/3.2/dialect/, + // whose vocabulary knows 3.2-only keywords such as `xml.nodeType`. A 3.1 + // document instead uses the 3.1 OAS dialect schema id: + // https://spec.openapis.org/oas/v3.1.2.html#dialect-schema-id + it.each([ + ['rewrites the dated 3.2 OAS dialect', 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17', 'https://spec.openapis.org/oas/3.1/dialect/base'], + ['rewrites a draft 3.2 OAS dialect', 'https://spec.openapis.org/oas/3.2/dialect/WORK-IN-PROGRESS', 'https://spec.openapis.org/oas/3.1/dialect/base'], + ['keeps a 3.1 OAS dialect', 'https://spec.openapis.org/oas/3.1/dialect/base', 'https://spec.openapis.org/oas/3.1/dialect/base'], + ['keeps a custom dialect', 'https://example.com/my-dialect', 'https://example.com/my-dialect'], + ['clones a malformed value through', { junk: true }, { junk: true }], + ])('%s', (_name, dialect, expected) => { + expect(convertSpec({ jsonSchemaDialect: dialect })).toEqual({ jsonSchemaDialect: expected, openapi: '3.1.2' }) + }) +}) + +describe('document shape', () => { + it('returns non-object input unchanged', () => { + expect(downgradeSpecV32ToV31(null as any)).toBeNull() + expect(downgradeSpecV32ToV31('junk' as any)).toBe('junk') + expect(downgradeSpecV32ToV31([1, 2] as any)).toEqual([1, 2]) + }) + + it('keeps extensions and unknown keys at the document, path item, and operation levels', () => { + const fields = { + 'futureKey': { anything: [1] }, + 'info': { title: 't', version: '1' }, + 'jsonSchemaDialect': 'https://spec.openapis.org/oas/3.1/dialect/base', + 'paths': { + '/a': { + 'get': { 'operationId': 'getA', 'responses': {}, 'unknownOperationKey': 1, 'x-op': true }, + 'unknownPathItemKey': 'kept', + 'x-item': [1, 2], + }, + }, + 'security': [{ oauth: ['read'] }], + 'x-root': { deep: { value: 1 } }, + } + expect(convertSpec(fields)).toEqual({ ...fields, openapi: '3.1.2' }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts new file mode 100644 index 0000000..416a4c7 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts @@ -0,0 +1,30 @@ +import { convertComponent } from './helpers' + +// 3.2 splits an example value into `dataValue` (the data, as the schema sees +// it) and `serializedValue` (the bytes on the wire): +// https://spec.openapis.org/oas/v3.2.0.html#example-data-value +// https://spec.openapis.org/oas/v3.2.0.html#example-serialized-value +// 3.1 has only `value` and `externalValue`, which are mutually exclusive: +// https://spec.openapis.org/oas/v3.1.2.html#example-value +// The data form wins the free `value` slot because it is what 3.1 `value` +// holds for JSON-like media types. When `value` or `externalValue` is +// already set, the new fields are simply removed. +describe('dataValue and serializedValue', () => { + it.each([ + ['moves dataValue into the free value slot', { dataValue: { a: 1 } }, { value: { a: 1 } }], + ['moves serializedValue into the free value slot', { serializedValue: 'a=1' }, { value: 'a=1' }], + ['lets dataValue win the value slot over serializedValue', { dataValue: 1, serializedValue: 's' }, { value: 1 }], + ['removes dataValue when value already exists', { dataValue: 1, value: 2 }, { value: 2 }], + ['removes serializedValue when value already exists', { serializedValue: 's', value: 2 }, { value: 2 }], + [ + 'removes both when externalValue exists', + { dataValue: 1, externalValue: 'https://example.com/e.json', serializedValue: 's' }, + { externalValue: 'https://example.com/e.json' }, + ], + ['keeps the other example fields', { dataValue: 1, description: 'd', summary: 's' }, { description: 'd', summary: 's', value: 1 }], + ['leaves an example without value fields unchanged', { summary: 's' }, { summary: 's' }], + ['clones a malformed example through', 42, 42], + ])('%s', (_name, example, expected) => { + expect(convertComponent('examples', example)).toEqual(expected) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts new file mode 100644 index 0000000..429f14b --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts @@ -0,0 +1,34 @@ +import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' + +import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { dig } from '../../helpers' + +/** + * Converts a 3.2 document built from `fields`. The input is typed loosely on + * purpose: many tests feed partial or malformed documents to check that the + * conversion tolerates them. + */ +export function convertSpec(fields: Record): OpenAPIV3_1.OpenAPIObject { + return downgradeSpecV32ToV31({ openapi: '3.2.0', ...fields } as any) +} + +/** Converts `pathItem` as the only entry of `paths` and returns it. */ +export function convertPathItem(pathItem: unknown, components: Record = {}): unknown { + return dig(convertSpec({ components, paths: { '/a': pathItem } }), 'paths', '/a') +} + +/** Converts `value` as the entry `X` of the `kind` component map and returns it. */ +export function convertComponent(kind: string, value: unknown, components: Record = {}): unknown { + return dig(convertSpec({ components: { ...components, [kind]: { X: value } } }), 'components', kind, 'X') +} + +/** Converts `content` as the content map of a request body and returns it. */ +export function convertContent(content: unknown, components: Record = {}): unknown { + return dig( + convertPathItem({ post: { requestBody: { content }, responses: {} } }, components), + 'post', + 'requestBody', + 'content', + ) +} diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts new file mode 100644 index 0000000..89b6fc1 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts @@ -0,0 +1,199 @@ +// A document is usually parsed JSON or YAML, but it can also come from a +// dereferencing tool that turns every `$ref` into a shared JavaScript object, +// possibly with cycles. These tests pin down how the conversion treats the +// input as an object graph rather than as text. + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { dig } from '../../helpers' +import { convertPathItem, convertSpec } from './helpers' + +describe('the input document', () => { + it('is never mutated', () => { + const spec = { + $self: 'https://example.com/api.json', + components: { + examples: { E: { dataValue: 1, serializedValue: 's' } }, + mediaTypes: { A: { $ref: '#/components/mediaTypes/B' }, B: { itemSchema: { xml: { nodeType: 'text' } } } }, + pathItems: { P: { query: { description: 'q' } } }, + schemas: { + R: { $ref: '#/components/mediaTypes/B/itemSchema', description: 'r' }, + S: { discriminator: { defaultMapping: 'Dog' } }, + }, + securitySchemes: { O: { deprecated: true, type: 'oauth2' } }, + }, + openapi: '3.2.0', + paths: { + '/a': { + additionalOperations: { NOTIFY: { description: 'n' } }, + get: { + parameters: [{ in: 'querystring', name: 'q' }], + requestBody: { content: { 'application/json': { $ref: '#/components/mediaTypes/A' } } }, + responses: { 200: { summary: 'ok' } }, + }, + query: { description: 'q' }, + servers: [{ name: 's', url: '/u' }], + }, + }, + servers: [{ name: 'root', url: 'https://example.com' }], + tags: [{ kind: 'nav', name: 't', parent: 'p', summary: 's' }], + webhooks: { hook: { query: { description: 'wq' } } }, + } + const before = structuredClone(spec) + downgradeSpecV32ToV31(spec as any) + expect(spec).toEqual(before) + }) + + it('returns a fresh copy on every call', () => { + const spec: OpenAPIV3_2.OpenAPIObject = { info: { title: 't', version: '1' }, openapi: '3.2.0', paths: {} } + const first = downgradeSpecV32ToV31(spec) + const second = downgradeSpecV32ToV31(spec) + expect(first).toEqual(second) + expect(first).not.toBe(second) + expect(first.info).not.toBe(spec.info) + }) + + // Some parsers build objects without a prototype so that keys such as + // `__proto__` or `constructor` cannot collide with Object.prototype. + it('accepts objects with a null prototype, as some parsers produce', () => { + const response = Object.assign(Object.create(null), { summary: 'ok' }) + const operation = Object.assign(Object.create(null), { responses: { 200: response } }) + expect(convertPathItem({ get: operation, query: {} })).toEqual({ + get: { responses: { 200: { description: 'ok' } } }, + }) + }) + + // Values such as a Date or a Map cannot come from JSON or YAML. They are + // not walked into, and are kept as the same instance. + it('keeps values that are not plain objects or arrays by reference', () => { + const date = new Date(0) + const values = new Map([['a', 1]]) + const result = convertSpec({ 'x-date': date, 'x-values': values }) + expect(dig(result, 'x-date')).toBe(date) + expect(dig(result, 'x-values')).toBe(values) + }) +}) + +describe('keys', () => { + it('keeps the key order of the input', () => { + const result = convertSpec({ 'zebra': 1, 'x-apple': 2, 'info': { version: '1', title: 't' }, 'paths': {} }) + expect(Object.keys(result)).toEqual(['openapi', 'zebra', 'x-apple', 'info', 'paths']) + expect(Object.keys(result.info)).toEqual(['version', 'title']) + }) + + // JSON.parse creates a real own `__proto__` key. Assigning it with `=` + // would instead replace the prototype of the output object. + it('copies a __proto__ key as a plain own property without polluting prototypes', () => { + const spec = JSON.parse('{"openapi":"3.2.0","x-data":{"__proto__":{"polluted":true}},"components":{"schemas":{"__proto__":{"xml":{"nodeType":"attribute"}}}}}') + const result = downgradeSpecV32ToV31(spec) + const data = dig(result, 'x-data') as object + const schemas = dig(result, 'components', 'schemas') as object + expect(Object.getPrototypeOf(data)).toBe(Object.prototype) + expect(Object.getOwnPropertyDescriptor(data, '__proto__')?.value).toEqual({ polluted: true }) + expect(Object.getOwnPropertyDescriptor(schemas, '__proto__')?.value).toEqual({ xml: { attribute: true } }) + expect('polluted' in {}).toBe(false) + }) + + it('treats keys named like Object.prototype members as ordinary keys', () => { + const spec = JSON.parse('{"openapi":"3.2.0","constructor":1,"toString":2,"components":{"hasOwnProperty":{"a":1}}}') + const result = downgradeSpecV32ToV31(spec) + expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) + expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(2) + expect(dig(result, 'components', 'hasOwnProperty')).toEqual({ a: 1 }) + }) +}) + +describe('shared objects and cycles', () => { + it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { + const callback: Record = {} + const pathItem: Record = { + get: { callbacks: { cb: callback }, responses: { 200: { summary: 'ok' } } }, + query: { description: 'q' }, + } + callback.expr = pathItem + const result = convertPathItem(pathItem) + expect(result).not.toHaveProperty('query') + expect(dig(result, 'get', 'responses', '200')).toEqual({ description: 'ok' }) + expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) + expect(callback.expr).toBe(pathItem) + }) + + it('copies a dereferenced schema shared across the document once', () => { + const pet = { $anchor: 'pet', $id: 'https://example.com/pet', properties: { name: { type: 'string' } }, type: 'object' } + const result = convertSpec({ + components: { schemas: { Pet: pet } }, + paths: { '/pets': { get: { responses: { 200: { content: { 'application/json': { schema: pet } }, description: 'ok' } } } } }, + }) + const schema = dig(result, 'components', 'schemas', 'Pet') + expect(schema).toEqual(pet) + expect(schema).not.toBe(pet) + expect(dig(result, 'paths', '/pets', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toBe(schema) + }) + + // A diamond (two properties sharing one subschema) doubles the number of + // paths per level: 2^40 here. Converting each shared object once keeps the + // work linear. + it('converts a deep shared schema diamond once, even when references force a second pass', () => { + let schema: Record = { type: 'string' } + for (let depth = 0; depth < 40; depth++) { + schema = { properties: { a: schema, b: schema }, type: 'object' } + } + const result = convertSpec({ + components: { + mediaTypes: { Gone: { schema: {} } }, + schemas: { Dangling: { $ref: '#/components/mediaTypes/Gone/schema' }, Root: schema }, + }, + }) + const root = dig(result, 'components', 'schemas', 'Root') + expect(dig(root, 'properties', 'a')).toBe(dig(root, 'properties', 'b')) + }) + + it('keeps cycles and sharing inside values it only copies', () => { + const node: Record = { name: 'root' } + node.self = node + const list: unknown[] = [1] + list.push(list) + const result = convertSpec({ 'x-list': list, 'x-node': node, 'x-same': node }) + expect(dig(result, 'x-node', 'self')).toBe(dig(result, 'x-node')) + expect(dig(result, 'x-same')).toBe(dig(result, 'x-node')) + expect(dig(result, 'x-list', '1')).toBe(dig(result, 'x-list')) + expect(dig(result, 'x-node')).not.toBe(node) + }) + + // `#/components/mediaTypes/%50et` and `#/components/mediaTypes/Pet` name + // the same target once percent-decoded, so they share one converted copy. + it('converts a target inlined from several places once, however its pointer is spelled', () => { + const content = dig(convertPathItem( + { + get: { + responses: { + 200: { content: { 'a/b': { $ref: '#/components/mediaTypes/Pet' } }, description: 'ok' }, + 201: { content: { 'a/b': { $ref: '#/components/mediaTypes/%50et' } }, description: 'ok' }, + }, + }, + }, + { mediaTypes: { Pet: { schema: { type: 'string' } } } }, + ), 'get', 'responses') as Record + expect(dig(content, '200', 'content', 'a/b')).toEqual({ schema: { type: 'string' } }) + expect(dig(content, '201', 'content', 'a/b')).toBe(dig(content, '200', 'content', 'a/b')) + }) +}) + +describe('long reference chains', () => { + // Chains are followed with loops rather than recursion, so their length is + // not bounded by the call stack. + it('removes thousands of aliases chained to a removed parameter', () => { + const parameters: Record = { p0: { in: 'querystring', name: 'q' } } + for (let index = 1; index <= 5000; index++) { + parameters[`p${index}`] = { $ref: `#/components/parameters/p${index - 1}` } + } + const result = convertSpec({ + components: { parameters }, + paths: { '/a': { get: { parameters: [{ $ref: '#/components/parameters/p5000' }], responses: {} } } }, + }) + expect(result.components).toEqual({ parameters: {} }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([]) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts new file mode 100644 index 0000000..1be3891 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts @@ -0,0 +1,86 @@ +import { dig } from '../../helpers' +import { convertContent } from './helpers' + +describe('itemSchema', () => { + // 3.2 adds `itemSchema` to describe each item of a sequential media type + // such as `application/jsonl` or `text/event-stream`: + // https://spec.openapis.org/oas/v3.2.0.html#media-type-item-schema + // https://spec.openapis.org/oas/v3.2.0.html#sequential-media-types + // 3.1 can only describe the complete content, and the closest description + // of a sequence of items is an array of them. + it('turns itemSchema into a deep-cloned array schema when no schema exists', () => { + const itemSchema = { type: 'object', xml: { nodeType: 'text' } } + const result = convertContent({ 'application/jsonl': { itemSchema } }) + expect(result).toEqual({ + 'application/jsonl': { schema: { items: { type: 'object', xml: {} }, type: 'array' } }, + }) + const promoted = dig(result, 'application/jsonl', 'schema', 'items') + expect(promoted).not.toBe(itemSchema) + expect(dig(promoted, 'xml')).not.toBe(itemSchema.xml) + }) + + it('removes itemSchema when a schema already describes the complete content', () => { + expect(convertContent({ 'application/json': { itemSchema: { type: 'string' }, schema: { type: 'array' } } })).toEqual({ + 'application/json': { schema: { type: 'array' } }, + }) + }) +}) + +describe('media type fields 3.1 lacks', () => { + // `prefixEncoding` and `itemEncoding` encode multipart parts by position: + // https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position + // 3.1 only encodes parts by property name, through `encoding`. + // + // `description` is missing from the 3.2.0 Fixed Fields table but is defined + // by the official 3.2 JSON Schema (`$defs/media-type`): https://github.com/OAI/OpenAPI-Specification/pull/4728 + it.each([ + ['removes prefixEncoding and itemEncoding', { example: 1, itemEncoding: { contentType: 'text/plain' }, prefixEncoding: [{ contentType: 'application/json' }] }, { example: 1 }], + ['removes description and keeps the other fields', { description: 'a JSON payload', example: 5, schema: { type: 'integer' } }, { example: 5, schema: { type: 'integer' } }], + ])('%s', (_name, mediaType, expected) => { + expect(convertContent({ 'application/json': mediaType })).toEqual({ 'application/json': expected }) + }) + + it('converts the example map', () => { + expect(convertContent({ + 'application/json': { examples: { inline: { serializedValue: 'raw' }, referenced: { $ref: '#/components/examples/E' } } }, + })).toEqual({ + 'application/json': { examples: { inline: { value: 'raw' }, referenced: { $ref: '#/components/examples/E' } } }, + }) + }) +}) + +describe('encoding objects', () => { + // 3.2 Encoding Objects can nest `encoding`, `prefixEncoding`, and + // `itemEncoding` for multipart parts that are themselves multipart: + // https://spec.openapis.org/oas/v3.2.0.html#nested-encoding + it('removes nested and positional encodings while still converting headers', () => { + expect(convertContent({ + 'multipart/form-data': { + encoding: { + part: { + contentType: 'application/json', + encoding: { inner: { headers: { 'X-C': { style: 'cookie' } } } }, + headers: { + 'Referenced': { $ref: '#/components/headers/H' }, + 'X-H': { description: 'h', style: 'cookie' }, + }, + itemEncoding: { contentType: 'text/plain' }, + prefixEncoding: [{ contentType: 'text/csv' }], + }, + }, + }, + })).toEqual({ + 'multipart/form-data': { + encoding: { + part: { + contentType: 'application/json', + headers: { + 'Referenced': { $ref: '#/components/headers/H' }, + 'X-H': { description: 'h' }, + }, + }, + }, + }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts new file mode 100644 index 0000000..a641ccb --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts @@ -0,0 +1,179 @@ +import { dig } from '../../helpers' +import { convertComponent, convertPathItem, convertSpec } from './helpers' + +describe('in: querystring', () => { + // 3.2 adds `in: "querystring"` to describe the whole query string with one + // `content` schema: https://spec.openapis.org/oas/v3.2.0.html#parameter-in + // 3.1 has no such location, and no query parameter can stand in for it, + // so the parameter is removed together with every reference to it. + it('removes querystring parameters from operation and path item lists, keeping neighbors and references', () => { + expect(convertPathItem({ + get: { + parameters: [ + { content: { 'application/x-www-form-urlencoded': {} }, in: 'querystring', name: 'q' }, + { in: 'query', name: 'keep' }, + { $ref: '#/components/parameters/P' }, + ], + responses: {}, + }, + parameters: [ + { in: 'querystring', name: 'q' }, + { in: 'path', name: 'id', required: true }, + ], + })).toEqual({ + get: { + parameters: [{ in: 'query', name: 'keep' }, { $ref: '#/components/parameters/P' }], + responses: {}, + }, + parameters: [{ in: 'path', name: 'id', required: true }], + }) + }) + + it('removes querystring entries from components.parameters, keeping neighbors and references', () => { + expect(convertSpec({ + components: { + parameters: { + N: { in: 'header', name: 'h' }, + Q: { in: 'querystring', name: 'q' }, + R: { $ref: '#/components/parameters/N' }, + }, + }, + }).components).toEqual({ + parameters: { + N: { in: 'header', name: 'h' }, + R: { $ref: '#/components/parameters/N' }, + }, + }) + }) + + // A Reference Object to a removed parameter would dangle, so it goes too. + // So does a chain of aliases (`$ref` to a `$ref`) that ends at one. + it('removes references to removed querystring parameters, following alias chains', () => { + const result = convertSpec({ + components: { + parameters: { + Alias: { $ref: '#/components/parameters/Qs' }, + AliasOfAlias: { $ref: '#/components/parameters/Alias' }, + Keep: { in: 'query', name: 'k', schema: {} }, + Qs: { content: { 'application/x-www-form-urlencoded': { schema: {} } }, in: 'querystring', name: 'filter' }, + }, + }, + paths: { + '/a': { + get: { + parameters: [ + { $ref: '#/components/parameters/AliasOfAlias' }, + { $ref: '#/components/parameters/Qs' }, + { $ref: '#/components/parameters/Keep' }, + ], + responses: {}, + }, + parameters: [{ $ref: '#/components/parameters/Qs' }], + }, + }, + }) + expect(result.components).toEqual({ parameters: { Keep: { in: 'query', name: 'k', schema: {} } } }) + expect(result.paths).toEqual({ + '/a': { + get: { parameters: [{ $ref: '#/components/parameters/Keep' }], responses: {} }, + parameters: [], + }, + }) + }) + + it('removes a reference to a querystring parameter through any pointer', () => { + expect(convertSpec({ + components: { parameters: { P: { $ref: '#/paths/~1a/get/parameters/0' } } }, + paths: { '/a': { get: { parameters: [{ in: 'querystring', name: 'qs' }], responses: {} } } }, + }).components).toEqual({ parameters: {} }) + }) +}) + +describe('style and allowReserved', () => { + // `style: "cookie"` is new in 3.2: https://spec.openapis.org/oas/v3.2.0.html#style-values + // Without it, a cookie parameter falls back to the 3.1 default for cookies, + // `form`: https://spec.openapis.org/oas/v3.1.2.html#parameter-style + // + // 3.1 defines `allowReserved` for query parameters only + // (https://spec.openapis.org/oas/v3.1.2.html#parameter-allow-reserved), + // while 3.2 extends it to every location that percent-encodes + // (https://spec.openapis.org/oas/v3.2.0.html#parameter-allow-reserved). + it.each([ + ['removes style: cookie and keeps the other fields', { in: 'cookie', name: 'c', style: 'cookie' }, { in: 'cookie', name: 'c' }], + ['keeps other style values', { in: 'query', name: 'q', style: 'deepObject' }, { in: 'query', name: 'q', style: 'deepObject' }], + ['keeps allowReserved on query parameters', { allowReserved: true, in: 'query', name: 'q', schema: {} }, { allowReserved: true, in: 'query', name: 'q', schema: {} }], + ['removes allowReserved on path parameters', { allowReserved: true, in: 'path', name: 'id', required: true, schema: {} }, { in: 'path', name: 'id', required: true, schema: {} }], + ['removes allowReserved on cookie parameters', { allowReserved: true, in: 'cookie', name: 'c', schema: {} }, { in: 'cookie', name: 'c', schema: {} }], + ['keeps allowReserved when there is no in to judge by', { allowReserved: true, schema: {} }, { allowReserved: true, schema: {} }], + ])('%s', (_name, input, expected) => { + expect(convertComponent('parameters', input)).toEqual(expected) + }) + + it('removes style: cookie from headers wherever they appear', () => { + expect(convertComponent('responses', { + description: 'ok', + headers: { 'X-H': { description: 'h', style: 'cookie' } }, + })).toEqual({ description: 'ok', headers: { 'X-H': { description: 'h' } } }) + }) +}) + +describe('schemas and examples', () => { + it('converts the parameter schema and its example map', () => { + expect(convertComponent('parameters', { + examples: { inline: { dataValue: 1 }, referenced: { $ref: '#/components/examples/E' } }, + in: 'query', + name: 'q', + schema: { type: 'string', xml: { nodeType: 'attribute' } }, + })).toEqual({ + examples: { inline: { value: 1 }, referenced: { $ref: '#/components/examples/E' } }, + in: 'query', + name: 'q', + schema: { type: 'string', xml: { attribute: true } }, + }) + }) + + // 3.2 lists `example` and `examples` among the fields that MAY be used with + // either `schema` or `content`: https://spec.openapis.org/oas/v3.2.0.html#parameter-example + // 3.1 lists them only among the fields for use with `schema` + // (https://spec.openapis.org/oas/v3.1.2.html#parameter-example), and its + // official JSON Schema rejects them beside `content`. The media type inside + // `content` can carry its own examples instead. + it('removes parameter and header examples beside content', () => { + const content = { 'a/b': { schema: { type: 'object' } } } + const operation = dig(convertSpec({ + paths: { + '/a': { + get: { + parameters: [ + { content, example: { a: 1 }, in: 'query', name: 'moved' }, + { content: { 'a/b': { example: 'own' } }, examples: { e: { dataValue: 1 } }, in: 'query', name: 'kept' }, + { content: { 'a/b': {}, 'c/d': {} }, example: 1, in: 'query', name: 'many' }, + { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, + ], + responses: { 200: { description: 'ok', headers: { X: { content, examples: { e: { dataValue: 2 } } } } } }, + }, + }, + }, + }), 'paths', '/a', 'get') + expect(dig(operation, 'parameters')).toEqual([ + { content, in: 'query', name: 'moved' }, + { content: { 'a/b': { example: 'own' } }, in: 'query', name: 'kept' }, + { content: { 'a/b': {}, 'c/d': {} }, in: 'query', name: 'many' }, + { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, + ]) + expect(dig(operation, 'responses', '200', 'headers', 'X')).toEqual({ content }) + }) + + it('keeps a parameter whose content map was already empty', () => { + const parameter = { content: {}, in: 'query', name: 'q' } + expect(dig(convertPathItem({ get: { parameters: [parameter] } }), 'get', 'parameters')).toEqual([parameter]) + }) +}) + +describe('malformed input', () => { + it('clones non-object parameter entries, non-array lists, and a malformed components map through', () => { + expect(convertPathItem({ parameters: [null, 'junk'] })).toEqual({ parameters: [null, 'junk'] }) + expect(convertPathItem({ parameters: 'junk' })).toEqual({ parameters: 'junk' }) + expect(convertSpec({ components: { parameters: 'junk' } }).components).toEqual({ parameters: 'junk' }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts new file mode 100644 index 0000000..ce4a9e6 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts @@ -0,0 +1,122 @@ +import { convertComponent, convertPathItem, convertSpec } from './helpers' + +describe('operations 3.1 cannot hold', () => { + // 3.2 adds the QUERY method and `additionalOperations` for any other method: + // https://spec.openapis.org/oas/v3.2.0.html#path-item-query + // https://spec.openapis.org/oas/v3.2.0.html#path-item-additional-operations + // A 3.1 Path Item only has fixed fields for the eight classic methods, and + // an extension would not describe a callable operation either, so both are + // removed rather than moved. + it('removes the query operation and additionalOperations whatever their shape', () => { + expect(convertSpec({ + paths: { + '/a': { get: { responses: {} }, query: { description: 'q', responses: {} } }, + '/b': { additionalOperations: { NOTIFY: { description: 'n' } } }, + '/c': { additionalOperations: 'junk' }, + '/d': { additionalOperations: 42, query: 'junk' }, + }, + }).paths).toEqual({ + '/a': { get: { responses: {} } }, + '/b': {}, + '/c': {}, + '/d': {}, + }) + }) + + it('removes them from webhooks and components.pathItems too', () => { + expect(convertSpec({ + components: { pathItems: { P: { get: { responses: {} }, query: { description: 'q' } } } }, + webhooks: { newPet: { post: { responses: { 200: { summary: 'ok' } } }, query: { description: 'q' } } }, + })).toEqual({ + components: { pathItems: { P: { get: { responses: {} } } } }, + openapi: '3.1.2', + webhooks: { newPet: { post: { responses: { 200: { description: 'ok' } } } } }, + }) + }) + + it('removes them from path items inside callbacks', () => { + expect(convertComponent('callbacks', { + 'https://example.com/cb': { post: { responses: { 200: { summary: 'ok' } } }, query: { description: 'q' } }, + })).toEqual({ + 'https://example.com/cb': { post: { responses: { 200: { description: 'ok' } } } }, + }) + }) +}) + +describe('paths', () => { + // Paths Object keys are templates that start with a slash; any other key + // can only be a specification extension: https://spec.openapis.org/oas/v3.2.0.html#paths-object + it('converts only keys starting with a slash and clones the rest', () => { + expect(convertSpec({ + paths: { + '/a': { query: { description: 'dropped' } }, + 'x-meta': { query: { description: 'kept' } }, + }, + }).paths).toEqual({ '/a': {}, 'x-meta': { query: { description: 'kept' } } }) + }) + + it('clones malformed paths, path items, and nested objects through', () => { + expect(convertSpec({ paths: 'junk' }).paths).toBe('junk') + const paths = { + '/a': { + get: 'junk', + post: { + requestBody: { + content: { + 'application/json': 42, + 'multipart/form-data': { encoding: { field: 'junk' }, example: 5 }, + }, + }, + }, + put: { requestBody: 42, responses: { 200: 42 } }, + }, + } + expect(convertSpec({ paths }).paths).toEqual(paths) + }) +}) + +describe('callbacks', () => { + // Callback Object keys are runtime expressions; only `x-` keys are + // extensions: https://spec.openapis.org/oas/v3.2.0.html#callback-object + it('converts the path items of operation callbacks and clones x- keys and references', () => { + expect(convertPathItem({ + post: { + callbacks: { + onEvent: { + 'x-note': { query: { description: 'kept' } }, + '{$request.body#/url}': { post: { responses: { 200: { summary: 'ok' } } }, query: { description: 'q' } }, + }, + referenced: { $ref: '#/components/callbacks/C' }, + }, + responses: {}, + }, + })).toEqual({ + post: { + callbacks: { + onEvent: { + 'x-note': { query: { description: 'kept' } }, + '{$request.body#/url}': { post: { responses: { 200: { description: 'ok' } } } }, + }, + referenced: { $ref: '#/components/callbacks/C' }, + }, + responses: {}, + }, + }) + }) + + it('converts components.callbacks, keeping references to surviving callbacks', () => { + expect(convertSpec({ + components: { + callbacks: { + inline: { 'https://example.com/cb': { post: { responses: { 200: { summary: 'ok' } } } } }, + referenced: { $ref: '#/components/callbacks/inline' }, + }, + }, + }).components).toEqual({ + callbacks: { + inline: { 'https://example.com/cb': { post: { responses: { 200: { description: 'ok' } } } } }, + referenced: { $ref: '#/components/callbacks/inline' }, + }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts new file mode 100644 index 0000000..fe126aa --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts @@ -0,0 +1,469 @@ +// Removing or moving a part of the document would leave every local `$ref` +// into it dangling. Instead, such a reference is replaced by a converted copy +// of its target (inlined). The removed or moved parts in 3.2 → 3.1 are: +// - `components.mediaTypes`, the `query` operation, and `additionalOperations` +// - `itemSchema`, which moves into `schema.items` +// - parameter lists that lost `querystring` entries, since the indices of +// the entries after a removed one shift + +import { dig } from '../../helpers' +import { convertComponent, convertPathItem, convertSpec } from './helpers' + +const petRef = { $ref: '#/components/mediaTypes/Pet/schema' } +const pet = { type: 'object', xml: { nodeType: 'element' } } +const convertedPet = { type: 'object', xml: {} } + +describe('schema references', () => { + it('inlines schema $refs at every subschema position', () => { + const everyPosition = (schema: unknown) => ({ + $defs: { d: schema }, + additionalProperties: schema, + allOf: [schema], + anyOf: [schema], + contains: schema, + contentSchema: schema, + dependentSchemas: { d: schema }, + else: schema, + if: schema, + items: schema, + not: schema, + oneOf: [schema], + patternProperties: { '^x': schema }, + prefixItems: [schema], + properties: { p: schema }, + propertyNames: schema, + then: schema, + unevaluatedItems: schema, + unevaluatedProperties: schema, + }) + expect(convertComponent('schemas', everyPosition(petRef), { mediaTypes: { Pet: { schema: pet } } })).toEqual(everyPosition(convertedPet)) + }) + + it('inlines schema $refs in parameter, header, media type, and itemSchema positions', () => { + expect(convertSpec({ + components: { + headers: { H: { schema: petRef } }, + mediaTypes: { Pet: { schema: pet } }, + parameters: { P: { in: 'query', name: 'p', schema: petRef } }, + requestBodies: { + B: { content: { 'application/json': { schema: petRef }, 'application/jsonl': { itemSchema: petRef } } }, + }, + }, + }).components).toEqual({ + headers: { H: { schema: convertedPet } }, + parameters: { P: { in: 'query', name: 'p', schema: convertedPet } }, + requestBodies: { + B: { + content: { + 'application/json': { schema: convertedPet }, + 'application/jsonl': { schema: { items: convertedPet, type: 'array' } }, + }, + }, + }, + }) + }) + + // `const`, `default`, `enum`, and `examples` hold instance data, not + // schemas, so an object there that looks like a reference is just a value. + // Only values under schema keywords are schemas: + // https://json-schema.org/draft/2020-12/json-schema-core#section-9.4.2 + it('keeps data keywords, extensions, and non-string $ref values verbatim', () => { + const schema = { + 'const': petRef, + 'default': petRef, + 'enum': [petRef], + 'examples': [petRef], + 'properties': { p: { $ref: 42 } }, + 'x-data': petRef, + } + expect(convertSpec({ + components: { headers: { H: { schema: petRef } }, mediaTypes: { Pet: { schema: pet } }, schemas: { S: schema } }, + }).components).toEqual({ headers: { H: { schema: convertedPet } }, schemas: { S: schema } }) + }) + + // In JSON Schema 2020-12, `$ref` applies its target alongside the sibling + // keywords, exactly like one more `allOf` entry: + // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1 + // So the inlined target joins `allOf` instead of being merged key by key, + // which could let one side's keyword overwrite the other's. + it.each([ + ['adds allOf beside sibling annotations', { $ref: petRef.$ref, description: 'd' }, { allOf: [convertedPet], description: 'd' }], + ['appends to an existing allOf, keeping its indices', { $ref: petRef.$ref, allOf: [{ required: ['a'] }] }, { allOf: [{ required: ['a'] }, convertedPet] }], + ['nests a malformed allOf instead of discarding it', { $ref: petRef.$ref, allOf: 'junk' }, { allOf: [{ allOf: 'junk' }, convertedPet] }], + ])('merges a $ref with its siblings: %s', (_name, schema, expected) => { + expect(convertComponent('schemas', schema, { mediaTypes: { Pet: { schema: pet } } })).toEqual(expected) + }) + + // Boolean schemas are valid 3.1 schemas: https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 + it('inlines a boolean target schema as is', () => { + expect(convertComponent('schemas', { $ref: '#/components/mediaTypes/None/schema' }, { mediaTypes: { None: { schema: false } } })).toBe(false) + }) + + it('inlines pointers to an itemSchema that the conversion moves or removes', () => { + expect(convertSpec({ + components: { + requestBodies: { + B: { + content: { + 'application/json': { itemSchema: { type: 'number' }, schema: { type: 'array' } }, + 'application/jsonl': { itemSchema: { type: 'string' } }, + }, + }, + }, + schemas: { + Moved: { $ref: '#/components/requestBodies/B/content/application~1jsonl/itemSchema' }, + Removed: { $ref: '#/components/requestBodies/B/content/application~1json/itemSchema' }, + }, + }, + }).components?.schemas).toEqual({ Moved: { type: 'string' }, Removed: { type: 'number' } }) + }) + + // A schema that is only `{ $ref }` (an alias) adds nothing of its own, so + // inlining follows it to its target. An alias with siblings is a schema in + // its own right: it is converted and inlined itself, keeping the siblings. + it('follows schema alias chains through removed parts, stopping at an alias with siblings', () => { + expect(dig(convertSpec({ + components: { + mediaTypes: { + A: { schema: { $ref: '#/components/mediaTypes/B/schema' } }, + B: { schema: { $ref: '#/components/mediaTypes/C/schema', description: 'b' } }, + C: { schema: { type: 'string' } }, + }, + schemas: { S: { $ref: '#/components/mediaTypes/A/schema' } }, + }, + }), 'components', 'schemas', 'S')).toEqual({ allOf: [{ type: 'string' }], description: 'b' }) + }) + + // Pointer fragments are percent-decoded (https://www.rfc-editor.org/rfc/rfc3986#section-2.1) + // before `~1` and `~0` are unescaped (https://www.rfc-editor.org/rfc/rfc6901#section-4). + it('decodes escaped and percent-encoded pointer tokens', () => { + expect(convertSpec({ + components: { + mediaTypes: { + 'a/b~c': { schema: { type: 'string' } }, + 'My Type': { schema: { type: 'number' } }, + }, + schemas: { + Escaped: { $ref: '#/components/mediaTypes/a~1b~0c/schema' }, + Percent: { $ref: '#/components/mediaTypes/My%20Type/schema' }, + Templated: { $ref: '#/paths/~1pets~1%7Bid%7D/query/requestBody/content/application~1json/schema' }, + }, + }, + paths: { + '/pets/{id}': { query: { requestBody: { content: { 'application/json': { schema: { type: 'integer' } } } } } }, + }, + }).components).toEqual({ + schemas: { Escaped: { type: 'string' }, Percent: { type: 'number' }, Templated: { type: 'integer' } }, + }) + }) +}) + +describe('reference Objects', () => { + it('inlines references into query, additionalOperations, and components.mediaTypes, converting each target for its position', () => { + const result = convertSpec({ + components: { + callbacks: { C: { $ref: '#/paths/~1search/query/callbacks/onDone' } }, + examples: { E: { $ref: '#/components/mediaTypes/Pet/examples/e' } }, + headers: { H: { $ref: '#/components/mediaTypes/Pet/encoding/file/headers/X-Rate' } }, + links: { L: { $ref: '#/paths/~1search/query/responses/200/links/next' } }, + mediaTypes: { + Pet: { + encoding: { file: { headers: { 'X-Rate': { examples: { a: { serializedValue: '1' } } } } } }, + examples: { e: { dataValue: 1 } }, + }, + }, + }, + paths: { + '/search': { + additionalOperations: { COPY: { responses: { 201: { summary: 'Copied' } } } }, + post: { + parameters: [{ $ref: '#/paths/~1search/query/parameters/0' }], + requestBody: { $ref: '#/paths/~1search/query/requestBody' }, + responses: { + 200: { $ref: '#/paths/~1search/query/responses/200' }, + 201: { $ref: '#/paths/~1search/additionalOperations/COPY/responses/201' }, + }, + }, + query: { + callbacks: { onDone: { '{$request.body#/url}': { post: { responses: { 200: { summary: 'ack' } } } } } }, + parameters: [{ in: 'cookie', name: 'c', style: 'cookie' }], + requestBody: { content: { 'application/jsonl': { itemSchema: { type: 'string' } } } }, + responses: { 200: { links: { next: { operationId: 'x', server: { name: 'n', url: '/' } } }, summary: 'Found' } }, + }, + }, + }, + }) + expect(result.components).toEqual({ + callbacks: { C: { '{$request.body#/url}': { post: { responses: { 200: { description: 'ack' } } } } } }, + examples: { E: { value: 1 } }, + headers: { H: { examples: { a: { value: '1' } } } }, + links: { L: { operationId: 'x', server: { url: '/' } } }, + }) + expect(result.paths).toEqual({ + '/search': { + post: { + parameters: [{ in: 'cookie', name: 'c' }], + requestBody: { content: { 'application/jsonl': { schema: { items: { type: 'string' }, type: 'array' } } } }, + responses: { + 200: { description: 'Found', links: { next: { operationId: 'x', server: { url: '/' } } } }, + 201: { description: 'Copied' }, + }, + }, + }, + }) + }) + + it('follows chains through removed parts and keeps the reference where a chain reaches a surviving part', () => { + const result = convertSpec({ + components: { + responses: { + Deep: { $ref: '#/paths/~1a/query/responses/200' }, + Kept: { $ref: '#/paths/~1a/query/responses/201' }, + Real: { description: 'real' }, + }, + }, + paths: { + '/a': { query: { responses: { 200: { $ref: '#/paths/~1b/query/responses/200' }, 201: { $ref: '#/components/responses/Real' } } } }, + '/b': { query: { responses: { 200: { summary: 'deep' } } } }, + }, + }) + expect(result.components).toEqual({ + responses: { + Deep: { description: 'deep' }, + Kept: { $ref: '#/components/responses/Real' }, + Real: { description: 'real' }, + }, + }) + expect(result.paths).toEqual({ '/a': {}, '/b': {} }) + }) + + // Removing a `querystring` parameter shifts the indices of the entries + // after it, so `#/paths/~1a/get/parameters/2` would silently point at a + // different parameter, or at nothing. References into such a list are + // inlined, even though the list itself survives. References to entries + // that kept their index, and external references, stay as written. + it('inlines references into a parameter list that lost entries, since its indices shift', () => { + const result = convertSpec({ + components: { + parameters: { + External: { $ref: '#/paths/~1a/get/parameters/1' }, + Kept: { $ref: '#/paths/~1b/get/parameters/0' }, + Shifted: { $ref: '#/paths/~1a/get/parameters/2' }, + }, + }, + paths: { + '/a': { + get: { + parameters: [{ in: 'querystring', name: 'qs' }, { $ref: './parameters/limit.yaml' }, { in: 'query', name: 'b' }], + responses: {}, + }, + }, + '/b': { get: { parameters: [{ in: 'query', name: 'c' }], responses: {} } }, + }, + }) + expect(result.components).toEqual({ + parameters: { + External: { $ref: './parameters/limit.yaml' }, + Kept: { $ref: '#/paths/~1b/get/parameters/0' }, + Shifted: { in: 'query', name: 'b' }, + }, + }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([{ $ref: './parameters/limit.yaml' }, { in: 'query', name: 'b' }]) + }) +}) + +describe('path Item references', () => { + // A Path Item `$ref` may sit beside the Path Item's own fields. The spec + // leaves a field on both sides undefined, but says `$ref` will move toward + // Reference Object behavior, where the referencing side's fields override + // the target's: https://spec.openapis.org/oas/v3.2.0.html#path-item-ref + // So when it is inlined, the own fields win. + it('inlines a path item $ref that points into a removed operation, keeping own fields', () => { + const callbacks = { c: { '{$url}': { description: 'inlined', summary: 'Inlined' } } } + expect(dig(convertSpec({ + components: { + pathItems: { + copy: { $ref: '#/paths/~1a/additionalOperations/COPY/callbacks/c/{$url}' }, + query: { $ref: '#/paths/~1a/query/callbacks/c/{$url}', summary: 'Own' }, + }, + }, + paths: { '/a': { additionalOperations: { COPY: { callbacks } }, query: { callbacks } } }, + }), 'components', 'pathItems')).toEqual({ + copy: { description: 'inlined', summary: 'Inlined' }, + query: { description: 'inlined', summary: 'Own' }, + }) + }) + + // Only pointers that actually land on a Path Item are merged as one. An + // extension inside a Callback Object is not a Path Item, so a `$ref` to it + // is left as written. + it('inlines a path item $ref into a removed operation of a callbacks component', () => { + expect(dig(convertSpec({ + components: { + callbacks: { + C: { '{$url}': { query: { callbacks: { d: { '{$v}': { description: 'inlined' } } } } }, 'x-cb': { query: {} } }, + }, + pathItems: { + P: { $ref: '#/components/callbacks/C/{$url}/query/callbacks/d/{$v}' }, + X: { $ref: '#/components/callbacks/C/x-cb' }, + }, + }, + }), 'components', 'pathItems')).toEqual({ + P: { description: 'inlined' }, + X: { $ref: '#/components/callbacks/C/x-cb' }, + }) + }) +}) + +describe('links and discriminator mappings', () => { + // A Link's `operationRef` and a discriminator `mapping` value are + // references too: https://spec.openapis.org/oas/v3.1.2.html#link-operation-ref + // https://spec.openapis.org/oas/v3.1.2.html#discriminator-mapping + // One that points into a removed part cannot be inlined (a Link needs an + // operation to point at), so it is removed. + it('removes links and discriminator mappings that point into removed parts', () => { + const result = convertSpec({ + components: { + links: { gone: { operationRef: '#/paths/~1a/query' }, kept: { operationRef: '#/paths/~1a/get' } }, + mediaTypes: { M: { schema: {} } }, + schemas: { + Pet: { + discriminator: { mapping: { cat: '#/components/schemas/Cat', item: '#/components/mediaTypes/M/schema' }, propertyName: 'kind' }, + }, + }, + }, + paths: { '/a': { get: {}, query: {} } }, + }) + expect(dig(result, 'components', 'links')).toEqual({ kept: { operationRef: '#/paths/~1a/get' } }) + expect(dig(result, 'components', 'schemas', 'Pet', 'discriminator')).toEqual({ mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }) + }) +}) + +describe('references left as written', () => { + // A chain that loops never reaches an object to inline, and tools cannot + // resolve it either, so rewriting it would not fix anything. + it('leaves a Reference Object whose chain loops through removed parts as written', () => { + const result = convertSpec({ + components: { + responses: { Keep: { description: 'k' }, Loop: { $ref: '#/paths/~1a/query/responses/200' } }, + }, + paths: { + '/a': { query: { responses: { 200: { $ref: '#/paths/~1b/query/responses/200' } } } }, + '/b': { query: { responses: { 200: { $ref: '#/paths/~1a/query/responses/200' } } } }, + '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, + }, + }) + expect(result.components).toEqual({ + responses: { Keep: { description: 'k' }, Loop: { $ref: '#/paths/~1a/query/responses/200' } }, + }) + expect(result.paths).toEqual({ + '/a': {}, + '/b': {}, + '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, + }) + }) + + it('leaves references whose alias chain loops as written', () => { + const parameters = { + A: { $ref: '#/components/parameters/B' }, + B: { $ref: '#/components/parameters/A' }, + } + expect(convertSpec({ components: { parameters } }).components).toEqual({ parameters }) + }) + + // The looping entry stays in the list, so it keeps its index, and the + // reference to the entry after it still points at the right parameter. + it('leaves a looping entry in a parameter list, so later indices stay correct', () => { + const result = convertSpec({ + components: { parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }, + paths: { + '/a': { get: { parameters: [{ $ref: '#/paths/~1b/query/parameters/0' }, { in: 'query', name: 'b' }], responses: {} } }, + '/b': { query: { parameters: [{ $ref: '#/paths/~1c/query/parameters/0' }] } }, + '/c': { query: { parameters: [{ $ref: '#/paths/~1b/query/parameters/0' }] } }, + }, + }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([{ $ref: '#/paths/~1b/query/parameters/0' }, { in: 'query', name: 'b' }]) + expect(result.components).toEqual({ parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }) + }) + + // `#pet` names a plain-name `$anchor`, not a JSON Pointer + // (https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.2), + // and `#` points at the whole document, which survives the conversion. + it('leaves external, anchor, root, unparseable, and already dangling references untouched', () => { + const schemas = { + Anchor: { $ref: '#pet' }, + BadEscape: { $ref: '#/components/mediaTypes/%E0%A4%A' }, + External: { $ref: 'https://example.com/api.json#/components/mediaTypes/Pet/schema' }, + Missing: { $ref: '#/components/mediaTypes/Nope/schema' }, + Root: { $ref: '#' }, + } + expect(convertSpec({ + components: { headers: { H: { schema: petRef } }, mediaTypes: { Pet: { schema: pet } }, schemas }, + }).components).toEqual({ headers: { H: { schema: convertedPet } }, schemas }) + }) +}) + +describe('recursion', () => { + // Inlining a recursive schema would never end. The recursion is cut at its + // first repeat by removing only the inner `$ref` keyword: a bare `$ref` + // becomes `{}`, which accepts anything, and one with siblings keeps them. + // Cutting can only loosen validation, never reject a value the original + // accepted (apart from under `not` and friends, a known limitation). + it('cuts a recursive schema at its first repeat by removing only the $ref keyword', () => { + expect(convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema' }, { + mediaTypes: { + Tree: { + schema: { + properties: { + children: { items: { $ref: '#/components/mediaTypes/Tree/schema' }, type: 'array' }, + parent: { $ref: '#/components/mediaTypes/Tree/schema', description: 'up' }, + }, + type: 'object', + }, + }, + }, + })).toEqual({ + properties: { children: { items: {}, type: 'array' }, parent: { description: 'up' } }, + type: 'object', + }) + }) + + // `%54ree` percent-decodes to `Tree`, so the inner reference is the same + // target as the outer one and the recursion is still detected. + it('detects recursion however the pointer is spelled', () => { + expect(convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema' }, { + mediaTypes: { Tree: { schema: { items: { $ref: '#/components/mediaTypes/%54ree/schema' }, type: 'array' } } }, + })).toEqual({ items: {}, type: 'array' }) + }) + + it('cuts a cycle entered through a pointer into a recursive schema', () => { + const result = convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema/properties/children' }, { + mediaTypes: { + Tree: { + schema: { + properties: { children: { items: { $ref: '#/components/mediaTypes/Tree/schema' }, type: 'array' } }, + type: 'object', + }, + }, + }, + }) + expect(() => JSON.stringify(result)).not.toThrow() + expect(result).toEqual({ items: { properties: { children: {} }, type: 'object' }, type: 'array' }) + }) + + // Outside schemas there is no "accept anything" value to cut with, so a + // Reference Object that leads back into the object being inlined is + // removed instead. + it('removes a Reference Object that comes back to the object being inlined', () => { + expect(convertPathItem({ + post: { callbacks: { copy: { $ref: '#/paths/~1a/query/callbacks/cb' } } }, + query: { + callbacks: { + cb: { '{$url}': { get: { callbacks: { back: { $ref: '#/paths/~1a/query/callbacks/cb' } } } } }, + }, + }, + })).toEqual({ + post: { callbacks: { copy: { '{$url}': { get: { callbacks: {} } } } } }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts new file mode 100644 index 0000000..e9b6bcc --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts @@ -0,0 +1,56 @@ +import { convertComponent, convertPathItem } from './helpers' + +describe('summary and description', () => { + // 3.2 adds a response `summary` and makes `description` optional: + // https://spec.openapis.org/oas/v3.2.0.html#response-summary + // 3.1 requires `description` (https://spec.openapis.org/oas/v3.1.2.html#response-description), + // so the summary fills it when present, and an empty string otherwise. + it.each([ + ['uses summary as the description when none exists', { summary: 'ok' }, { description: 'ok' }], + ['removes summary when a description exists', { description: 'd', summary: 's' }, { description: 'd' }], + ['adds an empty description when neither exists', {}, { description: '' }], + ['adds an empty description instead of promoting a malformed summary', { summary: 42 }, { description: '' }], + ['clones a non-object headers value through', { description: 'ok', headers: 'junk' }, { description: 'ok', headers: 'junk' }], + ])('%s', (_name, response, expected) => { + expect(convertComponent('responses', response)).toEqual(expected) + }) + + // Reference Objects already allow `summary` and `description` overrides in + // 3.1: https://spec.openapis.org/oas/v3.1.2.html#reference-object + it('leaves response Reference Objects untouched, including their overrides', () => { + const reference = { $ref: '#/components/responses/R', description: 'override', summary: 'kept' } + expect(convertComponent('responses', reference)).toEqual(reference) + }) +}) + +describe('responses maps', () => { + // Responses Object keys are status codes or `default`; `x-` keys are + // extensions, not responses: https://spec.openapis.org/oas/v3.2.0.html#responses-object + it('clones x- keys of the responses map without response conversion', () => { + expect(convertPathItem({ + get: { responses: { '200': { summary: 'ok' }, 'x-note': { summary: 'not a response' } } }, + })).toEqual({ + get: { responses: { '200': { description: 'ok' }, 'x-note': { summary: 'not a response' } } }, + }) + }) + + it('converts response headers, content, and links', () => { + expect(convertComponent('responses', { + content: { 'application/json': { itemSchema: { type: 'string' } } }, + description: 'ok', + headers: { 'X-H': { style: 'cookie' } }, + links: { + inline: { server: { name: 's', url: '/u' } }, + referenced: { $ref: '#/components/links/L' }, + }, + })).toEqual({ + content: { 'application/json': { schema: { items: { type: 'string' }, type: 'array' } } }, + description: 'ok', + headers: { 'X-H': {} }, + links: { + inline: { server: { url: '/u' } }, + referenced: { $ref: '#/components/links/L' }, + }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts new file mode 100644 index 0000000..af351b4 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts @@ -0,0 +1,123 @@ +// `$id`, `$anchor`, and `$dynamicAnchor` give a schema a URI. JSON Schema +// forbids two schemas from claiming the same one: "there is no way for a URI +// to identify more than one schema": +// https://json-schema.org/draft/2020-12/json-schema-core#section-9.1.2 +// Inlining a schema in several places would copy its identifiers, so only +// the first copy keeps them. A copy that loses its `$id` resolves its own +// relative `$ref`s against the enclosing base instead (a known limitation +// listed in the README). + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { dig, expectValidAs } from '../../helpers' +import { convertPathItem, convertSpec } from './helpers' + +it('keeps $id and $anchor on the first copy of a schema inlined in several places', () => { + const pet = { $id: 'https://example.com/pet', properties: { name: { $anchor: 'name', type: 'string' } }, type: 'object' } + const result = convertSpec({ + components: { + mediaTypes: { Pet: { schema: pet } }, + schemas: { Named: { $ref: '#/components/mediaTypes/Pet/schema', description: 'named' } }, + }, + paths: { + '/a': { get: { responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } }, description: 'ok' } } } }, + }, + }) + expect(dig(result, 'components', 'schemas', 'Named')).toEqual({ allOf: [pet], description: 'named' }) + expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content')).toEqual({ + 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } }, + }) +}) + +// When the original survives (moved into `schema.items`, or shifted in a +// parameter list), it keeps its identifiers and the inlined copies lose them. +it('keeps identifiers on a moved or shifted original rather than on the copies inlined from it', () => { + expect(convertPathItem({ + get: { + parameters: [ + { content: { 'text/plain': {} }, in: 'querystring', name: 'q' }, + { in: 'query', name: 'p', schema: { $dynamicAnchor: 'p', type: 'string' } }, + ], + responses: { + 200: { content: { 'application/jsonl': { itemSchema: { $id: 'https://example.com/item' } } }, description: 'ok' }, + }, + }, + post: { + parameters: [{ $ref: '#/paths/~1a/get/parameters/1' }], + requestBody: { + content: { 'application/json': { schema: { $ref: '#/paths/~1a/get/responses/200/content/application~1jsonl/itemSchema' } } }, + }, + }, + })).toEqual({ + get: { + parameters: [{ in: 'query', name: 'p', schema: { $dynamicAnchor: 'p', type: 'string' } }], + responses: { + 200: { content: { 'application/jsonl': { schema: { items: { $id: 'https://example.com/item' }, type: 'array' } } }, description: 'ok' }, + }, + }, + post: { + parameters: [{ in: 'query', name: 'p', schema: { type: 'string' } }], + requestBody: { content: { 'application/json': { schema: {} } } }, + }, + }) +}) + +it('produces a valid 3.1 document with unique identifiers', async () => { + const doc: OpenAPIV3_2.OpenAPIObject = { + components: { + mediaTypes: { + Pet: { + schema: { + $id: 'https://example.com/pet', + properties: { name: { $anchor: 'name', type: 'string' } }, + type: 'object', + }, + }, + }, + }, + info: { title: 'Identifiers', version: '1.0.0' }, + openapi: '3.2.0', + paths: { + '/pets': { + get: { + responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } }, description: 'Pet' } }, + }, + post: { + requestBody: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } } }, + responses: { + 201: { + content: { 'application/json': { schema: { $ref: '#/components/mediaTypes/Pet/schema/properties/name' } } }, + description: 'Name', + }, + }, + }, + }, + }, + } + const v31 = downgradeSpecV32ToV31(doc) + const serialized = JSON.stringify(v31) + expect(serialized.match(/"\$id"/g)).toHaveLength(1) + expect(serialized.match(/"\$anchor"/g)).toHaveLength(1) + await expectValidAs(v31, '3.1') +}) + +// Only the first copy is special. The copies after it are identical, so they +// share one converted object, like any other target inlined several times. +it('shares one identifier-free copy among the places after the first', () => { + const pet = { $id: 'https://example.com/pet', type: 'object' } + const result = convertSpec({ + components: { + mediaTypes: { Pet: { schema: pet } }, + schemas: { + A: { $ref: '#/components/mediaTypes/Pet/schema' }, + B: { $ref: '#/components/mediaTypes/Pet/schema' }, + C: { $ref: '#/components/mediaTypes/Pet/schema' }, + }, + }, + }) + const schemas = dig(result, 'components', 'schemas') + expect(schemas).toEqual({ A: pet, B: { type: 'object' }, C: { type: 'object' } }) + expect(dig(schemas, 'C')).toBe(dig(schemas, 'B')) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts new file mode 100644 index 0000000..e43121e --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts @@ -0,0 +1,58 @@ +import { convertComponent, convertSpec } from './helpers' + +const flow = { + authorizationUrl: 'https://example.com/auth', + scopes: {}, + tokenUrl: 'https://example.com/token', +} + +// 3.2 adds three security fields that 3.1 cannot express: +// - `deprecated`: https://spec.openapis.org/oas/v3.2.0.html#security-scheme-deprecated +// - `oauth2MetadataUrl` (RFC 8414 discovery): https://spec.openapis.org/oas/v3.2.0.html#security-scheme-oauth2-metadata-url +// - the OAuth device authorization flow (RFC 8628): https://spec.openapis.org/oas/v3.2.0.html#oauth-flows-device-authorization +// The scheme itself survives, so requirements naming it stay valid. +describe('3.2-only security scheme fields', () => { + it.each([ + ['removes deprecated: true', { deprecated: true, type: 'http' }, { type: 'http' }], + ['removes deprecated: false', { deprecated: false, type: 'http' }, { type: 'http' }], + ['removes a malformed deprecated', { deprecated: 'yes', type: 'http' }, { type: 'http' }], + ['removes oauth2MetadataUrl', { oauth2MetadataUrl: 'https://example.com/meta', type: 'oauth2' }, { type: 'oauth2' }], + ['removes a malformed oauth2MetadataUrl', { oauth2MetadataUrl: 42, type: 'oauth2' }, { type: 'oauth2' }], + [ + 'removes the deviceAuthorization flow and keeps the other flows', + { + flows: { + authorizationCode: flow, + deviceAuthorization: { deviceAuthorizationUrl: 'https://example.com/device', scopes: {}, tokenUrl: flow.tokenUrl }, + }, + type: 'oauth2', + }, + { flows: { authorizationCode: flow }, type: 'oauth2' }, + ], + ['removes a malformed deviceAuthorization', { flows: { deviceAuthorization: 'junk' }, type: 'oauth2' }, { flows: {}, type: 'oauth2' }], + ['clones malformed flows through', { flows: 'junk', type: 'oauth2' }, { flows: 'junk', type: 'oauth2' }], + ['clones references through', { $ref: '#/components/securitySchemes/Other' }, { $ref: '#/components/securitySchemes/Other' }], + ['passes a non-object scheme through', 'junk', 'junk'], + ])('%s', (_name, scheme, expected) => { + expect(convertComponent('securitySchemes', scheme)).toEqual(expected) + }) + + // An oauth2 scheme whose only flow was the device flow keeps an empty + // `flows`, which the official 3.1 schema accepts. Requirements that name + // the scheme are left intact rather than silently dropped. + it('keeps a scheme left with no flows, and the requirements that name it', () => { + const result = convertSpec({ + components: { + securitySchemes: { + device: { + flows: { deviceAuthorization: { deviceAuthorizationUrl: 'https://example.com/device', scopes: { read: 'Read' }, tokenUrl: flow.tokenUrl } }, + type: 'oauth2', + }, + }, + }, + security: [{ device: ['read'] }], + }) + expect(result.components).toEqual({ securitySchemes: { device: { flows: {}, type: 'oauth2' } } }) + expect(result.security).toEqual([{ device: ['read'] }]) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts new file mode 100644 index 0000000..e4605b3 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts @@ -0,0 +1,55 @@ +import { convertSpec } from './helpers' + +describe('servers', () => { + // Server `name` is new in 3.2: https://spec.openapis.org/oas/v3.2.0.html#server-name + it('removes server name at the root, path item, operation, and link levels', () => { + expect(convertSpec({ + components: { links: { L: { operationId: 'op', server: { name: 's', url: '/u' } } } }, + paths: { + '/a': { + get: { responses: {}, servers: [{ name: 's', url: '/u' }] }, + servers: [{ name: 's', url: '/u' }], + }, + }, + servers: [{ description: 'd', name: 'prod', url: 'https://example.com' }], + })).toEqual({ + components: { links: { L: { operationId: 'op', server: { url: '/u' } } } }, + openapi: '3.1.2', + paths: { + '/a': { + get: { responses: {}, servers: [{ url: '/u' }] }, + servers: [{ url: '/u' }], + }, + }, + servers: [{ description: 'd', url: 'https://example.com' }], + }) + }) + + it('clones non-array servers and non-object server entries through', () => { + expect(convertSpec({ paths: { '/a': { servers: 'junk' } }, servers: [5, null] })).toEqual({ + openapi: '3.1.2', + paths: { '/a': { servers: 'junk' } }, + servers: [5, null], + }) + }) +}) + +describe('tags', () => { + // 3.2 turns tags into a hierarchy with `summary`, `parent`, and `kind`: + // https://spec.openapis.org/oas/v3.2.0.html#tag-object + // 3.1 tags are flat, so the hierarchy is lost. Operations keep referring to + // every tag by name, so no operation loses a tag. + it('removes tag summary, parent, and kind and keeps the other fields', () => { + expect(convertSpec({ + tags: [ + { description: 'd', externalDocs: { url: 'https://example.com' }, kind: 'nav', name: 'pets', parent: 'animals', summary: 'Pets' }, + 'junk', + 1, + ], + }).tags).toEqual([ + { description: 'd', externalDocs: { url: 'https://example.com' }, name: 'pets' }, + 'junk', + 1, + ]) + }) +}) From 8d47b0c1314082bb817940cb51c868f67cb193d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:41:10 +0000 Subject: [PATCH 2/2] test(downgrader): share corpus, validator, and helpers across e2e tests - Reuse one Validator: a fresh instance per call recompiled the official schema every time. The suite drops from ~14s to ~4.5s. - Move validation helpers into tests/validate.ts, so files that only need `dig` no longer load Ajv, and add `expectValidDowngrade` for the per-document corpus check that was written out three times. - Declare the official corpora once in tests/corpus.ts instead of copying the 3.2 list into two files. - Add a `convertSchema` helper per downgrader folder in place of nine local wrappers and a dozen inline casts. - Share fixtures across v3.1 spec files, drop unused helper parameters and a positional wrapper, merge back-to-back it.each tables, and remove assertions an exact `toEqual` already covers. - Pin that a Path Item hop re-entered by its own chain contributes no fields, which no test checked before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018z4VMNTUoJPMp24y74NBMo --- packages/downgrader/tests/chained.test.ts | 95 +--------- packages/downgrader/tests/corpus.ts | 170 ++++++++++++++++++ packages/downgrader/tests/helpers.ts | 71 +------- .../v3.1-to-v3.0/schema/annotations.test.ts | 29 ++- .../schema/enum-const-required.test.ts | 16 +- .../tests/v3.1-to-v3.0/schema/helpers.ts | 9 + .../tests/v3.1-to-v3.0/schema/input.test.ts | 25 ++- .../v3.1-to-v3.0/schema/loosening.test.ts | 24 +-- .../schema/numeric-bounds.test.ts | 10 +- .../v3.1-to-v3.0/schema/references.test.ts | 26 ++- .../schema/removed-keywords.test.ts | 21 +-- .../v3.1-to-v3.0/schema/subschemas.test.ts | 11 +- .../tests/v3.1-to-v3.0/schema/type.test.ts | 54 ++---- .../v3.1-to-v3.0/spec/components.test.ts | 1 - .../tests/v3.1-to-v3.0/spec/corpus.test.ts | 101 +---------- .../tests/v3.1-to-v3.0/spec/document.test.ts | 8 +- .../v3.1-to-v3.0/spec/form-bodies.test.ts | 10 +- .../tests/v3.1-to-v3.0/spec/helpers.ts | 17 +- .../spec/links-and-mappings.test.ts | 9 +- .../v3.1-to-v3.0/spec/path-items.test.ts | 120 +++++++------ .../tests/v3.1-to-v3.0/spec/recursion.test.ts | 17 +- .../v3.1-to-v3.0/spec/removed-parts.test.ts | 41 ++--- .../tests/v3.2-to-v3.1/schema/helpers.ts | 9 + .../tests/v3.2-to-v3.1/schema/input.test.ts | 11 +- .../v3.2-to-v3.1/schema/keywords.test.ts | 7 +- .../v3.2-to-v3.1/schema/references.test.ts | 3 +- .../v3.2-to-v3.1/schema/subschemas.test.ts | 9 +- .../spec/content-references.test.ts | 9 +- .../tests/v3.2-to-v3.1/spec/corpus.test.ts | 99 +--------- .../tests/v3.2-to-v3.1/spec/helpers.ts | 6 +- .../v3.2-to-v3.1/spec/input-graph.test.ts | 2 +- .../v3.2-to-v3.1/spec/parameters.test.ts | 24 ++- .../v3.2-to-v3.1/spec/removed-parts.test.ts | 1 - .../spec/schema-identifiers.test.ts | 3 +- packages/downgrader/tests/validate.ts | 96 ++++++++++ 35 files changed, 539 insertions(+), 625 deletions(-) create mode 100644 packages/downgrader/tests/corpus.ts create mode 100644 packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts create mode 100644 packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts create mode 100644 packages/downgrader/tests/validate.ts diff --git a/packages/downgrader/tests/chained.test.ts b/packages/downgrader/tests/chained.test.ts index f390212..bddfddb 100644 --- a/packages/downgrader/tests/chained.test.ts +++ b/packages/downgrader/tests/chained.test.ts @@ -1,6 +1,6 @@ // There is no direct 3.2 → 3.0 converter on purpose: the two steps compose -// (see the package README). Every official 3.2 document must survive both -// steps as a valid document at each version. +// (see the package README). Every official 3.2 document (see corpus.ts) must +// come out of both steps as a valid 3.0 document. import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' @@ -8,99 +8,18 @@ import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/down import { doc as queryExample } from '../../types/tests/examples/3-2-query-example' import { doc as tagsExample } from '../../types/tests/examples/3-2-tags-example' -import { doc as callbackObjectExamples } from '../../types/tests/schema-tests-3.2/callback-object-examples' -import { doc as compPathitems } from '../../types/tests/schema-tests-3.2/comp-pathitems' -import { doc as componentsObjectExample } from '../../types/tests/schema-tests-3.2/components-object-example' -import { doc as exampleObjectExamples } from '../../types/tests/schema-tests-3.2/example-object-examples' -import { doc as headerObjectExamples } from '../../types/tests/schema-tests-3.2/header-object-examples' -import { doc as infoObjectExample } from '../../types/tests/schema-tests-3.2/info-object-example' -import { doc as infoSummary } from '../../types/tests/schema-tests-3.2/info-summary' -import { doc as jsonSchemaDialect } from '../../types/tests/schema-tests-3.2/json-schema-dialect' -import { doc as licenseIdentifier } from '../../types/tests/schema-tests-3.2/license-identifier' -import { doc as linkObjectExamples } from '../../types/tests/schema-tests-3.2/link-object-examples' -import { doc as mediaTypeExamples } from '../../types/tests/schema-tests-3.2/media-type-examples' import { doc as mega } from '../../types/tests/schema-tests-3.2/mega' -import { doc as minimalComp } from '../../types/tests/schema-tests-3.2/minimal-comp' -import { doc as minimalHooks } from '../../types/tests/schema-tests-3.2/minimal-hooks' -import { doc as minimalPaths } from '../../types/tests/schema-tests-3.2/minimal-paths' -import { doc as nonOauthScopes } from '../../types/tests/schema-tests-3.2/non-oauth-scopes' -import { doc as operationObjectExample } from '../../types/tests/schema-tests-3.2/operation-object-example' -import { doc as parameterObjectCookieFormAllowReserved } from '../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved' -import { doc as parameterObjectExamples } from '../../types/tests/schema-tests-3.2/parameter-object-examples' -import { doc as parameterObjectPathAllowReserved } from '../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved' -import { doc as parameterObjectQueryAllowReserved } from '../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved' -import { doc as pathItemObjectExample } from '../../types/tests/schema-tests-3.2/path-item-object-example' -import { doc as pathItemServersParameters } from '../../types/tests/schema-tests-3.2/path-item-servers-parameters' -import { doc as pathNoResponse } from '../../types/tests/schema-tests-3.2/path-no-response' -import { doc as pathVarEmptyPathitem } from '../../types/tests/schema-tests-3.2/path-var-empty-pathitem' -import { doc as pathsObjectExample } from '../../types/tests/schema-tests-3.2/paths-object-example' -import { doc as requestBodyExamples } from '../../types/tests/schema-tests-3.2/request-body-examples' -import { doc as responseObjectExamples } from '../../types/tests/schema-tests-3.2/response-object-examples' -import { doc as schema } from '../../types/tests/schema-tests-3.2/schema' -import { doc as schemaObjectDeprecatedExampleKeyword } from '../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword' -import { doc as servers } from '../../types/tests/schema-tests-3.2/servers' -import { doc as specificationExtensions } from '../../types/tests/schema-tests-3.2/specification-extensions' -import { doc as styleDefaults } from '../../types/tests/schema-tests-3.2/style-defaults' -import { doc as tagObjectExample } from '../../types/tests/schema-tests-3.2/tag-object-example' -import { doc as validSchemaTypes } from '../../types/tests/schema-tests-3.2/valid-schema-types' -import { doc as webhookExample } from '../../types/tests/schema-tests-3.2/webhook-example' -import { expectNoNewDanglingRefs, expectValidAs } from './helpers' - -// Left out: security-scheme-object-examples, whose external `$ref` the -// validator cannot resolve. -const corpus: readonly (readonly [name: string, doc: OpenAPIV3_2.OpenAPIObject])[] = [ - ['examples/3-2-query-example', queryExample], - ['examples/3-2-tags-example', tagsExample], - ['callback-object-examples', callbackObjectExamples], - ['comp-pathitems', compPathitems], - ['components-object-example', componentsObjectExample], - ['example-object-examples', exampleObjectExamples], - ['header-object-examples', headerObjectExamples], - ['info-object-example', infoObjectExample], - ['info-summary', infoSummary], - ['json-schema-dialect', jsonSchemaDialect], - ['license-identifier', licenseIdentifier], - ['link-object-examples', linkObjectExamples], - ['media-type-examples', mediaTypeExamples], - ['mega', mega], - ['minimal-comp', minimalComp], - ['minimal-hooks', minimalHooks], - ['minimal-paths', minimalPaths], - ['non-oauth-scopes', nonOauthScopes], - ['operation-object-example', operationObjectExample], - ['parameter-object-cookie-form-allow-reserved', parameterObjectCookieFormAllowReserved], - ['parameter-object-examples', parameterObjectExamples], - ['parameter-object-path-allow-reserved', parameterObjectPathAllowReserved], - ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReserved], - ['path-item-object-example', pathItemObjectExample], - ['path-item-servers-parameters', pathItemServersParameters], - ['path-no-response', pathNoResponse], - ['path-var-empty-pathitem', pathVarEmptyPathitem], - ['paths-object-example', pathsObjectExample], - ['request-body-examples', requestBodyExamples], - ['response-object-examples', responseObjectExamples], - ['schema', schema], - ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeyword], - ['servers', servers], - ['specification-extensions', specificationExtensions], - ['style-defaults', styleDefaults], - ['tag-object-example', tagObjectExample], - ['valid-schema-types', validSchemaTypes], - ['webhook-example', webhookExample], -] +import { corpusV32 } from './corpus' +import { expectValidDowngrade } from './validate' function downgradeTwice(doc: OpenAPIV3_2.OpenAPIObject) { return downgradeSpecV31ToV30(downgradeSpecV32ToV31(doc)) } +// The 3.2 → 3.1 step is validated by v3.2-to-v3.1/spec/corpus.test.ts. describe('official corpus', () => { - it.each(corpus)('converts %s to valid 3.1 and 3.0 documents without new dangling references', async (_name, doc) => { - const v31 = downgradeSpecV32ToV31(doc) - await expectValidAs(v31, '3.1') - const v30 = downgradeSpecV31ToV30(v31) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expectNoNewDanglingRefs(v31, v30) + it.each(corpusV32)('converts %s to a valid 3.0 document', async (_name, doc) => { + await expectValidDowngrade(doc, downgradeTwice, '3.2', '3.0') }) }) diff --git a/packages/downgrader/tests/corpus.ts b/packages/downgrader/tests/corpus.ts new file mode 100644 index 0000000..eb98b50 --- /dev/null +++ b/packages/downgrader/tests/corpus.ts @@ -0,0 +1,170 @@ +import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { doc as exampleQueryExampleV32 } from '../../types/tests/examples/3-2-query-example' +import { doc as exampleTagsExampleV32 } from '../../types/tests/examples/3-2-tags-example' +import { doc as exampleNonOauthScopesV31 } from '../../types/tests/examples/non-oauth-scopes-3-1' +import { doc as exampleTictactoeV31 } from '../../types/tests/examples/tictactoe-3-1' +import { doc as exampleWebhookExampleV31 } from '../../types/tests/examples/webhook-example-3-1' +import { doc as callbackObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/callback-object-examples' +import { doc as compPathitemsV31 } from '../../types/tests/schema-tests-3.1/comp-pathitems' +import { doc as componentsObjectExampleV31 } from '../../types/tests/schema-tests-3.1/components-object-example' +import { doc as exampleObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/example-object-examples' +import { doc as headerObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/header-object-examples' +import { doc as infoObjectExampleV31 } from '../../types/tests/schema-tests-3.1/info-object-example' +import { doc as infoSummaryV31 } from '../../types/tests/schema-tests-3.1/info-summary' +import { doc as jsonSchemaDialectV31 } from '../../types/tests/schema-tests-3.1/json-schema-dialect' +import { doc as licenseIdentifierV31 } from '../../types/tests/schema-tests-3.1/license-identifier' +import { doc as linkObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/link-object-examples' +import { doc as mediaTypeExamplesV31 } from '../../types/tests/schema-tests-3.1/media-type-examples' +import { doc as megaV31 } from '../../types/tests/schema-tests-3.1/mega' +import { doc as minimalCompV31 } from '../../types/tests/schema-tests-3.1/minimal-comp' +import { doc as minimalHooksV31 } from '../../types/tests/schema-tests-3.1/minimal-hooks' +import { doc as minimalPathsV31 } from '../../types/tests/schema-tests-3.1/minimal-paths' +import { doc as nonOauthScopesV31 } from '../../types/tests/schema-tests-3.1/non-oauth-scopes' +import { doc as operationObjectExampleV31 } from '../../types/tests/schema-tests-3.1/operation-object-example' +import { doc as parameterObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/parameter-object-examples' +import { doc as parameterObjectQueryAllowReservedV31 } from '../../types/tests/schema-tests-3.1/parameter-object-query-allow-reserved' +import { doc as pathItemObjectExampleV31 } from '../../types/tests/schema-tests-3.1/path-item-object-example' +import { doc as pathItemServersParametersV31 } from '../../types/tests/schema-tests-3.1/path-item-servers-parameters' +import { doc as pathNoResponseV31 } from '../../types/tests/schema-tests-3.1/path-no-response' +import { doc as pathVarEmptyPathitemV31 } from '../../types/tests/schema-tests-3.1/path-var-empty-pathitem' +import { doc as pathsObjectExampleV31 } from '../../types/tests/schema-tests-3.1/paths-object-example' +import { doc as requestBodyExamplesV31 } from '../../types/tests/schema-tests-3.1/request-body-examples' +import { doc as responseObjectExamplesV31 } from '../../types/tests/schema-tests-3.1/response-object-examples' +import { doc as schemaV31 } from '../../types/tests/schema-tests-3.1/schema' +import { doc as schemaObjectDeprecatedExampleKeywordV31 } from '../../types/tests/schema-tests-3.1/schema-object-deprecated-example-keyword' +import { doc as serversV31 } from '../../types/tests/schema-tests-3.1/servers' +import { doc as specificationExtensionsV31 } from '../../types/tests/schema-tests-3.1/specification-extensions' +import { doc as tagObjectExampleV31 } from '../../types/tests/schema-tests-3.1/tag-object-example' +import { doc as validSchemaTypesV31 } from '../../types/tests/schema-tests-3.1/valid-schema-types' +import { doc as webhookExampleV31 } from '../../types/tests/schema-tests-3.1/webhook-example' +import { doc as callbackObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/callback-object-examples' +import { doc as compPathitemsV32 } from '../../types/tests/schema-tests-3.2/comp-pathitems' +import { doc as componentsObjectExampleV32 } from '../../types/tests/schema-tests-3.2/components-object-example' +import { doc as exampleObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/example-object-examples' +import { doc as headerObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/header-object-examples' +import { doc as infoObjectExampleV32 } from '../../types/tests/schema-tests-3.2/info-object-example' +import { doc as infoSummaryV32 } from '../../types/tests/schema-tests-3.2/info-summary' +import { doc as jsonSchemaDialectV32 } from '../../types/tests/schema-tests-3.2/json-schema-dialect' +import { doc as licenseIdentifierV32 } from '../../types/tests/schema-tests-3.2/license-identifier' +import { doc as linkObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/link-object-examples' +import { doc as mediaTypeExamplesV32 } from '../../types/tests/schema-tests-3.2/media-type-examples' +import { doc as megaV32 } from '../../types/tests/schema-tests-3.2/mega' +import { doc as minimalCompV32 } from '../../types/tests/schema-tests-3.2/minimal-comp' +import { doc as minimalHooksV32 } from '../../types/tests/schema-tests-3.2/minimal-hooks' +import { doc as minimalPathsV32 } from '../../types/tests/schema-tests-3.2/minimal-paths' +import { doc as nonOauthScopesV32 } from '../../types/tests/schema-tests-3.2/non-oauth-scopes' +import { doc as operationObjectExampleV32 } from '../../types/tests/schema-tests-3.2/operation-object-example' +import { doc as parameterObjectCookieFormAllowReservedV32 } from '../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved' +import { doc as parameterObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/parameter-object-examples' +import { doc as parameterObjectPathAllowReservedV32 } from '../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved' +import { doc as parameterObjectQueryAllowReservedV32 } from '../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved' +import { doc as pathItemObjectExampleV32 } from '../../types/tests/schema-tests-3.2/path-item-object-example' +import { doc as pathItemServersParametersV32 } from '../../types/tests/schema-tests-3.2/path-item-servers-parameters' +import { doc as pathNoResponseV32 } from '../../types/tests/schema-tests-3.2/path-no-response' +import { doc as pathVarEmptyPathitemV32 } from '../../types/tests/schema-tests-3.2/path-var-empty-pathitem' +import { doc as pathsObjectExampleV32 } from '../../types/tests/schema-tests-3.2/paths-object-example' +import { doc as requestBodyExamplesV32 } from '../../types/tests/schema-tests-3.2/request-body-examples' +import { doc as responseObjectExamplesV32 } from '../../types/tests/schema-tests-3.2/response-object-examples' +import { doc as schemaV32 } from '../../types/tests/schema-tests-3.2/schema' +import { doc as schemaObjectDeprecatedExampleKeywordV32 } from '../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword' +import { doc as serversV32 } from '../../types/tests/schema-tests-3.2/servers' +import { doc as specificationExtensionsV32 } from '../../types/tests/schema-tests-3.2/specification-extensions' +import { doc as styleDefaultsV32 } from '../../types/tests/schema-tests-3.2/style-defaults' +import { doc as tagObjectExampleV32 } from '../../types/tests/schema-tests-3.2/tag-object-example' +import { doc as validSchemaTypesV32 } from '../../types/tests/schema-tests-3.2/valid-schema-types' +import { doc as webhookExampleV32 } from '../../types/tests/schema-tests-3.2/webhook-example' + +type Corpus = readonly (readonly [name: string, doc: T])[] + +// The official documents, from OAI/learn.openapis.org and from the +// `tests/schema/pass` folder of OAI/OpenAPI-Specification (see +// packages/types/tests/README.md). + +// Left out: +// - security-scheme-object-examples, whose external `$ref` the validator +// cannot resolve +// - style-defaults, which puts an `x-comment` in an Encoding Object; the +// official 3.0 schema rejects extensions there +export const corpusV31: Corpus = [ + ['examples/non-oauth-scopes-3-1', exampleNonOauthScopesV31], + ['examples/tictactoe-3-1', exampleTictactoeV31], + ['examples/webhook-example-3-1', exampleWebhookExampleV31], + ['callback-object-examples', callbackObjectExamplesV31], + ['comp-pathitems', compPathitemsV31], + ['components-object-example', componentsObjectExampleV31], + ['example-object-examples', exampleObjectExamplesV31], + ['header-object-examples', headerObjectExamplesV31], + ['info-object-example', infoObjectExampleV31], + ['info-summary', infoSummaryV31], + ['json-schema-dialect', jsonSchemaDialectV31], + ['license-identifier', licenseIdentifierV31], + ['link-object-examples', linkObjectExamplesV31], + ['media-type-examples', mediaTypeExamplesV31], + ['mega', megaV31], + ['minimal-comp', minimalCompV31], + ['minimal-hooks', minimalHooksV31], + ['minimal-paths', minimalPathsV31], + ['non-oauth-scopes', nonOauthScopesV31], + ['operation-object-example', operationObjectExampleV31], + ['parameter-object-examples', parameterObjectExamplesV31], + ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReservedV31], + ['path-item-object-example', pathItemObjectExampleV31], + ['path-item-servers-parameters', pathItemServersParametersV31], + ['path-no-response', pathNoResponseV31], + ['path-var-empty-pathitem', pathVarEmptyPathitemV31], + ['paths-object-example', pathsObjectExampleV31], + ['request-body-examples', requestBodyExamplesV31], + ['response-object-examples', responseObjectExamplesV31], + ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeywordV31], + ['schema', schemaV31], + ['servers', serversV31], + ['specification-extensions', specificationExtensionsV31], + ['tag-object-example', tagObjectExampleV31], + ['valid-schema-types', validSchemaTypesV31], + ['webhook-example', webhookExampleV31], +] + +// Left out: security-scheme-object-examples, whose external `$ref` the +// validator cannot resolve. +export const corpusV32: Corpus = [ + ['examples/3-2-query-example', exampleQueryExampleV32], + ['examples/3-2-tags-example', exampleTagsExampleV32], + ['callback-object-examples', callbackObjectExamplesV32], + ['comp-pathitems', compPathitemsV32], + ['components-object-example', componentsObjectExampleV32], + ['example-object-examples', exampleObjectExamplesV32], + ['header-object-examples', headerObjectExamplesV32], + ['info-object-example', infoObjectExampleV32], + ['info-summary', infoSummaryV32], + ['json-schema-dialect', jsonSchemaDialectV32], + ['license-identifier', licenseIdentifierV32], + ['link-object-examples', linkObjectExamplesV32], + ['media-type-examples', mediaTypeExamplesV32], + ['mega', megaV32], + ['minimal-comp', minimalCompV32], + ['minimal-hooks', minimalHooksV32], + ['minimal-paths', minimalPathsV32], + ['non-oauth-scopes', nonOauthScopesV32], + ['operation-object-example', operationObjectExampleV32], + ['parameter-object-cookie-form-allow-reserved', parameterObjectCookieFormAllowReservedV32], + ['parameter-object-examples', parameterObjectExamplesV32], + ['parameter-object-path-allow-reserved', parameterObjectPathAllowReservedV32], + ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReservedV32], + ['path-item-object-example', pathItemObjectExampleV32], + ['path-item-servers-parameters', pathItemServersParametersV32], + ['path-no-response', pathNoResponseV32], + ['path-var-empty-pathitem', pathVarEmptyPathitemV32], + ['paths-object-example', pathsObjectExampleV32], + ['request-body-examples', requestBodyExamplesV32], + ['response-object-examples', responseObjectExamplesV32], + ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeywordV32], + ['schema', schemaV32], + ['servers', serversV32], + ['specification-extensions', specificationExtensionsV32], + ['style-defaults', styleDefaultsV32], + ['tag-object-example', tagObjectExampleV32], + ['valid-schema-types', validSchemaTypesV32], + ['webhook-example', webhookExampleV32], +] diff --git a/packages/downgrader/tests/helpers.ts b/packages/downgrader/tests/helpers.ts index e976424..975033a 100644 --- a/packages/downgrader/tests/helpers.ts +++ b/packages/downgrader/tests/helpers.ts @@ -1,4 +1,3 @@ -import { Validator } from '@seriousme/openapi-schema-validator' import { expect } from 'vitest' /** @@ -13,71 +12,7 @@ export function dig(value: unknown, ...path: string[]): unknown { return current } -/** - * Resolves a local `$ref` such as `#/components/schemas/Pet` against `root`. - * The fragment is percent-decoded first (RFC 3986) and then split into - * JSON Pointer tokens with `~1` and `~0` unescaped (RFC 6901). - */ -export function resolvePointer(root: unknown, ref: string): unknown { - if (!ref.startsWith('#')) { - return undefined - } - let pointer: string - try { - pointer = decodeURIComponent(ref.slice(1)) - } - catch { - return undefined - } - if (pointer === '') { - return root - } - let current = root - for (const token of pointer.slice(1).split('/')) { - const key = token.replaceAll('~1', '/').replaceAll('~0', '~') - if (typeof current !== 'object' || current === null || !Object.hasOwn(current, key)) { - return undefined - } - current = (current as Record)[key] - } - return current -} - -function collectLocalRefs(value: unknown, refs: Set): Set { - if (typeof value === 'object' && value !== null) { - for (const [key, item] of Object.entries(value)) { - if ((key === '$ref' || key === 'operationRef') && typeof item === 'string' && item.startsWith('#')) { - refs.add(item) - } - else { - collectLocalRefs(item, refs) - } - } - } - return refs -} - -/** - * Every local `$ref` and `operationRef` in `output` that resolved in `input` - * must still resolve in `output`: a conversion may keep a reference only - * when its target survives. - */ -export function expectNoNewDanglingRefs(input: object, output: object): void { - for (const ref of collectLocalRefs(output, new Set())) { - if (resolvePointer(input, ref) !== undefined) { - expect(resolvePointer(output, ref), ref).toBeDefined() - } - } -} - -/** - * Validates a document against the official OpenAPI JSON Schema of the - * version its `openapi` field names. - */ -export async function expectValidAs(spec: object, expectedVersion: '3.0' | '3.1' | '3.2'): Promise { - const validator = new Validator() - const result = await validator.validate(structuredClone(spec) as Record) - expect(result.errors ?? []).toEqual([]) - expect(result.valid).toBe(true) - expect(validator.version).toBe(expectedVersion) +/** A converted document must stay plain JSON, which cannot hold a cycle. */ +export function expectAcyclic(value: unknown): void { + expect(() => JSON.stringify(value)).not.toThrow() } diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts index 65b8079..a12af91 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts @@ -1,8 +1,4 @@ -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' describe('examples', () => { // 3.1 uses the JSON Schema `examples` list, and deprecates the singular @@ -16,7 +12,7 @@ describe('examples', () => { ['drops an empty list', { examples: [] }, {}], ['drops a malformed value', { examples: 'junk' }, {}], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -40,21 +36,16 @@ describe('binary content', () => { { anyOf: [{ type: 'string' }, { type: 'integer' }], format: 'binary' }, ], ['keeps an existing format', { contentEncoding: 'base64', format: 'custom' }, { format: 'custom', type: 'string' }], - ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) - }) - - // `format: byte` is base64 as in RFC 4648 section 4, so it cannot describe - // the URL-safe alphabet of section 5, or any other encoding: - // https://spec.openapis.org/oas/v3.0.4.html#data-type-format - // Content keywords on a type that is not a string have nothing to map to. - it.each([ + // `format: byte` is base64 as in RFC 4648 section 4, so it cannot describe + // the URL-safe alphabet of section 5, or any other encoding: + // https://spec.openapis.org/oas/v3.0.4.html#data-type-format + // Content keywords on a type that is not a string have nothing to map to. ['drops base64url, which format: byte does not cover', { contentEncoding: 'base64url', contentMediaType: 'image/png', type: 'string' }, { type: 'string' }], ['drops content keywords on non-string types', { contentMediaType: 'image/png', type: 'object' }, { type: 'object' }], ['drops a malformed contentMediaType', { contentMediaType: 42 }, {}], ['drops contentSchema', { contentSchema: { type: 'string' } }, {}], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -71,7 +62,7 @@ describe('xml.nodeType', () => { ['keeps an xml object without nodeType', { type: 'string', xml: { attribute: true, name: 'n' } }, { type: 'string', xml: { attribute: true, name: 'n' } }], ['passes a malformed xml value through', { type: 'string', xml: 'junk' }, { type: 'string', xml: 'junk' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -81,10 +72,10 @@ describe('discriminator', () => { discriminator: { mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }, oneOf: [{ $ref: '#/components/schemas/Cat' }], } - expect(convert(schema)).toEqual(schema) + expect(convertSchema(schema)).toEqual(schema) }) it('passes a malformed discriminator through', () => { - expect(convert({ discriminator: 'junk' })).toEqual({ discriminator: 'junk' }) + expect(convertSchema({ discriminator: 'junk' })).toEqual({ discriminator: 'junk' }) }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts index 952f3b0..f7e9976 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts @@ -1,8 +1,4 @@ -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' describe('const', () => { // `const` arrived in JSON Schema draft 06, after the draft Wright-00 (05) @@ -25,15 +21,15 @@ describe('const', () => { ['keeps a null const that contradicts its type', { const: null, type: 'string' }, { enum: [null], type: 'string' }], ['matches nothing when a non-null const contradicts a null-only type', { const: 7, type: ['null'] }, { enum: [7], not: {} }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) // A value must satisfy both `const` and `enum`. When the const value is in // the enum, the const alone says it all. When it is not, nothing matches // in 3.1, and the single enum is looser (see loosening.test.ts). it('replaces an existing enum with the const value', () => { - expect(convert({ const: 5, enum: [1, 2, 5] })).toEqual({ enum: [5] }) - expect(convert({ const: 5, enum: [1, 2] })).toEqual({ enum: [5] }) + expect(convertSchema({ const: 5, enum: [1, 2, 5] })).toEqual({ enum: [5] }) + expect(convertSchema({ const: 5, enum: [1, 2] })).toEqual({ enum: [5] }) }) }) @@ -46,7 +42,7 @@ describe('enum', () => { ['removes an empty enum', { enum: [], type: 'string' }, { type: 'string' }], ['keeps a non-empty enum', { enum: ['a'], type: 'string' }, { enum: ['a'], type: 'string' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -61,6 +57,6 @@ describe('required', () => { ['deduplicates required names', { required: ['a', 'b', 'a'], type: 'object' }, { required: ['a', 'b'], type: 'object' }], ['passes a malformed required value through', { required: 'junk' }, { required: 'junk' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts new file mode 100644 index 0000000..9658bd7 --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts @@ -0,0 +1,9 @@ +import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' + +/** + * Converts `schema`. The input is typed loosely on purpose: many tests feed + * malformed schemas to check that the conversion tolerates them. + */ +export function convertSchema(schema: unknown): unknown { + return downgradeSchemaV31ToV30(schema as any) +} diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts index ff1c350..725f5aa 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts @@ -3,25 +3,22 @@ import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' import { dig } from '../../helpers' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' describe('input shapes', () => { it('clones non-schema input unchanged', () => { - expect(convert(null)).toBeNull() - expect(convert(42)).toBe(42) - expect(convert('x')).toBe('x') + expect(convertSchema(null)).toBeNull() + expect(convertSchema(42)).toBe(42) + expect(convertSchema('x')).toBe('x') const list = [{ type: 'string' }] - const result = convert(list) + const result = convertSchema(list) expect(result).toEqual(list) expect(result).not.toBe(list) }) it('treats keywords named like Object.prototype members as unknown keywords', () => { const input = JSON.parse('{"constructor":1,"hasOwnProperty":2,"toString":3,"__proto__":{"type":["string","null"]},"type":"string"}') - const result = convert(input) as object + const result = convertSchema(input) as object expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) expect(Object.getOwnPropertyDescriptor(result, 'hasOwnProperty')?.value).toBe(2) expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(3) @@ -32,7 +29,7 @@ describe('input shapes', () => { // JSON.parse creates a real own `__proto__` key, here a property name. // It is converted like any other property. it('converts a property named __proto__ without polluting prototypes', () => { - const properties = dig(convert(JSON.parse('{"properties":{"__proto__":{"type":["string","null"]}}}')), 'properties') as object + const properties = dig(convertSchema(JSON.parse('{"properties":{"__proto__":{"type":["string","null"]}}}')), 'properties') as object expect(Object.getOwnPropertyDescriptor(properties, '__proto__')?.value).toEqual({ nullable: true, type: 'string' }) expect(Object.getPrototypeOf(properties)).toBe(Object.prototype) expect('nullable' in {}).toBe(false) @@ -79,7 +76,7 @@ describe('object graphs', () => { const node: Record = { properties, type: ['object', 'null'] } properties.self = node properties.children = { items: node, type: 'array' } - const result = convert(node) as Record + const result = convertSchema(node) as Record expect(result.type).toBe('object') expect(result.nullable).toBe(true) expect(dig(result, 'properties', 'self')).toBe(result) @@ -91,7 +88,7 @@ describe('object graphs', () => { const grandchild: Record = { type: ['string', 'null'] } const child = { properties: { grandchild }, type: 'object' } grandchild.items = child - const result = convert({ properties: { child }, type: 'object' }) + const result = convertSchema({ properties: { child }, type: 'object' }) const convertedChild = dig(result, 'properties', 'child') expect(dig(convertedChild, 'properties', 'grandchild', 'items')).toBe(convertedChild) }) @@ -101,7 +98,7 @@ describe('object graphs', () => { it('points the array branch of a cyclic multi-type schema at the converted schema', () => { const node: Record = { type: ['array', 'object'] } node.items = node - const result = convert(node) as Record + const result = convertSchema(node) as Record expect(result).not.toHaveProperty('items') expect(dig(result, 'anyOf', '0', 'items')).toBe(result) expect(dig(result, 'anyOf', '1')).toEqual({ type: 'object' }) @@ -116,7 +113,7 @@ describe('object graphs', () => { for (let index = 0; index < 64; index += 1) { node = { properties: { left: node, right: node }, type: 'object' } } - const result = convert(node) + const result = convertSchema(node) expect(dig(result, 'properties', 'left')).toBe(dig(result, 'properties', 'right')) let leaf = result for (let index = 0; index < 64; index += 1) { diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts index 7ef92c5..a745850 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts @@ -13,11 +13,8 @@ // `additionalProperties`, `allOf`, and `anyOf`, and a cut recursion or an // object cycle counts as loosened too. -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { dig } from '../../helpers' +import { convertSchema } from './helpers' describe('not', () => { it.each([ @@ -34,17 +31,12 @@ describe('not', () => { { not: { allOf: [{ anyOf: [{ additionalProperties: { items: { contains: {} } } }] }] } }, {}, ], - ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) - }) - - it.each([ ['keeps a not whose const lies inside its enum', { not: { const: 1, enum: [1, 2] } }, { not: { enum: [1] } }], ['keeps a not whose operand converts exactly', { not: { type: ['string', 'null'] } }, { not: { nullable: true, type: 'string' } }], ['keeps a not whose null-only operand matches nothing exactly', { not: { const: 'a', type: 'null' } }, { not: { enum: ['a'], not: {} } }], ['keeps a not over a boolean schema', { not: false }, { not: { not: {} } }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -58,7 +50,7 @@ describe('oneOf', () => { ], ['keeps a oneOf whose branches convert exactly', { oneOf: [{ type: ['integer', 'null'] }, { type: 'string' }] }, { oneOf: [{ nullable: true, type: 'integer' }, { type: 'string' }] }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -68,12 +60,12 @@ describe('object cycles', () => { it('treats a cycle of the input graph as loosened under not and oneOf', () => { const negated: any = { not: { properties: {} }, patternProperties: { '^x': { type: 'string' } } } negated.not.properties.p = negated - expect(convert(negated)).toEqual({}) + expect(convertSchema(negated)).toEqual({}) const tree: any = { oneOf: [{ required: ['value'], type: 'object' }], unevaluatedProperties: false } tree.oneOf.push({ properties: { children: { items: tree, type: 'array' } }, type: 'object' }) - const out = convert(tree) as any - expect(out.oneOf).toBeUndefined() - expect(out.anyOf[1].properties.children.items).toBe(out) + const out = convertSchema(tree) + expect(out).not.toHaveProperty('oneOf') + expect(dig(out, 'anyOf', '1', 'properties', 'children', 'items')).toBe(out) }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts index eada4a0..c45c92b 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts @@ -8,11 +8,7 @@ // one bound per side, so the tighter of the two is kept. At a tie the // exclusive one is tighter. -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' it.each([ ['turns a numeric exclusiveMinimum into minimum plus the flag', { exclusiveMinimum: 3 }, { exclusiveMinimum: true, minimum: 3 }], @@ -25,12 +21,12 @@ it.each([ ['prefers the exclusive form for equal maximums', { exclusiveMaximum: 5, maximum: 5 }, { exclusiveMaximum: true, maximum: 5 }], ['converts both sides at once', { exclusiveMaximum: 9, exclusiveMinimum: 1 }, { exclusiveMaximum: true, exclusiveMinimum: true, maximum: 9, minimum: 1 }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) it.each([ ['a 3.0-style boolean exclusiveMinimum', { exclusiveMinimum: true, minimum: 3 }], ['a 3.0-style boolean exclusiveMaximum', { exclusiveMaximum: false, maximum: 3 }], ])('passes %s through', (_name, input) => { - expect(convert(input)).toEqual(input) + expect(convertSchema(input)).toEqual(input) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts index 8e5af93..054022b 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts @@ -1,8 +1,4 @@ -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' describe('$ref with sibling keywords', () => { // A 3.0 Reference Object "cannot be extended with additional properties, @@ -17,20 +13,20 @@ describe('$ref with sibling keywords', () => { ['prepends the $ref to an existing allOf', { $ref: '#/c/s', allOf: [{ type: 'string' }] }, { allOf: [{ $ref: '#/c/s' }, { type: 'string' }] }], ['nests a malformed allOf instead of discarding it', { $ref: '#/c/s', allOf: 'junk' }, { allOf: [{ $ref: '#/c/s' }, { allOf: 'junk' }] }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) it('keeps a lone $ref as a bare Reference Object, wherever it points', () => { const input = { $ref: '#/components/schemas/Pet' } - const result = convert(input) + const result = convertSchema(input) expect(result).toEqual(input) expect(result).not.toBe(input) - expect(convert({ $ref: 'https://example.com/pet.json' })).toEqual({ $ref: 'https://example.com/pet.json' }) + expect(convertSchema({ $ref: 'https://example.com/pet.json' })).toEqual({ $ref: 'https://example.com/pet.json' }) }) it('passes a non-string $ref through', () => { - expect(convert({ $ref: 123, type: 'string' })).toEqual({ $ref: 123, type: 'string' }) - expect(convert({ $ref: 123 })).toEqual({ $ref: 123 }) + expect(convertSchema({ $ref: 123, type: 'string' })).toEqual({ $ref: 123, type: 'string' }) + expect(convertSchema({ $ref: 123 })).toEqual({ $ref: 123 }) }) }) @@ -40,7 +36,7 @@ describe('references into removed keywords', () => { // each `$ref` into `$defs` is replaced by the converted definition. // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.4 it('inlines $refs into $defs', () => { - expect(convert({ $defs: { a: { type: ['string', 'null'] } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ + expect(convertSchema({ $defs: { a: { type: ['string', 'null'] } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ items: { nullable: true, type: 'string' }, type: 'array', }) @@ -50,14 +46,14 @@ describe('references into removed keywords', () => { // at its first repeat with `{}`, the schema that accepts anything, so the // result can only be looser than the original, never stricter. it('cuts recursion into {}', () => { - expect(convert({ + expect(convertSchema({ $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, $ref: '#/$defs/node', })).toEqual({ allOf: [{ properties: { next: {} }, type: 'object' }] }) }) it('inlines a definition that is itself an external reference', () => { - expect(convert({ + expect(convertSchema({ $defs: { pet: { $ref: './schemas/pet.yaml' } }, properties: { pet: { $ref: '#/$defs/pet' } }, })).toEqual({ properties: { pet: { $ref: './schemas/pet.yaml' } } }) @@ -67,7 +63,7 @@ describe('references into removed keywords', () => { // placeholder takes its place so the array stays valid 3.0. A `$ref` to // the original `items` must get the original schema, not the placeholder. it('inlines a $ref to items removed beside prefixItems instead of the placeholder that replaced them', () => { - expect(convert({ + expect(convertSchema({ properties: { cell: { $ref: '#/properties/row/items' }, notCell: { not: { $ref: '#/properties/row/items' } }, @@ -89,6 +85,6 @@ describe('references into removed keywords', () => { discriminator: { mapping: { a: '#/webhooks/newPet/post/requestBody/content/application~1json/schema' }, propertyName: 'kind' }, properties: { a: { $ref: '#/webhooks/newPet/post/requestBody/content/application~1json/schema' } }, } - expect(convert(schema)).toEqual(schema) + expect(convertSchema(schema)).toEqual(schema) }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts index 45de9db..9a16a71 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts @@ -4,14 +4,10 @@ // The official 3.0 schema rejects them (`additionalProperties: false`), so // JSON Schema 2020-12 keywords without a 3.0 form are removed. -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' it('removes every keyword with no 3.0 equivalent', () => { - expect(convert({ + expect(convertSchema({ $anchor: 'a', $comment: 'c', $defs: { D: { type: 'string' } }, @@ -57,17 +53,14 @@ it.each([ { additionalProperties: { type: 'integer' }, patternProperties: { '^x-': {} }, type: 'object' }, { type: 'object' }, ], + // An array keeps its `type`, so it needs an `items` again once the removed + // `prefixItems` took the original one with it. + ['gives an array that lost its items an empty one', { items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' }, { items: {}, type: 'array' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) -}) - -// An array keeps its `type`, so it needs an `items` again once the removed -// `prefixItems` took the original one with it. -it('gives an array that lost its items an empty one', () => { - expect(convert({ items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' })).toEqual({ items: {}, type: 'array' }) + expect(convertSchema(input)).toEqual(expected) }) it('keeps extensions and unknown keywords', () => { const input = { 'customKeyword': 'v', 'title': 't', 'x-foo': { a: 1 } } - expect(convert(input)).toEqual(input) + expect(convertSchema(input)).toEqual(input) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts index 93b0895..80db7e7 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts @@ -1,8 +1,5 @@ import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' describe('boolean schemas', () => { // `true` and `false` are schemas in JSON Schema 2020-12 that accept @@ -21,7 +18,7 @@ describe('boolean schemas', () => { ['converts a true items schema', { items: true }, { items: {} }], ['converts a false items schema', { items: false }, { items: { not: {} } }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -42,13 +39,13 @@ describe('nested schemas', () => { ['passes a malformed allOf through', { allOf: 'junk' }, { allOf: 'junk' }], ['passes malformed properties through', { properties: 5 }, { properties: 5 }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) // `const`, `default`, and `enum` hold instance data: a value there that // looks like a schema is copied as is. it('does not convert schema-like values outside subschema positions', () => { const data = { type: ['string', 'null'] } - expect(convert({ 'default': data, 'enum': [data], 'x-data': data })).toEqual({ 'default': data, 'enum': [data], 'x-data': data }) + expect(convertSchema({ 'default': data, 'enum': [data], 'x-data': data })).toEqual({ 'default': data, 'enum': [data], 'x-data': data }) }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts index e0be279..b6da324 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts @@ -6,11 +6,7 @@ // The official guide shows the same mapping in the other direction: // https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#replace-nullable-with-type-arrays -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -function convert(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} +import { convertSchema } from './helpers' describe('a single type', () => { it.each([ @@ -19,7 +15,7 @@ describe('a single type', () => { ['deduplicates entries', { type: ['string', 'string'] }, { type: 'string' }], ['ignores non-string entries beside valid ones', { type: ['string', 42] }, { type: 'string' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -31,19 +27,13 @@ describe('only null', () => { ['turns type: ["null"] into a null enum', { type: ['null'] }, { enum: [null] }], ['narrows an existing enum that allows null', { enum: ['a', null], type: ['null'] }, { enum: [null] }], ['converts a null-only anyOf branch', { anyOf: [{ type: 'string' }, { type: 'null' }] }, { anyOf: [{ type: 'string' }, { enum: [null] }] }], + // Here the 3.1 schema accepts nothing at all: the value must be null and + // also one of the enum values, none of which is null. `not: {}` keeps + // that meaning, since `{}` accepts everything. + ['matches nothing when the enum of a null-only type excludes null', { enum: ['a'], type: ['null'] }, { enum: ['a'], not: {} }], + ['drops the type beside a malformed enum', { enum: 'junk', type: ['null'] }, { enum: 'junk' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) - }) - - // Here the 3.1 schema accepts nothing at all: the value must be null and - // also one of the enum values, none of which is null. `not: {}` keeps - // that meaning, since `{}` accepts everything. - it('matches nothing when the enum of a null-only type excludes null', () => { - expect(convert({ enum: ['a'], type: ['null'] })).toEqual({ enum: ['a'], not: {} }) - }) - - it('drops the type beside a malformed enum', () => { - expect(convert({ enum: 'junk', type: ['null'] })).toEqual({ enum: 'junk' }) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -57,14 +47,9 @@ describe('several types', () => { { type: ['string', 'integer', 'null'] }, { anyOf: [{ nullable: true, type: 'string' }, { nullable: true, type: 'integer' }] }, ], - ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) - }) - - // 3.0 requires `items` wherever `type` is `"array"`, so the array branch - // takes the sibling `items`, or an empty one when there is none. Other - // types ignore `items`, so it moves rather than being copied. - it.each([ + // 3.0 requires `items` wherever `type` is `"array"`, so the array branch + // takes the sibling `items`, or an empty one when there is none. Other + // types ignore `items`, so it moves rather than being copied. ['gives the array branch an empty items', { type: ['array', 'string'] }, { anyOf: [{ items: {}, type: 'array' }, { type: 'string' }] }], [ 'moves a sibling items into the array branch', @@ -76,13 +61,8 @@ describe('several types', () => { { items: { type: 'integer' }, type: ['object', 'string'] }, { anyOf: [{ type: 'object' }, { type: 'string' }], items: { type: 'integer' } }, ], - ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) - }) - - // An existing `anyOf` must keep applying too, so the type union joins - // `allOf` rather than replacing or merging into it. - it.each([ + // An existing `anyOf` must keep applying too, so the type union joins + // `allOf` rather than replacing or merging into it. [ 'wraps the union into allOf when anyOf already exists', { anyOf: [{ minLength: 1 }], type: ['string', 'integer'] }, @@ -102,7 +82,7 @@ describe('several types', () => { }, ], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) // Each level moves `items` into one branch instead of copying it into @@ -114,7 +94,7 @@ describe('several types', () => { input = { items: input, type: ['array', 'object'] } expected = { anyOf: [{ items: expected, type: 'array' }, { type: 'object' }] } } - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -126,7 +106,7 @@ describe('arrays', () => { ['adds an empty items to an array without one', { type: 'array' }, { items: {}, type: 'array' }], ['adds an empty items to a nullable array without one', { type: ['array', 'null'] }, { items: {}, nullable: true, type: 'array' }], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -137,6 +117,6 @@ describe('malformed type values', () => { ['passes an object through', { type: { a: 1 } }, { type: { a: 1 } }], ['drops an empty array', { type: [] }, {}], ])('%s', (_name, input, expected) => { - expect(convert(input)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) 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 b69cffb..5c2799f 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 @@ -9,7 +9,6 @@ it('removes pathItems and keeps the other component maps', () => { }, }) expect(result.components).toEqual({ schemas: { S: { type: 'string' } } }) - expect(result.components).not.toHaveProperty('x-pathItems') }) it('converts component callbacks and schemas, including boolean schemas', () => { diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts index 27b8585..83c9ace 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts @@ -1,8 +1,6 @@ -// The official 3.1 documents, from OAI/learn.openapis.org and from the -// `tests/schema/pass` folder of OAI/OpenAPI-Specification (see -// packages/types/tests/README.md). Each one must downgrade to a document the -// official 3.0 JSON Schema accepts, without leaving a reference dangling that -// resolved before. +// Each official 3.1 document (see tests/corpus.ts) must downgrade to a +// document the official 3.0 JSON Schema accepts, without leaving a reference +// dangling that resolved before. import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' @@ -11,95 +9,14 @@ import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' import { doc as nonOauthScopesExample } from '../../../../types/tests/examples/non-oauth-scopes-3-1' import { doc as petstore } from '../../../../types/tests/examples/petstore-3-0' import { doc as tictactoe } from '../../../../types/tests/examples/tictactoe-3-1' -import { doc as webhookExampleDoc } from '../../../../types/tests/examples/webhook-example-3-1' -import { doc as callbackObjectExamples } from '../../../../types/tests/schema-tests-3.1/callback-object-examples' -import { doc as compPathitems } from '../../../../types/tests/schema-tests-3.1/comp-pathitems' -import { doc as componentsObjectExample } from '../../../../types/tests/schema-tests-3.1/components-object-example' -import { doc as exampleObjectExamples } from '../../../../types/tests/schema-tests-3.1/example-object-examples' -import { doc as headerObjectExamples } from '../../../../types/tests/schema-tests-3.1/header-object-examples' -import { doc as infoObjectExample } from '../../../../types/tests/schema-tests-3.1/info-object-example' -import { doc as infoSummary } from '../../../../types/tests/schema-tests-3.1/info-summary' -import { doc as jsonSchemaDialect } from '../../../../types/tests/schema-tests-3.1/json-schema-dialect' -import { doc as licenseIdentifier } from '../../../../types/tests/schema-tests-3.1/license-identifier' -import { doc as linkObjectExamples } from '../../../../types/tests/schema-tests-3.1/link-object-examples' -import { doc as mediaTypeExamples } from '../../../../types/tests/schema-tests-3.1/media-type-examples' +import { doc as webhookExample } from '../../../../types/tests/examples/webhook-example-3-1' import { doc as mega } from '../../../../types/tests/schema-tests-3.1/mega' -import { doc as minimalComp } from '../../../../types/tests/schema-tests-3.1/minimal-comp' -import { doc as minimalHooks } from '../../../../types/tests/schema-tests-3.1/minimal-hooks' -import { doc as minimalPaths } from '../../../../types/tests/schema-tests-3.1/minimal-paths' -import { doc as nonOauthScopes } from '../../../../types/tests/schema-tests-3.1/non-oauth-scopes' -import { doc as operationObjectExample } from '../../../../types/tests/schema-tests-3.1/operation-object-example' -import { doc as parameterObjectExamples } from '../../../../types/tests/schema-tests-3.1/parameter-object-examples' -import { doc as parameterObjectQueryAllowReserved } from '../../../../types/tests/schema-tests-3.1/parameter-object-query-allow-reserved' -import { doc as pathItemObjectExample } from '../../../../types/tests/schema-tests-3.1/path-item-object-example' -import { doc as pathItemServersParameters } from '../../../../types/tests/schema-tests-3.1/path-item-servers-parameters' -import { doc as pathNoResponse } from '../../../../types/tests/schema-tests-3.1/path-no-response' -import { doc as pathVarEmptyPathitem } from '../../../../types/tests/schema-tests-3.1/path-var-empty-pathitem' -import { doc as pathsObjectExample } from '../../../../types/tests/schema-tests-3.1/paths-object-example' -import { doc as requestBodyExamples } from '../../../../types/tests/schema-tests-3.1/request-body-examples' -import { doc as responseObjectExamples } from '../../../../types/tests/schema-tests-3.1/response-object-examples' -import { doc as schema } from '../../../../types/tests/schema-tests-3.1/schema' -import { doc as schemaObjectDeprecatedExampleKeyword } from '../../../../types/tests/schema-tests-3.1/schema-object-deprecated-example-keyword' -import { doc as servers } from '../../../../types/tests/schema-tests-3.1/servers' -import { doc as specificationExtensions } from '../../../../types/tests/schema-tests-3.1/specification-extensions' -import { doc as tagObjectExample } from '../../../../types/tests/schema-tests-3.1/tag-object-example' -import { doc as validSchemaTypes } from '../../../../types/tests/schema-tests-3.1/valid-schema-types' -import { doc as webhookExample } from '../../../../types/tests/schema-tests-3.1/webhook-example' -import { expectNoNewDanglingRefs, expectValidAs } from '../../helpers' - -// Left out: -// - security-scheme-object-examples, whose external `$ref` the validator -// cannot resolve -// - style-defaults, which puts an `x-comment` in an Encoding Object; the -// official 3.0 schema rejects extensions there -const corpus: readonly (readonly [name: string, doc: OpenAPIV3_1.OpenAPIObject])[] = [ - ['examples/non-oauth-scopes-3-1', nonOauthScopesExample], - ['examples/tictactoe-3-1', tictactoe], - ['examples/webhook-example-3-1', webhookExampleDoc], - ['callback-object-examples', callbackObjectExamples], - ['comp-pathitems', compPathitems], - ['components-object-example', componentsObjectExample], - ['example-object-examples', exampleObjectExamples], - ['header-object-examples', headerObjectExamples], - ['info-object-example', infoObjectExample], - ['info-summary', infoSummary], - ['json-schema-dialect', jsonSchemaDialect], - ['license-identifier', licenseIdentifier], - ['link-object-examples', linkObjectExamples], - ['media-type-examples', mediaTypeExamples], - ['mega', mega], - ['minimal-comp', minimalComp], - ['minimal-hooks', minimalHooks], - ['minimal-paths', minimalPaths], - ['non-oauth-scopes', nonOauthScopes], - ['operation-object-example', operationObjectExample], - ['parameter-object-examples', parameterObjectExamples], - ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReserved], - ['path-item-object-example', pathItemObjectExample], - ['path-item-servers-parameters', pathItemServersParameters], - ['path-no-response', pathNoResponse], - ['path-var-empty-pathitem', pathVarEmptyPathitem], - ['paths-object-example', pathsObjectExample], - ['request-body-examples', requestBodyExamples], - ['response-object-examples', responseObjectExamples], - ['schema', schema], - ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeyword], - ['servers', servers], - ['specification-extensions', specificationExtensions], - ['tag-object-example', tagObjectExample], - ['valid-schema-types', validSchemaTypes], - ['webhook-example', webhookExample], -] +import { corpusV31 } from '../../corpus' +import { expectValidAs, expectValidDowngrade } from '../../validate' describe('official corpus', () => { - it.each(corpus)('converts %s to a valid 3.0 document without new dangling references or mutating the input', async (_name, doc) => { - await expectValidAs(doc, '3.1') - const before = structuredClone(doc) - const v30 = downgradeSpecV31ToV30(doc) - expect(v30.openapi).toBe('3.0.4') - await expectValidAs(v30, '3.0') - expectNoNewDanglingRefs(doc, v30) - expect(doc).toEqual(before) + it.each(corpusV31)('converts %s to a valid 3.0 document', async (_name, doc) => { + await expectValidDowngrade(doc, downgradeSpecV31ToV30, '3.1', '3.0') }) }) @@ -109,7 +26,7 @@ describe('official examples', () => { }) it('removes the webhooks of the webhook example, leaving empty paths', () => { - const v30 = downgradeSpecV31ToV30(webhookExampleDoc) + const v30 = downgradeSpecV31ToV30(webhookExample) expect(v30).not.toHaveProperty('webhooks') expect(v30.paths).toEqual({}) expect(v30.components).toHaveProperty(['schemas', 'Pet']) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts index 4711755..ae0782d 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts @@ -1,6 +1,9 @@ import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' -import { convertSpec, empty, info } from './helpers' +import { convertSpec, info } from './helpers' + +/** The converted form of a document holding nothing but `info`. */ +const empty = { info, openapi: '3.0.4', paths: {} } describe('openapi and paths', () => { it('stamps 3.0.4, the latest 3.0 patch release', () => { @@ -38,13 +41,12 @@ describe('3.1-only root fields', () => { // operations, not as standalone webhooks, and an `x-` extension would // only hide them from tools, so webhooks are removed. // References into them are inlined (see removed-parts.test.ts). - it('removes jsonSchemaDialect and webhooks without leaving extensions behind', () => { + it('removes jsonSchemaDialect and webhooks without leaving an extension behind', () => { const result = convertSpec({ jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', webhooks: { newPet: { post: { summary: 's' } } }, }) expect(result).toEqual(empty) - expect(result).not.toHaveProperty('x-webhooks') }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts index 5259900..ff5bbe7 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts @@ -28,14 +28,10 @@ import { convertComponent, convertSpec } from './helpers' const octetStream = { contentType: 'application/octet-stream' } +const schemas = { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} } + function convertForm(mediaType: unknown, type = 'multipart/form-data'): unknown { - const result = convertSpec({ - components: { - requestBodies: { X: { content: { [type]: mediaType } } }, - schemas: { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} }, - }, - }) - return dig(result, 'components', 'requestBodies', 'X', 'content', type) + return dig(convertComponent('requestBodies', { content: { [type]: mediaType } }, { schemas }), 'content', type) } describe('parts that need the 3.1 default written out', () => { diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts index 7afee65..49af9a7 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts @@ -6,8 +6,17 @@ import { dig } from '../../helpers' export const info = { title: 't', version: '1' } -/** The converted form of a document holding nothing but `info`. */ -export const empty = { info, openapi: '3.0.4', paths: {} } +/** Matches a pointer into `webhooks` or `components.pathItems`, which 3.0 removes. */ +export const removedPointer = /#\/(?:webhooks|components\/pathItems)/ + +/** The request body schema of the `newPet` webhook. */ +export const webhookSchemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' + +/** A Path Item to put in `components.pathItems`. */ +export const item = { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], +} /** * Converts a 3.1 document built from `fields`. The input is typed loosely on @@ -19,8 +28,8 @@ export function convertSpec(fields: Record): OpenAPIV3_0.OpenAP } /** Converts `pathItem` as the only entry of `paths` and returns it. */ -export function convertPathItem(pathItem: unknown, components?: Record): unknown { - return dig(convertSpec({ ...(components && { components }), paths: { '/a': pathItem } }), 'paths', '/a') +export function convertPathItem(pathItem: unknown): unknown { + return dig(convertSpec({ paths: { '/a': pathItem } }), 'paths', '/a') } /** Converts `value` as the entry `X` of the `kind` component map and returns it. */ diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts index a1ca77b..4a9d84d 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts @@ -7,12 +7,7 @@ // Object that resolves to such a Link. import { dig } from '../../helpers' -import { convertSpec } from './helpers' - -const item = { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], -} +import { convertSpec, item, removedPointer } from './helpers' describe('links', () => { it('removes links whose operationRef points into the removed parts, together with references to them', () => { @@ -80,7 +75,7 @@ describe('links', () => { refUnknown: { $ref: '#/components/links/Unknown' }, }) expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({}) - expect(JSON.stringify(result)).not.toMatch(/#\/(?:webhooks|components\/pathItems)/) + expect(JSON.stringify(result)).not.toMatch(removedPointer) }) // `/a` inlines the webhook but defines its own `post`, which wins, so the diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts index 899a62d..9e35257 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts @@ -21,19 +21,15 @@ const inlined = { summary: 'Reusable', } -function convertWithPathItems(paths: unknown, pathItems: unknown, extra: Record = {}) { - return convertSpec({ components: { pathItems, ...extra }, paths }) -} - describe('inlining', () => { it('inlines the converted entry and lets the referencing fields win', () => { - const result = convertWithPathItems( - { + const result = convertSpec({ + components: { pathItems: { Reusable: reusable } }, + paths: { '/a': { $ref: '#/components/pathItems/Reusable' }, '/b': { $ref: '#/components/pathItems/Reusable', description: 'own', summary: 'Own summary' }, }, - { Reusable: reusable }, - ) + }) expect(result.components).toEqual({}) expect(result.paths).toEqual({ '/a': inlined, @@ -43,27 +39,30 @@ describe('inlining', () => { // Each hop of a chain adds the fields the hops before it did not set. it('follows chains of path item references, merging the fields of every hop', () => { - expect(convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Alias', summary: 'Own' } }, - { - Alias: { $ref: '#/components/pathItems/Reusable', description: 'alias' }, - Reusable: reusable, + expect(convertSpec({ + components: { + pathItems: { + Alias: { $ref: '#/components/pathItems/Reusable', description: 'alias' }, + Reusable: reusable, + }, }, - ).paths).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) + paths: { '/a': { $ref: '#/components/pathItems/Alias', summary: 'Own' } }, + }).paths).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) }) // The target is an external reference, which stays valid, so the result is // that reference with the referencing Path Item's fields beside it. it('inlines an entry that references an external file', () => { - expect(convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/External', summary: 'Own' } }, - { External: { $ref: './paths/a.yaml' } }, - ).paths).toEqual({ '/a': { $ref: './paths/a.yaml', summary: 'Own' } }) + expect(convertSpec({ + components: { pathItems: { External: { $ref: './paths/a.yaml' } } }, + paths: { '/a': { $ref: '#/components/pathItems/External', summary: 'Own' } }, + }).paths).toEqual({ '/a': { $ref: './paths/a.yaml', summary: 'Own' } }) }) it('inlines references inside callbacks', () => { - expect(convertWithPathItems( - { + expect(convertSpec({ + components: { pathItems: { Reusable: reusable } }, + paths: { '/a': { post: { callbacks: { onEvent: { '{$request.body#/url}': { $ref: '#/components/pathItems/Reusable' } } }, @@ -71,19 +70,19 @@ describe('inlining', () => { }, }, }, - { Reusable: reusable }, - ).paths).toEqual({ + }).paths).toEqual({ '/a': { post: { callbacks: { onEvent: { '{$request.body#/url}': inlined } }, responses: {} } }, }) }) it('converts the inlined path item like any other, removing mutualTLS requirements', () => { - const result = convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Secured' } }, - { Secured: { get: { responses: {}, security: [{ mtls: [] }, { api: ['r'] }] } } }, - { securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } } }, - ) - expect(result.paths).toEqual({ '/a': { get: { responses: {}, security: [{ api: [] }] } } }) + expect(convertSpec({ + components: { + pathItems: { Secured: { get: { responses: {}, security: [{ mtls: [] }, { api: ['r'] }] } } }, + securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } }, + }, + paths: { '/a': { $ref: '#/components/pathItems/Secured' } }, + }).paths).toEqual({ '/a': { get: { responses: {}, security: [{ api: [] }] } } }) }) }) @@ -98,17 +97,19 @@ describe('references left as written', () => { ['a prototype member', '#/components/pathItems/hasOwnProperty', {}], ['a malformed pathItems map', '#/components/pathItems/Reusable', 'junk'], ])('leaves a reference to %s untouched', (_name, ref, pathItems) => { - expect(convertWithPathItems({ '/a': { $ref: ref, summary: 's' } }, pathItems).paths).toEqual({ '/a': { $ref: ref, summary: 's' } }) + expect(convertSpec({ components: { pathItems }, paths: { '/a': { $ref: ref, summary: 's' } } }).paths).toEqual({ + '/a': { $ref: ref, summary: 's' }, + }) }) // `#/components/pathItems/Reusable/get` resolves to an Operation. A Path // Item `$ref` must point at a Path Item, so this one is not merged; it is // left as written, like a reference to any other invalid target. it('leaves a reference to something that is not a path item untouched', () => { - expect(convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }, - { Reusable: reusable }, - ).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }) + expect(convertSpec({ + components: { pathItems: { Reusable: reusable } }, + paths: { '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }, + }).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }) }) it('leaves a reference untouched when components.pathItems is missing', () => { @@ -116,13 +117,15 @@ describe('references left as written', () => { }) it('leaves a chain that loops without reaching a path item as written', () => { - expect(convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, - { - Ping: { $ref: '#/components/pathItems/Pong', description: 'ping' }, - Pong: { $ref: '#/components/pathItems/Ping' }, + expect(convertSpec({ + components: { + pathItems: { + Ping: { $ref: '#/components/pathItems/Pong', description: 'ping' }, + Pong: { $ref: '#/components/pathItems/Ping' }, + }, }, - ).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }) + paths: { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, + }).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }) }) }) @@ -131,22 +134,24 @@ describe('recursion', () => { // forever. The inner reference keeps only its own fields instead, since a // Path Item has no "accept anything" form like the `{}` schema. it('cuts a path item that reaches itself through its callbacks down to its own fields', () => { - expect(convertWithPathItems( - { '/a': { $ref: '#/components/pathItems/Self' } }, - { - Self: { - post: { - callbacks: { - loop: { - bare: { $ref: '#/components/pathItems/Self' }, - own: { $ref: '#/components/pathItems/Self', summary: 'own' }, + expect(convertSpec({ + components: { + pathItems: { + Self: { + post: { + callbacks: { + loop: { + bare: { $ref: '#/components/pathItems/Self' }, + own: { $ref: '#/components/pathItems/Self', summary: 'own' }, + }, }, + responses: {}, }, - responses: {}, }, }, }, - ).paths).toEqual({ + paths: { '/a': { $ref: '#/components/pathItems/Self' } }, + }).paths).toEqual({ '/a': { post: { callbacks: { loop: { bare: {}, own: { summary: 'own' } } }, responses: {} } }, }) }) @@ -161,24 +166,31 @@ describe('recursion', () => { }, paths: { '/a': { $ref: '#/components/pathItems/A' } }, }) - expect(result.paths?.['/a']).toEqual({ + expect(result.paths['/a']).toEqual({ post: { callbacks: { cb: { expr: { description: 'alias', summary: 'outer' } } }, responses: {} }, }) }) - it('cuts fields inherited from a later hop that lead back into it', () => { + // `A` is both a hop of the chain and the Path Item being inlined. Where the + // inner reference re-enters it, `A` contributes nothing, neither its + // operations nor its plain fields, and only the later hop `T` is merged. + it('cuts a hop that the chain re-enters, merging only the hops after it', () => { const responses = { 200: { description: 'ok' } } const result = convertSpec({ components: { pathItems: { - A: { $ref: '#/components/pathItems/T', post: { callbacks: { c: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses } }, + A: { + $ref: '#/components/pathItems/T', + description: 'a', + post: { callbacks: { c: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses }, + }, T: { summary: 't' }, }, }, paths: { '/p': { $ref: '#/components/pathItems/A' } }, }) expect(result.paths).toEqual({ - '/p': { post: { callbacks: { c: { '{$url}': { summary: 't' } } }, responses }, summary: 't' }, + '/p': { description: 'a', post: { callbacks: { c: { '{$url}': { summary: 't' } } }, responses }, summary: 't' }, }) }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts index b7375b4..b77ff05 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts @@ -6,10 +6,9 @@ // - on a Path Item, the inner reference keeps only its own fields // - anywhere else, the inner Reference Object is removed -import { dig } from '../../helpers' -import { convertSpec } from './helpers' +import { dig, expectAcyclic } from '../../helpers' +import { convertSpec, webhookSchemaPointer } from './helpers' -const schemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' const responses = { 200: { description: 'ok' } } it('cuts recursion into {} for schemas and into own fields for path items, keeping the output acyclic', () => { @@ -47,7 +46,7 @@ it('cuts recursion into {} for schemas and into own fields for path items, keepi pong: { '{$request.body#/url}': {} }, self: { '{$request.body#/url}': {} }, }) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) + expectAcyclic(result) }) it('cuts callbacks that reach back into an enclosing callback', () => { @@ -77,7 +76,7 @@ it('cuts callbacks that reach back into an enclosing callback', () => { }) expect(dig(result, 'paths', '/self', 'post', 'callbacks', 'cb', '{$url}', 'post', 'callbacks')).toEqual({ again: {} }) expect(dig(result, 'paths', '/item', 'post', 'callbacks', 'A', '{$url}', 'post', 'callbacks', 'toB', '{$url}', 'post', 'callbacks')).toEqual({ toA: {} }) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) + expectAcyclic(result) }) it('cuts a callback that reaches back into the path item that contains it', () => { @@ -103,7 +102,7 @@ it('cuts own fields that lead back into a path item still being converted', () = expect(dig(result, 'components', 'callbacks', 'C', '{$url}', 'get', 'callbacks', 'd', '{$url}', 'post', 'callbacks')).toEqual({ c: { '{$url}': { summary: 't' } }, }) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) + expectAcyclic(result) }) describe('object cycles of the input', () => { @@ -113,7 +112,7 @@ describe('object cycles of the input', () => { it('keeps an object cycle that an inlined target also reaches', () => { const a: Record = { properties: {}, type: 'object' } const b = { properties: { back: a }, type: 'object' } - a.properties = { hook: { $ref: schemaPointer }, b } + a.properties = { hook: { $ref: webhookSchemaPointer }, b } const result = convertSpec({ components: { schemas: { A: a } }, webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b }, type: 'object' } } } } } } }, @@ -124,7 +123,7 @@ describe('object cycles of the input', () => { }) it('cuts a reference that comes back to an object shared within the input', () => { - const shared: Record = { properties: { a: { $ref: schemaPointer } }, type: 'object' } + const shared: Record = { properties: { a: { $ref: webhookSchemaPointer } }, type: 'object' } expect(dig(convertSpec({ components: { schemas: { S: shared } }, webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b: shared }, type: 'object' } } } } } } }, @@ -136,7 +135,7 @@ describe('object cycles of the input', () => { it('inlines into a cyclic input graph, preserving its cycle', () => { const node: Record = { type: 'object' } - node.properties = { hook: { $ref: schemaPointer }, self: node } + node.properties = { hook: { $ref: webhookSchemaPointer }, self: node } const result = convertSpec({ components: { schemas: { Node: node } }, webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } } } } } } }, diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts index 2f45af3..57cbab5 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts @@ -4,10 +4,8 @@ // following the reference chain until it leaves the removed parts. import { dig } from '../../helpers' -import { convertSpec } from './helpers' +import { convertSpec, item, removedPointer, webhookSchemaPointer } from './helpers' -const removedPointer = /#\/(?:webhooks|components\/pathItems)/ -const schemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' const hook = { post: { operationId: 'newPetHook', @@ -16,16 +14,17 @@ const hook = { responses: { 200: { description: 'ok' } }, }, } -const hookParameter = { description: 'orig', in: 'header', name: 'X-Hook', schema: { nullable: true, type: 'string' } } -const item = { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], +/** Points into the `full` webhook that several tests below inline from. */ +function full(path: string): string { + return `#/webhooks/full/post/${path}` } +const hookParameter = { description: 'orig', in: 'header', name: 'X-Hook', schema: { nullable: true, type: 'string' } } + describe('inlining', () => { it('inlines references into webhooks and components.pathItems without mutating the input', () => { const input = { - components: { pathItems: { Item: item }, schemas: { Pet: { $ref: schemaPointer } } }, + components: { pathItems: { Item: item }, schemas: { Pet: { $ref: webhookSchemaPointer } } }, paths: { '/a': { get: { @@ -57,7 +56,6 @@ describe('inlining', () => { // a callback's path items get default responses, a parameter in the path // becomes required, schemas lose their 3.1-only keywords, and so on. it('converts each inlined target for its position, in every component map', () => { - const pointer = (path: string) => `#/webhooks/full/post/${path}` const response = { content: { 'application/json': { examples: { e: { value: 1 } } } }, description: 'ok', @@ -65,13 +63,13 @@ describe('inlining', () => { } const result = convertSpec({ components: { - callbacks: { C: { $ref: pointer('callbacks/cb') } }, - examples: { E: { $ref: pointer('responses/200/content/application~1json/examples/e') } }, - headers: { H: { $ref: pointer('responses/200/headers/H') } }, - parameters: { P: { $ref: pointer('parameters/0') } }, - requestBodies: { B: { $ref: pointer('requestBody') } }, - responses: { R: { $ref: pointer('responses/200') } }, - securitySchemes: { S: { $ref: pointer('x-scheme') } }, + callbacks: { C: { $ref: full('callbacks/cb') } }, + examples: { E: { $ref: full('responses/200/content/application~1json/examples/e') } }, + headers: { H: { $ref: full('responses/200/headers/H') } }, + parameters: { P: { $ref: full('parameters/0') } }, + requestBodies: { B: { $ref: full('requestBody') } }, + responses: { R: { $ref: full('responses/200') } }, + securitySchemes: { S: { $ref: full('x-scheme') } }, }, webhooks: { full: { @@ -97,26 +95,25 @@ describe('inlining', () => { }) it('inlines references in operation, path item, media type, parameter, and encoding positions', () => { - const pointer = (path: string) => `#/webhooks/full/post/${path}` expect(convertSpec({ paths: { '/a': { get: { - parameters: [{ examples: { e: { $ref: pointer('x-example') } }, in: 'query', name: 'q' }], - requestBody: { $ref: pointer('requestBody') }, + parameters: [{ examples: { e: { $ref: full('x-example') } }, in: 'query', name: 'q' }], + requestBody: { $ref: full('requestBody') }, responses: { 200: { content: { 'application/json': { - encoding: { f: { headers: { H: { $ref: pointer('x-header') } } } }, - examples: { e: { $ref: pointer('x-example') } }, + encoding: { f: { headers: { H: { $ref: full('x-header') } } } }, + examples: { e: { $ref: full('x-example') } }, }, }, description: 'ok', }, }, }, - parameters: [{ $ref: pointer('x-parameter') }], + parameters: [{ $ref: full('x-parameter') }], }, }, webhooks: { diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts new file mode 100644 index 0000000..579a7f1 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts @@ -0,0 +1,9 @@ +import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' + +/** + * Converts `schema`. The input is typed loosely on purpose: many tests feed + * malformed schemas to check that the conversion tolerates them. + */ +export function convertSchema(schema: unknown): unknown { + return downgradeSchemaV32ToV31(schema as any) +} diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts index d916c34..2d3236c 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts @@ -3,6 +3,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' import { dig } from '../../helpers' +import { convertSchema } from './helpers' describe('input shapes', () => { // `true` and `false` are complete schemas in JSON Schema 2020-12, and 3.1 @@ -13,9 +14,9 @@ describe('input shapes', () => { }) it('passes non-schema input through', () => { - expect(downgradeSchemaV32ToV31('junk' as any)).toBe('junk') - expect(downgradeSchemaV32ToV31(null as any)).toBeNull() - expect(downgradeSchemaV32ToV31([{ type: 'string' }] as any)).toEqual([{ type: 'string' }]) + expect(convertSchema('junk')).toBe('junk') + expect(convertSchema(null)).toBeNull() + expect(convertSchema([{ type: 'string' }])).toEqual([{ type: 'string' }]) }) }) @@ -27,7 +28,7 @@ describe('copies', () => { items: { xml: { nodeType: 'cdata' } }, properties: { a: { xml: { name: 'a' } } }, } - const result = downgradeSchemaV32ToV31(source as any) + const result = convertSchema(source) expect(result).toEqual({ allOf: [{ discriminator: {} }, true], discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, @@ -89,7 +90,7 @@ describe('object graphs', () => { it('converts a subschema shared by several positions once', () => { const shared = { xml: { nodeType: 'attribute' } } - const result = downgradeSchemaV32ToV31({ properties: { a: shared, b: shared } } as any) + const result = convertSchema({ properties: { a: shared, b: shared } }) expect(dig(result, 'properties', 'a')).toEqual({ xml: { attribute: true } }) expect(dig(result, 'properties', 'b')).toBe(dig(result, 'properties', 'a')) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts index 00c065e..ec0ecd0 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts @@ -5,6 +5,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' +import { convertSchema } from './helpers' describe('xml.nodeType', () => { // 3.2 replaces the `attribute` and `wrapped` flags with `nodeType`, one of @@ -29,7 +30,7 @@ describe('xml.nodeType', () => { ['keeps an xml object without nodeType', { xml: { name: 'n', prefix: 'p' } }, { xml: { name: 'n', prefix: 'p' } }], ['passes a malformed xml value through', { xml: 'junk' }, { xml: 'junk' }], ])('%s', (_name, input, expected) => { - expect(downgradeSchemaV32ToV31(input as any)).toEqual(expected) + expect(convertSchema(input)).toEqual(expected) }) }) @@ -49,8 +50,8 @@ describe('discriminator.defaultMapping', () => { }) it('passes a malformed discriminator or mapping through', () => { - expect(downgradeSchemaV32ToV31({ discriminator: 'junk' } as any)).toEqual({ discriminator: 'junk' }) - expect(downgradeSchemaV32ToV31({ discriminator: { mapping: 'junk', propertyName: 'kind' } } as any)).toEqual({ + expect(convertSchema({ discriminator: 'junk' })).toEqual({ discriminator: 'junk' }) + expect(convertSchema({ discriminator: { mapping: 'junk', propertyName: 'kind' } })).toEqual({ discriminator: { mapping: 'junk', propertyName: 'kind' }, }) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts index 8e63746..0f970a7 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts @@ -1,6 +1,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' +import { convertSchema } from './helpers' // Converting a standalone schema removes nothing a `$ref` could point at: // `$defs`, `$id`, and `$anchor` all exist in 3.1. So every reference stays as @@ -29,7 +30,7 @@ it('leaves references into converted keywords as written', () => { properties: { a: { $ref: '#/xml' }, b: { $ref: '#/discriminator/defaultMapping' } }, xml: { nodeType: 'attribute' }, } - expect(downgradeSchemaV32ToV31(schema as any)).toEqual({ + expect(convertSchema(schema)).toEqual({ ...schema, discriminator: { propertyName: 'kind' }, xml: { attribute: true }, diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts index fa53b8f..321f2c4 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts @@ -1,4 +1,5 @@ import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' +import { convertSchema } from './helpers' const inner = { discriminator: { defaultMapping: 'A', propertyName: 'kind' } } const converted = { discriminator: { propertyName: 'kind' } } @@ -10,11 +11,11 @@ const lists = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] const maps = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'] it('converts nested schemas at every subschema position', () => { - expect(downgradeSchemaV32ToV31({ + expect(convertSchema({ ...Object.fromEntries(single.map(key => [key, inner])), ...Object.fromEntries(lists.map(key => [key, [inner, true]])), ...Object.fromEntries(maps.map(key => [key, { a: inner }])), - } as any)).toEqual({ + })).toEqual({ ...Object.fromEntries(single.map(key => [key, converted])), ...Object.fromEntries(lists.map(key => [key, [converted, true]])), ...Object.fromEntries(maps.map(key => [key, { a: converted }])), @@ -26,7 +27,7 @@ it('converts nested schemas at every subschema position', () => { // one, so it is copied as is. it('does not convert schema-like values outside subschema positions', () => { const schema = { 'const': inner, 'default': inner, 'enum': [inner], 'examples': [inner], 'x-extension': inner } - expect(downgradeSchemaV32ToV31(schema as any)).toEqual(schema) + expect(convertSchema(schema)).toEqual(schema) }) it('converts nested schemas at any depth', () => { @@ -38,5 +39,5 @@ it('converts nested schemas at any depth', () => { }) it('clones malformed subschema containers through', () => { - expect(downgradeSchemaV32ToV31({ allOf: 'junk', properties: 5 } as any)).toEqual({ allOf: 'junk', properties: 5 }) + expect(convertSchema({ allOf: 'junk', properties: 5 })).toEqual({ allOf: 'junk', properties: 5 }) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts index 58f2821..3718faa 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts @@ -8,7 +8,7 @@ // A reference that cannot be resolved inside the document (external, // missing, or looping) cannot be kept either, so its entry is removed. -import { dig } from '../../helpers' +import { dig, expectAcyclic } from '../../helpers' import { convertContent, convertPathItem, convertSpec } from './helpers' describe('inlining', () => { @@ -124,10 +124,7 @@ describe('references that cannot be inlined', () => { it('removes entries when components.mediaTypes is missing or malformed', () => { const content = { 'application/json': { $ref: '#/components/mediaTypes/A' } } - expect(convertSpec({ paths: { '/a': { post: { requestBody: { content }, responses: {} } } } })).toEqual({ - openapi: '3.1.2', - paths: { '/a': { post: { requestBody: { content: {} }, responses: {} } } }, - }) + expect(convertContent(content)).toEqual({}) expect(convertContent(content, { mediaTypes: 'junk' })).toEqual({}) }) @@ -253,7 +250,7 @@ describe('recursive media types', () => { }, }, }) - expect(() => JSON.stringify(result)).not.toThrow() + expectAcyclic(result) expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toEqual({ properties: { children: { items: {}, type: 'array' } }, type: 'object', diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts index 0cbb16c..34213f5 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts @@ -1,8 +1,6 @@ -// The official 3.2 documents, from OAI/learn.openapis.org and from the -// `tests/schema/pass` folder of OAI/OpenAPI-Specification (see -// packages/types/tests/README.md). Each one must downgrade to a document the -// official 3.1 JSON Schema accepts, without leaving a reference dangling that -// resolved before. +// Each official 3.2 document (see tests/corpus.ts) must downgrade to a +// document the official 3.1 JSON Schema accepts, without leaving a reference +// dangling that resolved before. import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' @@ -10,96 +8,13 @@ import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' import { doc as queryExample } from '../../../../types/tests/examples/3-2-query-example' import { doc as tagsExample } from '../../../../types/tests/examples/3-2-tags-example' -import { doc as callbackObjectExamples } from '../../../../types/tests/schema-tests-3.2/callback-object-examples' -import { doc as compPathitems } from '../../../../types/tests/schema-tests-3.2/comp-pathitems' -import { doc as componentsObjectExample } from '../../../../types/tests/schema-tests-3.2/components-object-example' -import { doc as exampleObjectExamples } from '../../../../types/tests/schema-tests-3.2/example-object-examples' -import { doc as headerObjectExamples } from '../../../../types/tests/schema-tests-3.2/header-object-examples' -import { doc as infoObjectExample } from '../../../../types/tests/schema-tests-3.2/info-object-example' -import { doc as infoSummary } from '../../../../types/tests/schema-tests-3.2/info-summary' -import { doc as jsonSchemaDialect } from '../../../../types/tests/schema-tests-3.2/json-schema-dialect' -import { doc as licenseIdentifier } from '../../../../types/tests/schema-tests-3.2/license-identifier' -import { doc as linkObjectExamples } from '../../../../types/tests/schema-tests-3.2/link-object-examples' -import { doc as mediaTypeExamples } from '../../../../types/tests/schema-tests-3.2/media-type-examples' import { doc as mega } from '../../../../types/tests/schema-tests-3.2/mega' -import { doc as minimalComp } from '../../../../types/tests/schema-tests-3.2/minimal-comp' -import { doc as minimalHooks } from '../../../../types/tests/schema-tests-3.2/minimal-hooks' -import { doc as minimalPaths } from '../../../../types/tests/schema-tests-3.2/minimal-paths' -import { doc as nonOauthScopes } from '../../../../types/tests/schema-tests-3.2/non-oauth-scopes' -import { doc as operationObjectExample } from '../../../../types/tests/schema-tests-3.2/operation-object-example' -import { doc as parameterObjectCookieFormAllowReserved } from '../../../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved' -import { doc as parameterObjectExamples } from '../../../../types/tests/schema-tests-3.2/parameter-object-examples' -import { doc as parameterObjectPathAllowReserved } from '../../../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved' -import { doc as parameterObjectQueryAllowReserved } from '../../../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved' -import { doc as pathItemObjectExample } from '../../../../types/tests/schema-tests-3.2/path-item-object-example' -import { doc as pathItemServersParameters } from '../../../../types/tests/schema-tests-3.2/path-item-servers-parameters' -import { doc as pathNoResponse } from '../../../../types/tests/schema-tests-3.2/path-no-response' -import { doc as pathVarEmptyPathitem } from '../../../../types/tests/schema-tests-3.2/path-var-empty-pathitem' -import { doc as pathsObjectExample } from '../../../../types/tests/schema-tests-3.2/paths-object-example' -import { doc as requestBodyExamples } from '../../../../types/tests/schema-tests-3.2/request-body-examples' -import { doc as responseObjectExamples } from '../../../../types/tests/schema-tests-3.2/response-object-examples' -import { doc as schema } from '../../../../types/tests/schema-tests-3.2/schema' -import { doc as schemaObjectDeprecatedExampleKeyword } from '../../../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword' -import { doc as servers } from '../../../../types/tests/schema-tests-3.2/servers' -import { doc as specificationExtensions } from '../../../../types/tests/schema-tests-3.2/specification-extensions' -import { doc as styleDefaults } from '../../../../types/tests/schema-tests-3.2/style-defaults' -import { doc as tagObjectExample } from '../../../../types/tests/schema-tests-3.2/tag-object-example' -import { doc as validSchemaTypes } from '../../../../types/tests/schema-tests-3.2/valid-schema-types' -import { doc as webhookExample } from '../../../../types/tests/schema-tests-3.2/webhook-example' -import { expectNoNewDanglingRefs, expectValidAs } from '../../helpers' - -// Left out: security-scheme-object-examples, whose external `$ref` the -// validator cannot resolve. -const corpus: readonly (readonly [name: string, doc: OpenAPIV3_2.OpenAPIObject])[] = [ - ['examples/3-2-query-example', queryExample], - ['examples/3-2-tags-example', tagsExample], - ['callback-object-examples', callbackObjectExamples], - ['comp-pathitems', compPathitems], - ['components-object-example', componentsObjectExample], - ['example-object-examples', exampleObjectExamples], - ['header-object-examples', headerObjectExamples], - ['info-object-example', infoObjectExample], - ['info-summary', infoSummary], - ['json-schema-dialect', jsonSchemaDialect], - ['license-identifier', licenseIdentifier], - ['link-object-examples', linkObjectExamples], - ['media-type-examples', mediaTypeExamples], - ['mega', mega], - ['minimal-comp', minimalComp], - ['minimal-hooks', minimalHooks], - ['minimal-paths', minimalPaths], - ['non-oauth-scopes', nonOauthScopes], - ['operation-object-example', operationObjectExample], - ['parameter-object-cookie-form-allow-reserved', parameterObjectCookieFormAllowReserved], - ['parameter-object-examples', parameterObjectExamples], - ['parameter-object-path-allow-reserved', parameterObjectPathAllowReserved], - ['parameter-object-query-allow-reserved', parameterObjectQueryAllowReserved], - ['path-item-object-example', pathItemObjectExample], - ['path-item-servers-parameters', pathItemServersParameters], - ['path-no-response', pathNoResponse], - ['path-var-empty-pathitem', pathVarEmptyPathitem], - ['paths-object-example', pathsObjectExample], - ['request-body-examples', requestBodyExamples], - ['response-object-examples', responseObjectExamples], - ['schema', schema], - ['schema-object-deprecated-example-keyword', schemaObjectDeprecatedExampleKeyword], - ['servers', servers], - ['specification-extensions', specificationExtensions], - ['style-defaults', styleDefaults], - ['tag-object-example', tagObjectExample], - ['valid-schema-types', validSchemaTypes], - ['webhook-example', webhookExample], -] +import { corpusV32 } from '../../corpus' +import { expectValidAs, expectValidDowngrade } from '../../validate' describe('official corpus', () => { - it.each(corpus)('converts %s to a valid 3.1 document without new dangling references or mutating the input', async (_name, doc) => { - await expectValidAs(doc, '3.2') - const before = structuredClone(doc) - const v31 = downgradeSpecV32ToV31(doc) - expect(v31.openapi).toBe('3.1.2') - await expectValidAs(v31, '3.1') - expectNoNewDanglingRefs(doc, v31) - expect(doc).toEqual(before) + it.each(corpusV32)('converts %s to a valid 3.1 document', async (_name, doc) => { + await expectValidDowngrade(doc, downgradeSpecV32ToV31, '3.2', '3.1') }) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts index 429f14b..bafb6e4 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts @@ -14,8 +14,8 @@ export function convertSpec(fields: Record): OpenAPIV3_1.OpenAP } /** Converts `pathItem` as the only entry of `paths` and returns it. */ -export function convertPathItem(pathItem: unknown, components: Record = {}): unknown { - return dig(convertSpec({ components, paths: { '/a': pathItem } }), 'paths', '/a') +export function convertPathItem(pathItem: unknown, components?: Record): unknown { + return dig(convertSpec({ ...(components && { components }), paths: { '/a': pathItem } }), 'paths', '/a') } /** Converts `value` as the entry `X` of the `kind` component map and returns it. */ @@ -24,7 +24,7 @@ export function convertComponent(kind: string, value: unknown, components: Recor } /** Converts `content` as the content map of a request body and returns it. */ -export function convertContent(content: unknown, components: Record = {}): unknown { +export function convertContent(content: unknown, components?: Record): unknown { return dig( convertPathItem({ post: { requestBody: { content }, responses: {} } }, components), 'post', diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts index 89b6fc1..5bd4aa6 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts @@ -175,7 +175,7 @@ describe('shared objects and cycles', () => { }, }, { mediaTypes: { Pet: { schema: { type: 'string' } } } }, - ), 'get', 'responses') as Record + ), 'get', 'responses') expect(dig(content, '200', 'content', 'a/b')).toEqual({ schema: { type: 'string' } }) expect(dig(content, '201', 'content', 'a/b')).toBe(dig(content, '200', 'content', 'a/b')) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts index a641ccb..163daf7 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts @@ -140,21 +140,17 @@ describe('schemas and examples', () => { // `content` can carry its own examples instead. it('removes parameter and header examples beside content', () => { const content = { 'a/b': { schema: { type: 'object' } } } - const operation = dig(convertSpec({ - paths: { - '/a': { - get: { - parameters: [ - { content, example: { a: 1 }, in: 'query', name: 'moved' }, - { content: { 'a/b': { example: 'own' } }, examples: { e: { dataValue: 1 } }, in: 'query', name: 'kept' }, - { content: { 'a/b': {}, 'c/d': {} }, example: 1, in: 'query', name: 'many' }, - { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, - ], - responses: { 200: { description: 'ok', headers: { X: { content, examples: { e: { dataValue: 2 } } } } } }, - }, - }, + const operation = dig(convertPathItem({ + get: { + parameters: [ + { content, example: { a: 1 }, in: 'query', name: 'moved' }, + { content: { 'a/b': { example: 'own' } }, examples: { e: { dataValue: 1 } }, in: 'query', name: 'kept' }, + { content: { 'a/b': {}, 'c/d': {} }, example: 1, in: 'query', name: 'many' }, + { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, + ], + responses: { 200: { description: 'ok', headers: { X: { content, examples: { e: { dataValue: 2 } } } } } }, }, - }), 'paths', '/a', 'get') + }), 'get') expect(dig(operation, 'parameters')).toEqual([ { content, in: 'query', name: 'moved' }, { content: { 'a/b': { example: 'own' } }, in: 'query', name: 'kept' }, diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts index fe126aa..326925a 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts @@ -447,7 +447,6 @@ describe('recursion', () => { }, }, }) - expect(() => JSON.stringify(result)).not.toThrow() expect(result).toEqual({ items: { properties: { children: {} }, type: 'object' }, type: 'array' }) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts index af351b4..19788ff 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts @@ -11,7 +11,8 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' -import { dig, expectValidAs } from '../../helpers' +import { dig } from '../../helpers' +import { expectValidAs } from '../../validate' import { convertPathItem, convertSpec } from './helpers' it('keeps $id and $anchor on the first copy of a schema inlined in several places', () => { diff --git a/packages/downgrader/tests/validate.ts b/packages/downgrader/tests/validate.ts new file mode 100644 index 0000000..15c32cb --- /dev/null +++ b/packages/downgrader/tests/validate.ts @@ -0,0 +1,96 @@ +import { Validator } from '@seriousme/openapi-schema-validator' +import { expect } from 'vitest' + +type Version = '3.0' | '3.1' | '3.2' + +// Compiling an official schema takes tens of milliseconds, and a Validator +// caches the compiled schema per version, so one instance serves every call. +const validator = new Validator() + +/** + * Resolves a local `$ref` such as `#/components/schemas/Pet` against `root`. + * The fragment is percent-decoded first (RFC 3986) and then split into + * JSON Pointer tokens with `~1` and `~0` unescaped (RFC 6901). + */ +function resolvePointer(root: unknown, ref: string): unknown { + if (!ref.startsWith('#')) { + return undefined + } + let pointer: string + try { + pointer = decodeURIComponent(ref.slice(1)) + } + catch { + return undefined + } + if (pointer === '') { + return root + } + let current = root + for (const token of pointer.slice(1).split('/')) { + const key = token.replaceAll('~1', '/').replaceAll('~0', '~') + if (typeof current !== 'object' || current === null || !Object.hasOwn(current, key)) { + return undefined + } + current = (current as Record)[key] + } + return current +} + +function collectLocalRefs(value: unknown, refs: Set): Set { + if (typeof value === 'object' && value !== null) { + for (const [key, item] of Object.entries(value)) { + if ((key === '$ref' || key === 'operationRef') && typeof item === 'string' && item.startsWith('#')) { + refs.add(item) + } + else { + collectLocalRefs(item, refs) + } + } + } + return refs +} + +/** + * Every local `$ref` and `operationRef` in `output` that resolved in `input` + * must still resolve in `output`: a conversion may keep a reference only + * when its target survives. + */ +export function expectNoNewDanglingRefs(input: object, output: object): void { + for (const ref of collectLocalRefs(output, new Set())) { + if (resolvePointer(input, ref) !== undefined) { + expect(resolvePointer(output, ref), ref).toBeDefined() + } + } +} + +/** + * Validates a document against the official OpenAPI JSON Schema of the + * version its `openapi` field names. + */ +export async function expectValidAs(spec: object, expectedVersion: Version): Promise { + const result = await validator.validate(structuredClone(spec) as Record) + expect(result.errors ?? []).toEqual([]) + expect(result.valid).toBe(true) + expect(validator.version).toBe(expectedVersion) +} + +/** + * Downgrades a valid `from` document and checks that the result is a valid + * `to` document, that every reference that resolved still does, and that the + * input is left untouched. + */ +export async function expectValidDowngrade( + doc: I, + downgrade: (doc: I) => O, + from: Version, + to: Version, +): Promise { + await expectValidAs(doc, from) + const before = structuredClone(doc) + const output = downgrade(doc) + await expectValidAs(output, to) + expectNoNewDanglingRefs(doc, output) + expect(doc).toEqual(before) + return output +}