diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 460915b..fe9aa77 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -129,10 +129,10 @@ Accepts the default OAS dialect. It builds on JSON Schema 2020-12 in both versio #### Removed -| API | Why | -| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| XML [`nodeType`][3.2-xml-node-type] | `"attribute"` becomes `attribute: true`, and `"element"` on an array becomes `wrapped: true`. `"text"` and `"cdata"` have no 3.1 equivalent. | -| Discriminator [`defaultMapping`][3.2-discriminator-default-mapping] | Picks the schema when the discriminating property is missing or unmapped. 3.1 has no such field. | +| API | Why | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| XML [`nodeType`][3.2-xml-node-type] | `"attribute"` becomes `attribute: true`, and `"element"` on an array, or on a `$ref` to one, becomes `wrapped: true`. `"text"`, `"cdata"`, and `"none"` have no 3.1 equivalent, and `name`, which 3.2 ignores beside them, is removed too. | +| Discriminator [`defaultMapping`][3.2-discriminator-default-mapping] | Picks the schema when the discriminating property is missing or unmapped. 3.1 has no such field. | [3.2-xml-node-type]: https://spec.openapis.org/oas/v3.2.0.html#xml-node-type [3.2-discriminator-default-mapping]: https://spec.openapis.org/oas/v3.2.0.html#discriminator-default-mapping @@ -194,28 +194,28 @@ A schema is _loosened_ when the conversion removes a restriction from it or a su #### Removed -| API | Why | -| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`$schema`][js-schema] and [`$vocabulary`][js-vocabulary] | 3.0 has one fixed dialect. | -| [`$id`][js-id] and [`$anchor`][js-anchor] | 3.0 identifies schemas only by location. JSON Pointer `$ref`s and `mapping` values inside a schema with an `$id` resolve against it, and are rewritten from the root. Others stay as written. | -| [`$defs`][js-defs] | 3.0 has no local definitions. Each `$ref` into `$defs` is replaced by its converted target, and a reference back into a target being inlined becomes `{}`, so recursion stops after one level. | -| [`$dynamicRef` and `$dynamicAnchor`][js-dynamic] | 3.0 has no dynamic references. | -| [`$comment`][js-comment] and [`contentSchema`][js-content-schema] | Annotations with no 3.0 equivalent. | -| [`if`][js-if], [`then`][js-then], and [`else`][js-else] | 3.0 has no conditionals. | -| [`dependentSchemas`][js-dependent-schemas] and [`dependentRequired`][js-dependent-required] | 3.0 has no dependencies. | -| [`prefixItems`][js-prefix-items] and its `items` | 3.0 `items` applies one schema to every item, so tuples become plain arrays. | -| [`contains`][js-contains], [`minContains`][js-min-contains], and [`maxContains`][js-max-contains] | 3.0 has no equivalent. | -| [`patternProperties`][js-pattern-properties] and its `additionalProperties` | 3.0 has no equivalent. `additionalProperties` goes too, because it would reject properties that `patternProperties` allowed. | -| [`propertyNames`][js-property-names] | 3.0 has no equivalent. | -| [`unevaluatedItems`][js-unevaluated-items] and [`unevaluatedProperties`][js-unevaluated-properties] | 3.0 has no equivalent. | -| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | 3.0 marks binary strings with `format` instead: `base64` becomes `format: byte`, and a media type without an encoding becomes `format: binary`. Anything else is lost. | -| [`examples`][js-examples] | 3.0 has a single `example`. The first entry fills it when missing, and the rest are dropped. | -| [`readOnly` and `writeOnly`][js-read-only-write-only] when both are `true` | 3.0 forbids marking a property with both. Dropping these annotations loses detail, not validation. Keeping one would misstate the intent and, in 3.0, apply `required` one way only. | -| Empty [`enum`][js-enum] | 3.0 requires at least one value. An empty `enum` rejects everything, so dropping it only loosens the schema. | -| [`not`][js-not] over a loosened schema | Negating a looser schema would reject values the original accepts. | -| The exclusivity of [`oneOf`][js-one-of] with a loosened branch | Looser branches may overlap, so "exactly one" could reject values the original accepts. It becomes `anyOf`. | -| [`nullable`][3.0-schema-nullable], a 3.0 keyword | 3.1 ignores it, but in 3.0 it admits null, so keeping it would accept null where the original rejects it. Only `"null"` in `type` becomes `nullable: true`. | -| XML [`nodeType`][3.2-xml-node-type], a 3.2 field | As in 3.2 → 3.1, kept only as `attribute: true` or `wrapped: true`. | +| API | Why | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`$schema`][js-schema] and [`$vocabulary`][js-vocabulary] | 3.0 has one fixed dialect. | +| [`$id`][js-id] and [`$anchor`][js-anchor] | 3.0 identifies schemas only by location. JSON Pointer `$ref`s and `mapping` values inside a schema with an `$id` resolve against it, and are rewritten from the root. Others stay as written. | +| [`$defs`][js-defs] | 3.0 has no local definitions. Each `$ref` into `$defs` is replaced by its converted target, and a reference back into a target being inlined becomes `{}`, so recursion stops after one level. | +| [`$dynamicRef` and `$dynamicAnchor`][js-dynamic] | 3.0 has no dynamic references. | +| [`$comment`][js-comment] and [`contentSchema`][js-content-schema] | Annotations with no 3.0 equivalent. | +| [`if`][js-if], [`then`][js-then], and [`else`][js-else] | 3.0 has no conditionals. | +| [`dependentSchemas`][js-dependent-schemas] and [`dependentRequired`][js-dependent-required] | 3.0 has no dependencies. | +| [`prefixItems`][js-prefix-items] and its `items` | 3.0 `items` applies one schema to every item, so tuples become plain arrays. | +| [`contains`][js-contains], [`minContains`][js-min-contains], and [`maxContains`][js-max-contains] | 3.0 has no equivalent. | +| [`patternProperties`][js-pattern-properties] and its `additionalProperties` | 3.0 has no equivalent. `additionalProperties` goes too, because it would reject properties that `patternProperties` allowed. | +| [`propertyNames`][js-property-names] | 3.0 has no equivalent. | +| [`unevaluatedItems`][js-unevaluated-items] and [`unevaluatedProperties`][js-unevaluated-properties] | 3.0 has no equivalent. | +| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | 3.0 marks binary strings with `format` instead: `base64` becomes `format: byte`, and a media type without an encoding becomes `format: binary`. Anything else is lost. | +| [`examples`][js-examples] | 3.0 has a single `example`. The first entry fills it when missing, and the rest are dropped. | +| [`readOnly` and `writeOnly`][js-read-only-write-only] when both are `true` | 3.0 forbids marking a property with both. Dropping these annotations loses detail, not validation. Keeping one would misstate the intent and, in 3.0, apply `required` one way only. | +| Empty [`enum`][js-enum] | 3.0 requires at least one value. An empty `enum` rejects everything, so dropping it only loosens the schema. | +| [`not`][js-not] over a loosened schema | Negating a looser schema would reject values the original accepts. | +| The exclusivity of [`oneOf`][js-one-of] with a loosened branch | Looser branches may overlap, so "exactly one" could reject values the original accepts. It becomes `anyOf`. | +| [`nullable`][3.0-schema-nullable], a 3.0 keyword | 3.1 ignores it, but in 3.0 it admits null, so keeping it would accept null where the original rejects it. Only `"null"` in `type` becomes `nullable: true`. | +| XML [`nodeType`][3.2-xml-node-type], a 3.2 field | As in 3.2 → 3.1, kept only as `attribute: true` or `wrapped: true`, and `name` is removed beside `"text"`, `"cdata"`, and `"none"`. When several types become `anyOf`, `wrapped` moves into the `array` branch. | [js-schema]: https://json-schema.org/draft/2020-12/json-schema-core#name-the-schema-keyword [js-vocabulary]: https://json-schema.org/draft/2020-12/json-schema-core#name-the-vocabulary-keyword diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 091a5d4..43e6eff 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -191,7 +191,21 @@ export function allOfItems(allOf: unknown): unknown[] { return allOf === undefined ? [] : [{ allOf }] } -export function convertXml(value: unknown, _ctx: Context, schema: Record): unknown { +// 3.2 defaults a `$ref` to no node, as it does an array, so an explicit +// `element` there wraps the referenced array. +function describesArray(schema: Record, ctx: Context): boolean { + if (hasType(schema.type, 'array')) { + return true + } + const ref = rebasedRef(schema, ctx.base) + if (ref === undefined) { + return false + } + const target = ctx.resolve(skipAliases(ref, ctx, (_next, hop) => !hasType(hop.type, 'array'))) + return isRecord(target) && hasType(target.type, 'array') +} + +export function convertXml(value: unknown, ctx: Context, schema: Record): unknown { if (!isRecord(value)) { return clone(value) } @@ -200,9 +214,14 @@ export function convertXml(value: unknown, _ctx: Context, schema: Record): Record { + const fields: Record = { items: out.items ?? {} } + delete out.items + if (isRecord(out.xml) && has(out.xml, 'wrapped')) { + const { wrapped: _, ...xml } = out.xml + fields.xml = out.xml + out.xml = xml + } + return fields +} + function convertType(out: Record, type: unknown): boolean { if (typeof type === 'string' && type !== 'null') { out.type = type @@ -273,14 +287,12 @@ function convertType(out: Record, type: unknown): boolean { } } else if (rest.length > 1) { + const arrayFields = rest.includes('array') && takeArrayFields(out) addAnyOf(out, rest.map(item => ({ type: item, - ...(item === 'array' && { items: out.items ?? {} }), + ...(item === 'array' && arrayFields), ...(nullable && { nullable: true }), }))) - if (rest.includes('array')) { - delete out.items - } } else if (out.enum === undefined) { out.enum = [null] 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 6366478..5c0f68a 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 @@ -76,8 +76,18 @@ describe('xml.nodeType', () => { ['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 } }], + [ + 'maps element on a type union with an array to wrapped: true in the array branch', + { type: ['array', 'string'], xml: { name: 'w', nodeType: 'element' } }, + { anyOf: [{ items: {}, type: 'array', xml: { name: 'w', wrapped: true } }, { type: 'string' }], xml: { name: 'w' } }, + ], + [ + 'maps element on a $ref to an array to wrapped: true', + { $defs: { arr: { type: 'array' } }, $ref: '#/$defs/arr', xml: { nodeType: 'element' } }, + { allOf: [{ items: {}, 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' } }], + ['removes values 3.0 cannot express and their name', { type: 'string', xml: { name: 'n', nodeType: 'text' } }, { type: 'string', xml: {} }], ['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) => { 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 7ec22dd..4868519 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 @@ -72,6 +72,22 @@ describe('several types', () => { { items: { type: 'integer' }, type: ['array', 'string', 'null'] }, { anyOf: [{ items: { type: 'integer' }, nullable: true, type: 'array' }, { nullable: true, type: 'string' }] }, ], + // `xml.wrapped` likewise applies only beside `type: "array"`: + // https://spec.openapis.org/oas/v3.0.4.html#xml-wrapped + // The rest of `xml` names the element whatever its type, so it stays. + [ + 'moves xml.wrapped into the array branch', + { type: ['array', 'string', 'null'], xml: { name: 'w', prefix: 'p', wrapped: true } }, + { + anyOf: [{ items: {}, nullable: true, type: 'array', xml: { name: 'w', prefix: 'p', wrapped: true } }, { nullable: true, type: 'string' }], + xml: { name: 'w', prefix: 'p' }, + }, + ], + [ + 'leaves xml in place when no branch is an array', + { type: ['object', 'string'], xml: { name: 'w', wrapped: true } }, + { anyOf: [{ type: 'object' }, { type: 'string' }], xml: { name: 'w', wrapped: true } }, + ], [ 'leaves items in place when no branch is an array', { items: { type: 'integer' }, type: ['object', 'string'] }, 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 ec0ecd0..cf1b4a6 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 @@ -18,20 +18,51 @@ describe('xml.nodeType', () => { // 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. + // `cdata`, and `none` have no 3.1 form, so those are removed. 3.2 ignores + // `name` on those three, while 3.1 would name an element after it, so it + // goes too. 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: {} }], + ['removes a text node and its name', { xml: { name: 'n', nodeType: 'text' } }, { xml: {} }], + ['removes a cdata node and its name', { xml: { name: 'n', nodeType: 'cdata' } }, { xml: {} }], + ['removes a none node and its name', { xml: { name: 'n', nodeType: 'none' } }, { xml: {} }], + ['keeps the rest of a text node', { xml: { name: 'n', namespace: 'urn:x', nodeType: 'text', prefix: 'p' } }, { xml: { namespace: 'urn:x', prefix: 'p' } }], ['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(convertSchema(input)).toEqual(expected) }) + + // A `$ref` also defaults to `none`, so an explicit `element` beside one + // wraps the referenced array, which 3.1 writes as `wrapped: true`: + // https://spec.openapis.org/oas/v3.2.0.html#xml-node-type + // The type is looked up along the whole `$ref` chain. + it.each([ + ['maps an element node on a $ref to an array to wrapped: true', { $defs: { arr: { type: 'array' } }, $ref: '#/$defs/arr' }, { wrapped: true }], + ['follows an alias to the array', { $defs: { alias: { $ref: '#/$defs/arr' }, arr: { type: ['array', 'null'] } }, $ref: '#/$defs/alias' }, { wrapped: true }], + ['stops at a hop that is an array', { $defs: { arr: { $ref: '#/$defs/base', type: 'array' }, base: { minItems: 1 } }, $ref: '#/$defs/arr' }, { wrapped: true }], + ['removes an element node on a $ref to a non-array', { $defs: { obj: { type: 'object' } }, $ref: '#/$defs/obj' }, {}], + ['removes an element node on an unresolvable $ref', { $ref: '#/$defs/missing' }, {}], + ['removes an element node on a $ref loop', { $defs: { a: { $ref: '#/$defs/b' }, b: { $ref: '#/$defs/a' } }, $ref: '#/$defs/a' }, {}], + ])('%s', (_name, refs, xml) => { + expect(convertSchema({ ...refs, xml: { nodeType: 'element' } })).toEqual({ ...refs, xml }) + }) + + // Inside a schema with an `$id`, the `$ref` resolves against that `$id`: + // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.1 + it('resolves the $ref against the enclosing $id', () => { + const list = { $defs: { arr: { type: 'array' } }, $id: 'https://example.com/list', $ref: '#/$defs/arr' } + expect(convertSchema({ + $defs: { arr: { type: 'object' } }, + properties: { list: { ...list, xml: { nodeType: 'element' } } }, + })).toEqual({ + $defs: { arr: { type: 'object' } }, + properties: { list: { ...list, xml: { wrapped: true } } }, + }) + }) }) describe('discriminator.defaultMapping', () => { 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 index faee838..ff9e973 100644 --- 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 @@ -1,3 +1,4 @@ +import { dig } from '../../helpers' import { convertComponent, convertSpec } from './helpers' describe('component maps', () => { @@ -37,6 +38,17 @@ describe('component maps', () => { }) }) + it('wraps a components.schemas entry that references an array', () => { + expect(dig(convertSpec({ + components: { + schemas: { + List: { type: 'array' }, + Wrapped: { $ref: '#/components/schemas/List', xml: { name: 'w', nodeType: 'element' } }, + }, + }, + }), 'components', 'schemas', 'Wrapped')).toEqual({ $ref: '#/components/schemas/List', xml: { name: 'w', wrapped: 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')