diff --git a/packages/downgrader/tests/orpc.test.ts b/packages/downgrader/tests/orpc.test.ts index 9ec0200..8296ce6 100644 --- a/packages/downgrader/tests/orpc.test.ts +++ b/packages/downgrader/tests/orpc.test.ts @@ -11,6 +11,16 @@ function downgradeTwice(spec: OpenAPIV3_2.OpenAPIObject) { return downgradeSpecV31ToV30(downgradeSpecV32ToV31(spec)) } +// Rebuilds every object in `value` from its entries with `object`. +function rebuild(value: unknown, object: (entries: [string, unknown][]) => object): any { + if (Array.isArray(value)) { + return value.map((item) => rebuild(item, object)) + } + return typeof value === 'object' && value !== null + ? object(Object.entries(value).map(([key, item]) => [key, rebuild(item, object)])) + : value +} + it('converts the oRPC document into a valid 3.1 document', async () => { const v31 = await expectValidDowngrade(doc, downgradeSpecV32ToV31, '3.2', '3.1') expect(v31.servers).toEqual([{ url: 'https://api.example.com' }]) @@ -85,3 +95,24 @@ it('converts the oRPC document into a valid 3.0 document', async () => { it('matches the 3.0 snapshot', () => { expect(downgradeTwice(doc)).toMatchSnapshot() }) + +const nullPrototype = (entries: [string, unknown][]) => + Object.assign(Object.create(null), Object.fromEntries(entries)) +const withUndefined = (entries: [string, unknown][]) => ({ + ...Object.fromEntries(entries), + missing: undefined, +}) + +it.each([ + ['built from null-prototype objects', nullPrototype], + ['with a key holding undefined in every object', withUndefined], +])('converts the oRPC document %s to 3.1 as it converts the plain one', (_name, object) => { + expect(downgradeSpecV32ToV31(rebuild(doc, object))).toStrictEqual(downgradeSpecV32ToV31(doc)) +}) + +it('converts the 3.1 oRPC document built from null-prototype objects to 3.0 as it converts the plain one', () => { + const v31 = downgradeSpecV32ToV31(doc) + expect(downgradeSpecV31ToV30(rebuild(v31, nullPrototype))).toStrictEqual( + downgradeSpecV31ToV30(v31), + ) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index eb2b290..103cbe1 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1,4 +1,5 @@ import { downgradeSchemaV31ToV30, downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' +import type * as OpenAPIV3_0 from '@openapi-spec/types/v3.0' import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' import { expectValidDowngrade } from './validate' @@ -368,6 +369,13 @@ describe('downgradeSpecV31ToV30', () => { }) }) + it('copies extensions in paths, responses, and callbacks as they are', () => { + const extension = { $ref: '#/paths/~1a', get: {} } + const operation = { responses: { 'x-a': extension }, callbacks: { cb: { 'x-a': extension } } } + const out = convert({ paths: { 'x-a': extension, '/a': { post: operation } } }) + expect(out.paths).toEqual({ 'x-a': extension, '/a': { post: operation } }) + }) + it('leaves the input untouched', () => { const doc: OpenAPIV3_1.OpenAPIObject = { openapi: '3.1.2', @@ -513,19 +521,35 @@ describe('downgradeSchemaV31ToV30', () => { expect(downgradeSchemaV31ToV30({ const: new Date(0) })).toEqual({ enum: [new Date(0)] }) }) - it('turns numeric exclusive bounds into boolean ones, keeping the stricter bound', () => { - expect(downgradeSchemaV31ToV30({ exclusiveMinimum: 0, exclusiveMaximum: 10 })).toEqual({ - minimum: 0, - exclusiveMinimum: true, - maximum: 10, - exclusiveMaximum: true, - }) - expect(downgradeSchemaV31ToV30({ minimum: 5, exclusiveMinimum: 0 })).toEqual({ minimum: 5 }) - expect(downgradeSchemaV31ToV30({ minimum: 0, exclusiveMinimum: 5 })).toEqual({ - minimum: 5, - exclusiveMinimum: true, - }) - }) + it.each<[OpenAPIV3_1.SchemaObject, OpenAPIV3_0.SchemaObject]>([ + [ + { exclusiveMinimum: 0, exclusiveMaximum: 10 }, + { minimum: 0, exclusiveMinimum: true, maximum: 10, exclusiveMaximum: true }, + ], + [{ minimum: 5, exclusiveMinimum: 0 }, { minimum: 5 }], + [ + { minimum: 0, exclusiveMinimum: 5 }, + { minimum: 5, exclusiveMinimum: true }, + ], + [ + { minimum: 5, exclusiveMinimum: 5 }, + { minimum: 5, exclusiveMinimum: true }, + ], + [{ maximum: 5, exclusiveMaximum: 10 }, { maximum: 5 }], + [ + { maximum: 10, exclusiveMaximum: 5 }, + { maximum: 5, exclusiveMaximum: true }, + ], + [ + { maximum: 5, exclusiveMaximum: 5 }, + { maximum: 5, exclusiveMaximum: true }, + ], + ])( + 'turns numeric exclusive bounds into boolean ones, keeping the stricter bound: %o', + (schema, expected) => { + expect(downgradeSchemaV31ToV30(schema)).toEqual(expected) + }, + ) it('turns examples into example', () => { expect(downgradeSchemaV31ToV30({ examples: ['a', 'b'] })).toEqual({ example: 'a' }) @@ -580,6 +604,34 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ type: 'array', items: { type: 'number' }, maxItems: 2 }) }) + it.each<[OpenAPIV3_1.SchemaObjectFields, OpenAPIV3_0.SchemaObject]>([ + // As ArkType emits a tuple with an optional item. + [ + { items: false, minItems: 1 }, + { minItems: 1, maxItems: 2 }, + ], + [{ items: false, maxItems: 1 }, { maxItems: 1 }], + [{ items: false, maxItems: 3 }, { maxItems: 2 }], + [{ items: true }, { items: {} }], + [ + { items: { type: 'boolean' }, maxItems: 3 }, + { + items: { anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] }, + maxItems: 3, + }, + ], + ])( + 'turns a tuple beside %o into an array, bounded by its length when items is false', + (fields, expected) => { + expect( + downgradeSchemaV31ToV30({ + prefixItems: [{ type: 'string' }, { type: 'number' }], + ...fields, + }), + ).toEqual({ items: { anyOf: [{ type: 'string' }, { type: 'number' }] }, ...expected }) + }, + ) + it('marks binary strings with format', () => { expect(downgradeSchemaV31ToV30({ type: 'string', contentEncoding: 'base64' })).toEqual({ type: 'string', @@ -679,6 +731,15 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ type: 'object', properties: { b: { type: 'string', nullable: true } } }) }) + it('decodes percent-encoding, ~1, and ~0 in a $ref, in that order', () => { + expect( + downgradeSchemaV31ToV30({ + properties: { a: { $ref: '#/$defs/a%20b~1c~01' } }, + $defs: { 'a b/c~1': { type: ['string', 'null'] } }, + }), + ).toEqual({ properties: { a: { type: 'string', nullable: true } } }) + }) + it('leaves a $ref into $defs that does not resolve as written', () => { expect(downgradeSchemaV31ToV30({ $ref: '#/$defs/Missing' })).toEqual({ $ref: '#/$defs/Missing', @@ -694,6 +755,8 @@ describe('downgradeSchemaV31ToV30', () => { $anchor: 'pet', $comment: 'c', $dynamicAnchor: 'node', + $dynamicRef: '#node', + $vocabulary: { 'https://json-schema.org/draft/2020-12/vocab/core': true }, type: 'object', if: { required: ['a'] }, then: { required: ['b'] }, @@ -749,6 +812,31 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ allOf: [{ $ref: '#/components/schemas/Base' }], properties }) }) + it.each<[string, unknown]>([ + ['$dynamicRef', '#node'], + ['$ref', '#/components/schemas/Base'], + ['additionalProperties', true], + ['allOf', [{ properties: { id: {} } }]], + ['anyOf', [{ properties: { id: {} } }]], + ['dependentSchemas', { name: { properties: { id: {} } } }], + ['else', { properties: { id: {} } }], + ['if', { properties: { id: {} } }], + ['oneOf', [{ properties: { id: {} } }]], + ['patternProperties', { '^id$': {} }], + ['then', { properties: { id: {} } }], + ])( + 'keeps unevaluatedProperties beside %s, which evaluates properties too, out of additionalProperties', + (key, value) => { + expect( + downgradeSchemaV31ToV30({ + properties: { name: { type: 'string' } }, + [key]: value, + unevaluatedProperties: false, + }), + ).not.toHaveProperty('additionalProperties', false) + }, + ) + it('keeps meaning an empty enum, which 3.0 forbids, as a schema that rejects every value', () => { expect(downgradeSchemaV31ToV30({ type: 'string', enum: [] })).toEqual({ type: 'string', @@ -952,6 +1040,44 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ properties: { any: {}, none: { not: {} } }, additionalProperties: false, not: {} }) }) + it('keeps every keyword 3.0 shares as it is', () => { + const schema: OpenAPIV3_1.SchemaObject = { + title: 'Pet', + description: 'd', + type: 'object', + format: 'pet', + default: { kind: 'cat' }, + example: { kind: 'cat' }, + enum: [{ kind: 'cat' }], + readOnly: true, + writeOnly: false, + deprecated: true, + discriminator: { propertyName: 'kind' }, + externalDocs: { url: 'https://example.com' }, + xml: { name: 'pet' }, + multipleOf: 2, + minimum: 1, + maximum: 10, + minLength: 1, + maxLength: 5, + pattern: '^a', + minItems: 1, + maxItems: 3, + uniqueItems: true, + minProperties: 1, + maxProperties: 4, + required: ['kind'], + properties: { kind: { type: 'string' } }, + additionalProperties: { type: 'string' }, + items: { type: 'string' }, + allOf: [{ minProperties: 1 }], + anyOf: [{ required: ['kind'] }], + oneOf: [{ maxProperties: 4 }], + not: { type: 'string' }, + } + expect(downgradeSchemaV31ToV30(schema)).toEqual(schema) + }) + it('keeps extensions, and turns unknown keywords, which 3.0 forbids, into extensions', () => { expect( downgradeSchemaV31ToV30({ @@ -1052,8 +1178,19 @@ describe('unusual input', () => { }) it('resolves only local JSON Pointers, leaving other $refs as written', () => { - for (const ref of ['./other.json#/$defs/A', '#/$defs/%E0%A4%A']) { - expect(downgradeSchemaV31ToV30({ $ref: ref })).toEqual({ $ref: ref }) + for (const ref of [ + './other.json#/$defs/A', + 'x/$defs/A', + '#x/$defs/A', + '#/$defs/%E0%A4%A', + '#/$defs/constructor', + ]) { + expect( + downgradeSchemaV31ToV30({ + properties: { a: { $ref: ref } }, + $defs: { A: { type: 'string' } }, + }), + ).toEqual({ properties: { a: { $ref: ref } } }) } const out = convert({ security: [{ anchor: ['a'], loop: ['b'], missing: ['c'] }], @@ -1080,7 +1217,10 @@ describe('unusual input', () => { security: 'all', paths: { '/a': { get: { security: [null, 'x'], parameters: 'none', responses: [] } } }, components: { - schemas: { S: { properties: 'none', allOf: {}, example: { a: 1, b: undefined } } }, + schemas: { + S: { properties: 'none', allOf: {}, example: { a: 1, b: undefined } }, + R: { $ref: 1 }, + }, }, } const out = downgradeSpecV31ToV30(doc as any) @@ -1090,6 +1230,9 @@ describe('unusual input', () => { parameters: 'none', responses: [], }) - expect(out.components?.schemas?.S).toEqual({ properties: 'none', allOf: {}, example: { a: 1 } }) + expect(out.components?.schemas).toEqual({ + S: { properties: 'none', allOf: {}, example: { a: 1 } }, + R: {}, + }) }) }) diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index 6bbbbfb..2021069 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -142,8 +142,9 @@ describe('downgradeSpecV32ToV31', () => { expect(out.servers).toEqual([expected]) expect(out.paths?.['/a']?.servers).toEqual([expected]) expect(out.paths?.['/a']?.get?.servers).toEqual([expected]) - expect(out.paths?.['/a']?.get?.responses?.['200']).toMatchObject({ - links: { self: { server: expected } }, + expect(out.paths?.['/a']?.get?.responses?.['200']).toEqual({ + description: 'ok', + links: { self: { operationId: 'a', server: expected } }, }) }) @@ -348,7 +349,7 @@ describe('downgradeSpecV32ToV31', () => { }, }, }) - expect(out?.requestBody).toEqual({ content: {} }) + expect(out?.requestBody).toStrictEqual({ content: {} }) }) }) @@ -478,18 +479,35 @@ describe('downgradeSpecV32ToV31', () => { // Keys an object inherits are not part of it as JSON sees it, so such an object is kept as it is. const inherits = Object.create(Object.assign(Object.create(null), { type: 'string' })) expect(downgradeSchemaV32ToV31(inherits)).toBe(inherits) + expect( + downgradeSchemaV32ToV31( + Object.assign(Object.create(null), { xml: { nodeType: 'attribute' } }), + ), + ).toEqual({ xml: { attribute: true } }) }) it('treats keys holding undefined as missing, and keeps non-plain values as they are', () => { const date = new Date(0) const out = convertOperation({ 'summary': undefined, - 'responses': { 200: { summary: 'OK', description: undefined } }, + 'responses': { 200: { summary: 'OK', description: undefined }, 201: undefined }, + 'x-date': date, + 'x-meta': { a: undefined }, + }) + expect(out).toStrictEqual({ + 'responses': { 200: { description: 'OK' } }, 'x-date': date, + 'x-meta': {}, }) - expect(out).toEqual({ 'responses': { 200: { description: 'OK' } }, 'x-date': date }) expect(out?.['x-date']).toBe(date) }) + + it('copies extensions in paths, responses, and callbacks as they are', () => { + const extension = { summary: 's', query: {} } + const operation = { responses: { 'x-a': extension }, callbacks: { cb: { 'x-a': extension } } } + const out = convert({ paths: { 'x-a': extension, '/a': { post: operation } } }) + expect(out.paths).toEqual({ 'x-a': extension, '/a': { post: operation } }) + }) }) describe('downgradeSchemaV32ToV31', () => { @@ -505,6 +523,7 @@ describe('downgradeSchemaV32ToV31', () => { xml: { nodeType: 'element' }, items: { type: 'string', xml: { nodeType: 'text' } }, }, + notes: { type: 'array', xml: { nodeType: 'none' } }, }, $defs: { Note: { anyOf: [{ xml: { nodeType: 'cdata' } }] } }, }), @@ -514,11 +533,47 @@ describe('downgradeSchemaV32ToV31', () => { properties: { id: { type: 'string', xml: { attribute: true } }, tags: { type: 'array', xml: { wrapped: true }, items: { type: 'string', xml: {} } }, + notes: { type: 'array', xml: {} }, }, $defs: { Note: { anyOf: [{ xml: {} }] } }, }) }) + const attribute = { xml: { nodeType: 'attribute' } } as const + const converted = { xml: { attribute: true } } + + it.each([ + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'items', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + ])('turns XML nodeType into attribute in %s', (key) => { + expect(downgradeSchemaV32ToV31({ [key]: attribute })).toEqual({ [key]: converted }) + }) + + it.each(['$defs', 'dependentSchemas', 'patternProperties', 'properties'])( + 'turns XML nodeType into attribute in each entry of %s', + (key) => { + expect(downgradeSchemaV32ToV31({ [key]: { a: attribute } })).toEqual({ + [key]: { a: converted }, + }) + }, + ) + + it.each(['allOf', 'anyOf', 'oneOf', 'prefixItems'])( + 'turns XML nodeType into attribute in each item of %s', + (key) => { + expect(downgradeSchemaV32ToV31({ [key]: [attribute] })).toEqual({ [key]: [converted] }) + }, + ) + it('drops discriminator defaultMapping', () => { expect( downgradeSchemaV32ToV31({ @@ -541,12 +596,14 @@ describe('downgradeSchemaV32ToV31', () => { }) it('keeps a cycle as a cycle, in converted and copied values alike', () => { - const schema: Record = { type: 'object', properties: {} } + const schema: Record = { type: 'object', properties: {}, examples: [] } schema.properties.self = schema schema.default = schema + schema.examples.push(schema.examples) const out = downgradeSchemaV32ToV31(schema) as Record expect(out.properties.self).toBe(out) expect(out.default.default).toBe(out.default) + expect(out.examples[0]).toBe(out.examples) }) }) @@ -557,6 +614,18 @@ describe('unusual input', () => { ).toEqual({ type: ['array', 'null'], xml: { wrapped: true } }) }) + it('keeps own __proto__ keys as ordinary keys', () => { + const out = downgradeSchemaV32ToV31( + JSON.parse('{"__proto__":{"type":"string"},"default":{"__proto__":1}}'), + ) as Record + const own = { configurable: true, enumerable: true, writable: true } + expect(Object.getOwnPropertyDescriptor(out, '__proto__')).toEqual({ + ...own, + value: { type: 'string' }, + }) + expect(Object.getOwnPropertyDescriptor(out.default, '__proto__')).toEqual({ ...own, value: 1 }) + }) + it('tolerates malformed input without throwing', () => { expect(downgradeSchemaV32ToV31({ xml: true, allOf: {}, properties: 'none' } as any)).toEqual({ xml: true,