diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 6525cb9..f481c1b 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -58,7 +58,7 @@ All types come from [`@openapi-spec/types`](https://github.com/middleapi/openapi ## 3.2 → 3.1 -Schema Objects pass through unchanged. 3.2 keeps the 3.1 JSON Schema keyword set and only adds two fields to the OAS vocabulary, `discriminator.defaultMapping` and `xml.nodeType`, and both are kept. 3.1 tooling ignores them, so a `defaultMapping` fallback stops taking effect, while `nodeType` is picked up again on the 3.1 → 3.0 hop. The standard OpenAPI 3.1 document schema accepts them, but the strict OAS 3.1 base-vocabulary meta-schema closes the XML and Discriminator Objects and will flag them. +Schema Objects pass through unchanged, apart from `$ref`s into removed parts of the document (see below). 3.2 keeps the 3.1 JSON Schema keyword set and only adds two fields to the OAS vocabulary, `discriminator.defaultMapping` and `xml.nodeType`, and both are kept. 3.1 tooling ignores them, so a `defaultMapping` fallback stops taking effect, while `nodeType` is picked up again on the 3.1 → 3.0 hop. The standard OpenAPI 3.1 document schema accepts them, but the strict OAS 3.1 base-vocabulary meta-schema closes the XML and Discriminator Objects and will flag them. Converted: @@ -66,11 +66,12 @@ Converted: | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `openapi: 3.2.x` | `openapi: 3.1.2` | | `jsonSchemaDialect` naming a 3.2 OAS dialect | `https://spec.openapis.org/oas/3.1/dialect/base`; other dialects pass through | -| `components.mediaTypes` and content-map `$ref`s to it | references inlined and the component map removed. Entries whose target cannot be inlined (external, unknown, or cyclic) are removed, since 3.1 content maps cannot hold references. A parameter or header that loses its entire `content` that way is removed too, because 3.1 requires exactly one entry there | +| `components.mediaTypes` and content-map `$ref`s | references inlined and the component map removed. Entries whose target cannot be inlined (external, unknown, or cyclic) are removed, since 3.1 content maps cannot hold references. A parameter or header that loses its entire `content` that way is removed too, because 3.1 requires exactly one entry there | | media type `itemSchema` without a sibling `schema` | `schema: { type: "array", items: … }`, the sequential media type data model | | response `summary` without a `description` | promoted to `description`; `""` when neither exists, since 3.1 requires it | | example `dataValue` / `serializedValue` without `value` or `externalValue` | promoted to `value`, `dataValue` taking precedence | | parameter `style: "cookie"` | removed so the 3.1 default `form` applies | +| `$ref` into a removed part | the target inlined in converted form, following reference chains, e.g. for `#/components/mediaTypes/Pet/schema`, anything under a `query` operation, or an index into a parameter list that lost entries. Beside other schema keywords it joins `allOf`; a cycle is cut by removing the reference | Removed, with no 3.1 equivalent: @@ -78,7 +79,7 @@ Removed, with no 3.1 equivalent: - server `name` - tag `summary`, `parent`, and `kind` - the Path Item `query` operation and `additionalOperations` -- `in: "querystring"` parameters, in parameter lists and in `components.parameters`, together with references to removed component parameters and headers (chains of reference aliases included) +- `in: "querystring"` parameters, in parameter lists and in `components.parameters`, together with references that resolve to a removed parameter or header (chains of reference aliases included) - `allowReserved` on non-query parameters - media type `description` - `prefixEncoding`, `itemEncoding`, and nested `encoding` on media types and encodings @@ -86,7 +87,7 @@ Removed, with no 3.1 equivalent: - OAuth `deviceAuthorization` flows - security scheme `oauth2MetadataUrl` and `deprecated` -Known limitations: security requirements keyed by URI, `$self`-relative reference resolution, and a `$schema` keyword inside a Schema Object that names the 3.2 dialect all pass through unchanged. +Known limitations: security requirements keyed by URI, `$self`-relative reference resolution, Link `operationRef` and discriminator `mapping` values that point into removed parts, and a `$schema` keyword inside a Schema Object that names the 3.2 dialect all pass through unchanged. ## 3.1 → 3.0 diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 8e26212..e04ee89 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -5,12 +5,16 @@ import { convertRecord, deepClone, DROP, + getChild, getRef, HTTP_METHODS_UP_TO_V31, + isConverting, isRecord, mapArray, mapRecord, operationFields, + parseLocalRef, + resolveLocalRef, setOwn, } from './shared' @@ -417,6 +421,86 @@ describe('getRef', () => { }) }) +describe('isConverting', () => { + it('reports only source records whose conversion is still in progress', () => { + const child = { a: 1 } + const source = { child } + const seen: boolean[] = [] + convertRecord(source, { + child: (item) => { + seen.push(isConverting(source), isConverting(item)) + return item + }, + }) + expect(seen).toEqual([true, false]) + expect(isConverting(source)).toBe(false) + expect(isConverting('text')).toBe(false) + }) +}) + +describe('parseLocalRef', () => { + it('splits a local JSON pointer into unescaped tokens', () => { + expect(parseLocalRef('#/components/schemas/Pet')).toEqual(['components', 'schemas', 'Pet']) + expect(parseLocalRef('#/paths/~1pets~1{id}/a~0b')).toEqual(['paths', '/pets/{id}', 'a~b']) + expect(parseLocalRef('#/~01')).toEqual(['~1']) + }) + + it('percent-decodes the fragment before splitting it', () => { + expect(parseLocalRef('#/paths/~1pets~1%7Bid%7D')).toEqual(['paths', '/pets/{id}']) + expect(parseLocalRef('#/a%2Fb')).toEqual(['a', 'b']) + }) + + it('returns no tokens for the whole-document pointer', () => { + expect(parseLocalRef('#')).toEqual([]) + expect(parseLocalRef('#/')).toEqual(['']) + }) + + it('returns undefined for external refs, anchors, and malformed percent-encoding', () => { + expect(parseLocalRef('other.json#/a')).toBeUndefined() + expect(parseLocalRef('#anchor')).toBeUndefined() + expect(parseLocalRef('#/%E0%A4%A')).toBeUndefined() + }) +}) + +describe('getChild', () => { + it('reads own record keys, including __proto__', () => { + expect(getChild({ a: 1 }, 'a')).toBe(1) + expect(getChild(JSON.parse('{"__proto__": 2}'), '__proto__')).toBe(2) + }) + + it('reads canonical array indices only', () => { + const list = ['a', 'b'] + expect(getChild(list, '1')).toBe('b') + expect(getChild(list, '2')).toBeUndefined() + expect(getChild(list, '01')).toBeUndefined() + expect(getChild(list, '-')).toBeUndefined() + expect(getChild(list, 'length')).toBeUndefined() + // eslint-disable-next-line no-sparse-arrays + expect(getChild([, 'b'], '0')).toBeUndefined() + }) + + it('does not read inherited members or step into primitives', () => { + expect(getChild({}, 'hasOwnProperty')).toBeUndefined() + expect(getChild('text', 'length')).toBeUndefined() + expect(getChild(null, 'a')).toBeUndefined() + }) +}) + +describe('resolveLocalRef', () => { + const root = { a: [{ 'b/c': 1 }] } + + it('resolves a local pointer against the root', () => { + expect(resolveLocalRef(root, '#/a/0/b~1c')).toBe(1) + expect(resolveLocalRef(root, '#')).toBe(root) + }) + + it('returns undefined for unresolvable or non-local pointers', () => { + expect(resolveLocalRef(root, '#/a/1')).toBeUndefined() + expect(resolveLocalRef(root, '#/x/y/z')).toBeUndefined() + expect(resolveLocalRef(root, 'other.json#/a')).toBeUndefined() + }) +}) + describe('setOwn', () => { it('defines an enumerable, writable, configurable own property', () => { const target: Record = {} diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 7909692..c152b3d 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -119,6 +119,10 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis } } +export function isConverting(value: unknown): boolean { + return isRecord(value) && conversions.get(value)?.done === false +} + export function mapRecord(value: unknown, convert: (item: unknown, key: string) => unknown): unknown { if (!isRecord(value)) { return deepClone(value) @@ -146,3 +150,37 @@ export function getRef(value: unknown): string | undefined { } return undefined } + +export function parseLocalRef(ref: string): string[] | undefined { + if (!ref.startsWith('#')) { + return undefined + } + let pointer = ref.slice(1) + if (pointer.includes('%')) { + try { + pointer = decodeURIComponent(pointer) + } + catch { + return undefined + } + } + if (pointer === '') { + return [] + } + if (!pointer.startsWith('/')) { + return undefined + } + const tokens = pointer.slice(1).split('/') + return pointer.includes('~') ? tokens.map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) : tokens +} + +export function getChild(value: unknown, token: string): unknown { + if (Array.isArray(value)) { + return /^(?:0|[1-9]\d*)$/.test(token) && Object.hasOwn(value, token) ? value[Number(token)] : undefined + } + return isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined +} + +export function resolveLocalRef(root: unknown, ref: string): unknown { + return parseLocalRef(ref)?.reduce((node, token) => getChild(node, token), root) +} diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index 27eeee9..46f7013 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -530,6 +530,35 @@ describe('downgradeSpecV32ToV31', () => { ).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( @@ -1204,6 +1233,524 @@ describe('downgradeSpecV32ToV31', () => { }) }) + describe('references into removed parts', () => { + const petRef = { $ref: '#/components/mediaTypes/Pet/schema' } + const pet = { type: 'object', xml: { nodeType: 'element' } } + + 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(pet)) + }) + + 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: pet } }, + parameters: { P: { in: 'query', name: 'p', schema: pet } }, + requestBodies: { + B: { + content: { + 'application/json': { schema: pet }, + 'application/jsonl': { schema: { items: pet, 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: pet } }, schemas: { S: schema } }) + }) + + it.each([ + [ + 'adds allOf beside sibling annotations', + { $ref: petRef.$ref, description: 'd' }, + { allOf: [pet], description: 'd' }, + ], + [ + 'appends to an existing allOf, keeping its indices', + { $ref: petRef.$ref, allOf: [{ required: ['a'] }] }, + { allOf: [{ required: ['a'] }, pet] }, + ], + [ + 'nests the siblings when allOf is malformed', + { $ref: petRef.$ref, allOf: 'junk' }, + { allOf: [{ allOf: 'junk' }, pet] }, + ], + ])('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('removes a Reference Object whose inlining cycles, together with references to it', () => { + 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' } } }) + expect(result.paths).toEqual({ '/a': {}, '/b': {}, '/c': { get: { responses: {} } } }) + }) + + 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: {}, type: 'array' }) + }) + + it('inlines references into a parameter list that lost entries, since its indices shift', () => { + const result = convertSpec({ + components: { + parameters: { + Kept: { $ref: '#/paths/~1b/get/parameters/0' }, + Shifted: { $ref: '#/paths/~1a/get/parameters/1' }, + }, + }, + paths: { + '/a': { + get: { + parameters: [ + { in: 'querystring', name: 'qs' }, + { in: 'query', name: 'b' }, + ], + responses: {}, + }, + }, + '/b': { get: { parameters: [{ in: 'query', name: 'c' }], responses: {} } }, + }, + }) + expect(result.components).toEqual({ + parameters: { + Kept: { $ref: '#/paths/~1b/get/parameters/0' }, + Shifted: { in: 'query', name: 'b' }, + }, + }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ + { 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('removes references whose alias chain cycles, since they can never resolve', () => { + expect( + convertSpec({ + components: { + parameters: { + A: { $ref: '#/components/parameters/B' }, + B: { $ref: '#/components/parameters/A' }, + }, + }, + }).components, + ).toEqual({ parameters: {} }) + }) + + it('removes a looping reference in both passes, 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([{ in: 'query', name: 'b' }]) + expect(result.components).toEqual({ parameters: { P: { in: 'query', name: 'b' } } }) + }) + + 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: pet } }, 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' } }) + }) + }) + describe('robustness', () => { it('never mutates the input document', () => { const spec = { @@ -1215,7 +1762,10 @@ describe('downgradeSpecV32ToV31', () => { B: { itemSchema: { xml: { nodeType: 'text' } } }, }, pathItems: { P: { query: { description: 'q' } } }, - schemas: { S: { discriminator: { defaultMapping: 'Dog' } } }, + schemas: { + R: { $ref: '#/components/mediaTypes/B/itemSchema', description: 'r' }, + S: { discriminator: { defaultMapping: 'Dog' } }, + }, securitySchemes: { O: { deprecated: true, type: 'oauth2' } }, }, openapi: '3.2.0', @@ -1257,6 +1807,21 @@ describe('downgradeSpecV32ToV31', () => { 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 = { properties: { name: { type: 'string' } }, type: 'object' } const result = convertSpec({ diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 5d210cb..0b94308 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -1,43 +1,109 @@ import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' -import type { FieldConverter } from './shared' +import type { FieldConverter, FieldTable } from './shared' import { convertRecord, deepClone, DROP, + getChild, getRef, + isConverting, isRecord, mapArray, mapRecord, operationFields, + parseLocalRef, + resolveLocalRef, } from './shared' -const HEADERS_REF_PREFIX = '#/components/headers/' -const MEDIA_TYPES_REF_PREFIX = '#/components/mediaTypes/' -const PARAMETERS_REF_PREFIX = '#/components/parameters/' - const V32_DIALECT_PREFIX = 'https://spec.openapis.org/oas/3.2/dialect/' const V31_DIALECT = 'https://spec.openapis.org/oas/3.1/dialect/base' interface Context { - mediaTypes: Record | undefined - removedHeaderRefs: ReadonlySet - removedParameterRefs: ReadonlySet + convertSchema: (value: unknown) => unknown + dangles: (ref: string) => boolean + inlining: Set + resolve: (ref: string) => unknown } export function downgradeSchemaV32ToV31(schema: OpenAPIV3_2.SchemaObject): OpenAPIV3_1.SchemaObject { return deepClone(schema) as OpenAPIV3_1.SchemaObject } +function inlineRef(ref: string, context: Context, convert: (item: unknown) => unknown): unknown { + const target = context.resolve(ref) + if (isConverting(target) || [...context.inlining].some(inlined => inlined === ref || inlined.startsWith(`${ref}/`))) { + return DROP + } + context.inlining.add(ref) + const result = convert(target) + context.inlining.delete(ref) + return result +} + function convertRefOr(value: unknown, context: Context, convert: (item: unknown, context: Context) => unknown): unknown { - return getRef(value) === undefined ? convert(value, context) : deepClone(value) + const ref = getRef(value) + if (ref === undefined) { + return convert(value, context) + } + if (resolveRefChain(value, context) === DROP) { + return DROP + } + return context.dangles(ref) ? inlineRef(ref, context, item => convertRefOr(item, context, convert)) : deepClone(value) } function refMap(context: Context, convert: (item: unknown, context: Context) => unknown): FieldConverter { return item => mapRecord(item, entry => convertRefOr(entry, context, convert)) } +function createSchemaFields(schema: (item: unknown) => unknown, dangles: (ref: string) => boolean): FieldTable { + const list = (item: unknown): unknown => mapArray(item, schema) + const map = (item: unknown): unknown => mapRecord(item, schema) + return { + $defs: map, + $ref: item => (typeof item === 'string' && dangles(item) ? DROP : deepClone(item)), + additionalProperties: schema, + allOf: list, + anyOf: list, + contains: schema, + contentSchema: schema, + dependentSchemas: map, + else: schema, + if: schema, + items: schema, + not: schema, + oneOf: list, + patternProperties: map, + prefixItems: list, + properties: map, + propertyNames: schema, + then: schema, + unevaluatedItems: schema, + unevaluatedProperties: schema, + } +} + +function finishSchema(out: Record, schema: Record, context: Context): unknown { + const ref = getRef(schema) + if (ref === undefined || '$ref' in out) { + return out + } + const target = inlineRef(ref, context, context.convertSchema) + if (target === DROP) { + return out + } + if (Object.keys(out).length === 0) { + return target + } + const allOf = out.allOf ?? [] + if (!Array.isArray(allOf)) { + return { allOf: [out, target] } + } + out.allOf = [...allOf, target] + return out +} + function convertServer(value: unknown): unknown { return convertRecord(value, { name: DROP }) } @@ -77,38 +143,56 @@ function isQuerystringParameter(value: unknown): boolean { return isRecord(value) && value.in === 'querystring' } -function isRemovedRef(value: unknown, removed: ReadonlySet): boolean { - const ref = getRef(value) - return ref !== undefined && removed.has(ref) +function memoize(compute: (ref: string) => T): (ref: string) => T { + const cache = new Map() + return (ref) => { + if (!cache.has(ref)) { + cache.set(ref, compute(ref)) + } + return cache.get(ref) as T + } +} + +function resolveRefChain(value: unknown, context: Context): unknown { + const seen = new Set() + let target = value + let ref = getRef(target) + while (ref !== undefined) { + if (seen.has(ref)) { + return DROP + } + seen.add(ref) + target = context.resolve(ref) + ref = getRef(target) + } + return target +} + +function isRemovedHeader(value: unknown, context: Context): boolean { + return losesEntireContent(resolveRefChain(value, context), context) +} + +function isRemovedParameter(value: unknown, context: Context): boolean { + const target = resolveRefChain(value, context) + return isQuerystringParameter(target) || losesEntireContent(target, context) } function convertParameterOrHeader(value: unknown, context: Context): unknown { - return convertRecord( - value, - { - allowReserved: (item, parameter) => (!('in' in parameter) || parameter.in === 'query' ? deepClone(item) : DROP), - content: item => convertContentMap(item, context), - examples: refMap(context, convertExample), - style: item => (item === 'cookie' ? DROP : deepClone(item)), - }, - (out, parameter) => { - const lostContent = isRecord(parameter.content) - && Object.keys(parameter.content).length > 0 - && isRecord(out.content) - && Object.keys(out.content).length === 0 - return lostContent ? DROP : out - }, - ) + return convertRecord(value, { + allowReserved: (item, parameter) => (!('in' in parameter) || parameter.in === 'query' ? deepClone(item) : DROP), + content: item => convertContentMap(item, context), + examples: refMap(context, convertExample), + schema: context.convertSchema, + style: item => (item === 'cookie' ? DROP : deepClone(item)), + }) } function convertParameterEntry(value: unknown, context: Context): unknown { - return isQuerystringParameter(value) || isRemovedRef(value, context.removedParameterRefs) - ? DROP - : convertRefOr(value, context, convertParameterOrHeader) + return isRemovedParameter(value, context) ? DROP : convertRefOr(value, context, convertParameterOrHeader) } function convertHeaderMap(value: unknown, context: Context): unknown { - return mapRecord(value, item => isRemovedRef(item, context.removedHeaderRefs) ? DROP : convertRefOr(item, context, convertParameterOrHeader)) + return mapRecord(value, item => isRemovedHeader(item, context) ? DROP : convertRefOr(item, context, convertParameterOrHeader)) } function convertEncoding(value: unknown, context: Context): unknown { @@ -130,35 +214,25 @@ function convertMediaType(value: unknown, context: Context): unknown { itemEncoding: DROP, itemSchema: DROP, prefixEncoding: DROP, + schema: context.convertSchema, }, (out, mediaType) => { if ('itemSchema' in mediaType && out.schema === undefined) { - out.schema = { items: deepClone(mediaType.itemSchema), type: 'array' } + out.schema = { items: context.convertSchema(mediaType.itemSchema), type: 'array' } } return out }, ) } -function resolveMediaType(value: unknown, mediaTypes: Record | undefined, seen: Set): unknown { - const ref = getRef(value) - if (ref === undefined) { - return value - } - if (!ref.startsWith(MEDIA_TYPES_REF_PREFIX)) { - return DROP - } - const name = ref.slice(MEDIA_TYPES_REF_PREFIX.length) - if (name === '' || name.includes('/') || mediaTypes === undefined || !Object.hasOwn(mediaTypes, name) || seen.has(name)) { - return DROP - } - seen.add(name) - return resolveMediaType(mediaTypes[name], mediaTypes, seen) +function resolveMediaType(value: unknown, context: Context): unknown { + const target = resolveRefChain(value, context) + return target === undefined ? DROP : target } function convertContentMap(value: unknown, context: Context): unknown { return mapRecord(value, (item) => { - const target = resolveMediaType(item, context.mediaTypes, new Set()) + const target = resolveMediaType(item, context) return target === DROP ? DROP : convertMediaType(target, context) }) } @@ -228,58 +302,24 @@ function convertComponents(value: unknown, context: Context): unknown { pathItems: item => mapRecord(item, entry => convertPathItem(entry, context)), requestBodies: refMap(context, convertRequestBody), responses: refMap(context, convertResponse), + schemas: item => mapRecord(item, context.convertSchema), securitySchemes: refMap(context, convertSecurityScheme), }) } -function losesEntireContent(value: unknown, mediaTypes: Record | undefined): boolean { +function losesEntireContent(value: unknown, context: Context): boolean { if (!(isRecord(value) && isRecord(value.content))) { return false } const entries = Object.values(value.content) - return entries.length > 0 && entries.every(item => resolveMediaType(item, mediaTypes, new Set()) === DROP) -} - -function indexRemovedComponentRefs(map: unknown, prefix: string, mediaTypes: Record | undefined, isDirectlyRemoved: (item: unknown) => boolean): Set { - const removed = new Set() - if (!isRecord(map)) { - return removed - } - const entries = Object.entries(map) - let changed = true - while (changed) { - changed = false - for (const [name, item] of entries) { - const selfRef = prefix + name - if (removed.has(selfRef)) { - continue - } - const target = getRef(item) - if (isDirectlyRemoved(item) || losesEntireContent(item, mediaTypes) || (target !== undefined && removed.has(target))) { - removed.add(selfRef) - changed = true - } - } - } - return removed -} - -function createContext(spec: unknown): Context { - const components = isRecord(spec) ? spec.components : undefined - const mediaTypes = isRecord(components) && isRecord(components.mediaTypes) ? components.mediaTypes : undefined - return { - mediaTypes, - removedHeaderRefs: indexRemovedComponentRefs(isRecord(components) ? components.headers : undefined, HEADERS_REF_PREFIX, mediaTypes, () => false), - removedParameterRefs: indexRemovedComponentRefs(isRecord(components) ? components.parameters : undefined, PARAMETERS_REF_PREFIX, mediaTypes, isQuerystringParameter), - } + return entries.length > 0 && entries.every(item => resolveMediaType(item, context) === DROP) } function convertJsonSchemaDialect(value: unknown): unknown { return typeof value === 'string' && value.startsWith(V32_DIALECT_PREFIX) ? V31_DIALECT : deepClone(value) } -export function downgradeSpecV32ToV31(spec: OpenAPIV3_2.OpenAPIObject): OpenAPIV3_1.OpenAPIObject { - const context = createContext(spec) +function convertSpec(spec: unknown, context: Context): unknown { return convertRecord( spec, { @@ -295,5 +335,46 @@ export function downgradeSpecV32ToV31(spec: OpenAPIV3_2.OpenAPIObject): OpenAPIV out.openapi = '3.1.2' return out }, - ) as OpenAPIV3_1.OpenAPIObject + ) +} + +function createContext(resolve: (ref: string) => unknown, dangles: (ref: string) => boolean): Context { + const context: Context = { convertSchema, dangles, inlining: new Set(), resolve } + const fields = createSchemaFields(convertSchema, dangles) + const finish = (out: Record, schema: Record): unknown => finishSchema(out, schema, context) + function convertSchema(value: unknown): unknown { + return convertRecord(value, fields, finish) + } + return context +} + +function danglesIn(output: unknown, source: unknown, ref: string): boolean { + const tokens = parseLocalRef(ref) + if (tokens === undefined) { + return false + } + let from = source + let to = output + for (const token of tokens) { + if (Array.isArray(from) && !(Array.isArray(to) && to.length === from.length)) { + to = undefined + } + from = getChild(from, token) + to = getChild(to, token) + if (from === undefined) { + return false + } + } + return to === undefined +} + +export function downgradeSpecV32ToV31(spec: OpenAPIV3_2.OpenAPIObject): OpenAPIV3_1.OpenAPIObject { + const resolve = memoize(ref => resolveLocalRef(spec, ref)) + const refs = new Set() + const draft = convertSpec(spec, createContext(resolve, (ref) => { + refs.add(ref) + return false + })) + const dangles = memoize(ref => danglesIn(draft, spec, ref)) + return ([...refs].some(dangles) ? convertSpec(spec, createContext(resolve, dangles)) : draft) as OpenAPIV3_1.OpenAPIObject } diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts index c123272..0e93f0a 100644 --- a/packages/downgrader/tests/e2e.test.ts +++ b/packages/downgrader/tests/e2e.test.ts @@ -217,6 +217,86 @@ describe('3.2 example documents downgraded to 3.1 and chained to 3.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('converts the 3.2 mega document, preserving the discriminator defaultMapping in the schema', async () => { const before = structuredClone(mega32) const v31 = downgradeSpecV32ToV31(mega32)