From aa70dbcd52b9d630eb90220a3770e7e9f35eea0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:48:16 +0000 Subject: [PATCH 1/4] fix(downgrader): convert XML nodeType where 3.2 ignores name or wraps via $ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `xml.name` beside `nodeType` `text`, `cdata`, or `none`, which 3.2 ignores but an older version would use to name an element. - Treat an explicit `element` on a `$ref` to an array as `wrapped: true`, looking the type up along the `$ref` chain, since 3.2 defaults a `$ref` to `none` as it does an array. - When 3.1 → 3.0 splits a type union into `anyOf`, move `xml.wrapped` into the array branch, the only place 3.0 applies it. The rest of `xml` stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CfwrBWKobQGsT53QRjUbBm --- packages/downgrader/README.md | 50 +++++++-------- packages/downgrader/src/shared.ts | 23 ++++++- packages/downgrader/src/v3.1-to-v3.0.ts | 20 ++++-- .../v3.1-to-v3.0/schema/annotations.test.ts | 17 ++++- .../tests/v3.1-to-v3.0/schema/type.test.ts | 16 +++++ .../v3.2-to-v3.1/schema/keywords.test.ts | 62 ++++++++++++++++++- 6 files changed, 153 insertions(+), 35 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index c096ad1..e8fc78e 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -127,10 +127,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 @@ -188,27 +188,27 @@ 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. `$ref`s through them are left 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. | -| 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. `$ref`s through them are left 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. | +| 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 9e60bfe..aac48be 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -156,7 +156,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 = getRef(schema) + 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) } @@ -165,9 +179,14 @@ export function convertXml(value: unknown, _ctx: Context, schema: Record): Record { + const fields: Record = { items: out.items ?? {} } + delete out.items + if (isRecord(out.xml) && Object.hasOwn(out.xml, 'wrapped')) { + const { wrapped, ...xml } = out.xml + fields.xml = { ...xml, wrapped } + out.xml = xml + } + return fields +} + function convertType(out: Record, type: unknown): boolean { if (typeof type === 'string' && type !== 'null') { out.type = type @@ -253,14 +267,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 a12af91..81457e3 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 @@ -52,13 +52,26 @@ describe('binary content', () => { 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. + // 3.2 → 3.1 converter maps it the same way. 3.0 applies `wrapped` only + // beside `type: array`, so on a type union it goes into the array branch: + // https://spec.openapis.org/oas/v3.0.4.html#xml-wrapped 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 } }], + [ + 'maps element on a type union with an array to wrapped: true in the array branch', + { items: {}, 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' } }], + // 3.2 ignores `name` beside `text`, `cdata`, and `none`, so it goes too. + ['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..ad7e5d5 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 @@ -4,7 +4,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' -import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' +import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' import { convertSchema } from './helpers' describe('xml.nodeType', () => { @@ -18,7 +18,9 @@ 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 } }], @@ -27,11 +29,67 @@ describe('xml.nodeType', () => { ['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 the name of a text node', { type: 'string', xml: { name: 'n', nodeType: 'text' } }, { type: 'string', xml: {} }], + ['removes the name of a cdata node', { type: 'string', xml: { name: 'n', nodeType: 'cdata' } }, { type: 'string', xml: {} }], + ['removes the name of a none node', { type: 'object', xml: { name: 'n', nodeType: 'none' } }, { type: 'object', 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: { items: {}, type: 'array' } }, $ref: '#/$defs/arr', xml: { name: 'w', nodeType: 'element' } }, + { $defs: { arr: { items: {}, type: 'array' } }, $ref: '#/$defs/arr', xml: { name: 'w', wrapped: true } }, + ], + [ + 'follows an alias to the array', + { $defs: { alias: { $ref: '#/$defs/arr' }, arr: { type: ['array', 'null'] } }, $ref: '#/$defs/alias', xml: { nodeType: 'element' } }, + { $defs: { alias: { $ref: '#/$defs/arr' }, arr: { type: ['array', 'null'] } }, $ref: '#/$defs/alias', xml: { wrapped: true } }, + ], + [ + 'stops at a hop that is an array', + { $defs: { arr: { $ref: '#/$defs/base', type: 'array' }, base: { minItems: 1 } }, $ref: '#/$defs/arr', xml: { nodeType: 'element' } }, + { $defs: { arr: { $ref: '#/$defs/base', type: 'array' }, base: { minItems: 1 } }, $ref: '#/$defs/arr', xml: { wrapped: true } }, + ], + [ + 'removes an element node on a $ref to a non-array', + { $defs: { obj: { type: 'object' } }, $ref: '#/$defs/obj', xml: { nodeType: 'element' } }, + { $defs: { obj: { type: 'object' } }, $ref: '#/$defs/obj', xml: {} }, + ], + ['removes an element node on an unresolvable $ref', { $ref: '#/$defs/missing', xml: { nodeType: 'element' } }, { $ref: '#/$defs/missing', xml: {} }], + [ + 'removes an element node on a $ref loop', + { $defs: { a: { $ref: '#/$defs/b' }, b: { $ref: '#/$defs/a' } }, $ref: '#/$defs/a', xml: { nodeType: 'element' } }, + { $defs: { a: { $ref: '#/$defs/b' }, b: { $ref: '#/$defs/a' } }, $ref: '#/$defs/a', xml: {} }, + ], + ])('%s', (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected) + }) + + it('maps an element node on a $ref to a component array to wrapped: true', () => { + const spec = { + components: { + schemas: { + List: { items: { type: 'string' }, type: 'array' }, + Wrapped: { $ref: '#/components/schemas/List', xml: { name: 'w', nodeType: 'element' } }, + }, + }, + info: { title: 't', version: '1' }, + openapi: '3.2.0', + } satisfies OpenAPIV3_2.OpenAPIObject + expect(downgradeSpecV32ToV31(spec).components?.schemas?.Wrapped).toEqual({ + $ref: '#/components/schemas/List', + xml: { name: 'w', wrapped: true }, + }) + }) }) describe('discriminator.defaultMapping', () => { From b9ba262413e9a76024863430dbbc9c9bf1104e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:57:04 +0000 Subject: [PATCH 2/4] refactor(downgrader): simplify XML nodeType fix and its tests - Hand the array branch the already-fresh `xml` instead of copying it twice. - Fold the dropped-`name` checks into the existing text/cdata/none rows and share the `$ref` fixtures between input and expected output. - Move the whole-document `$ref` case to spec/components.test.ts, where it uses `convertSpec`, and drop comments repeated across files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CfwrBWKobQGsT53QRjUbBm --- packages/downgrader/src/v3.1-to-v3.0.ts | 6 +- .../v3.1-to-v3.0/schema/annotations.test.ts | 7 +- .../v3.2-to-v3.1/schema/keywords.test.ts | 64 ++++--------------- .../v3.2-to-v3.1/spec/components.test.ts | 12 ++++ 4 files changed, 29 insertions(+), 60 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 1f881df..838817b 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -239,8 +239,8 @@ function takeArrayFields(out: Record): Record const fields: Record = { items: out.items ?? {} } delete out.items if (isRecord(out.xml) && Object.hasOwn(out.xml, 'wrapped')) { - const { wrapped, ...xml } = out.xml - fields.xml = { ...xml, wrapped } + const { wrapped: _, ...xml } = out.xml + fields.xml = out.xml out.xml = xml } return fields @@ -267,7 +267,7 @@ function convertType(out: Record, type: unknown): boolean { } } else if (rest.length > 1) { - const arrayFields = rest.includes('array') ? takeArrayFields(out) : {} + const arrayFields = rest.includes('array') && takeArrayFields(out) addAnyOf(out, rest.map(item => ({ type: item, ...(item === 'array' && arrayFields), 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 81457e3..4bc7252 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 @@ -52,16 +52,14 @@ describe('binary content', () => { 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. 3.0 applies `wrapped` only - // beside `type: array`, so on a type union it goes into the array branch: - // https://spec.openapis.org/oas/v3.0.4.html#xml-wrapped + // 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 } }], [ 'maps element on a type union with an array to wrapped: true in the array branch', - { items: {}, type: ['array', 'string'], xml: { name: 'w', nodeType: 'element' } }, + { type: ['array', 'string'], xml: { name: 'w', nodeType: 'element' } }, { anyOf: [{ items: {}, type: 'array', xml: { name: 'w', wrapped: true } }, { type: 'string' }], xml: { name: 'w' } }, ], [ @@ -70,7 +68,6 @@ describe('xml.nodeType', () => { { allOf: [{ items: {}, type: 'array' }], xml: { wrapped: true } }, ], ['removes element on other schemas', { type: 'string', xml: { nodeType: 'element' } }, { type: 'string', xml: {} }], - // 3.2 ignores `name` beside `text`, `cdata`, and `none`, so it goes too. ['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' }], 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 ad7e5d5..cdda774 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 @@ -4,7 +4,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' -import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' +import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' import { convertSchema } from './helpers' describe('xml.nodeType', () => { @@ -26,12 +26,9 @@ describe('xml.nodeType', () => { ['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 the name of a text node', { type: 'string', xml: { name: 'n', nodeType: 'text' } }, { type: 'string', xml: {} }], - ['removes the name of a cdata node', { type: 'string', xml: { name: 'n', nodeType: 'cdata' } }, { type: 'string', xml: {} }], - ['removes the name of a none node', { type: 'object', xml: { name: 'n', nodeType: 'none' } }, { type: 'object', 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' }], @@ -44,51 +41,14 @@ describe('xml.nodeType', () => { // 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: { items: {}, type: 'array' } }, $ref: '#/$defs/arr', xml: { name: 'w', nodeType: 'element' } }, - { $defs: { arr: { items: {}, type: 'array' } }, $ref: '#/$defs/arr', xml: { name: 'w', wrapped: true } }, - ], - [ - 'follows an alias to the array', - { $defs: { alias: { $ref: '#/$defs/arr' }, arr: { type: ['array', 'null'] } }, $ref: '#/$defs/alias', xml: { nodeType: 'element' } }, - { $defs: { alias: { $ref: '#/$defs/arr' }, arr: { type: ['array', 'null'] } }, $ref: '#/$defs/alias', xml: { wrapped: true } }, - ], - [ - 'stops at a hop that is an array', - { $defs: { arr: { $ref: '#/$defs/base', type: 'array' }, base: { minItems: 1 } }, $ref: '#/$defs/arr', xml: { nodeType: 'element' } }, - { $defs: { arr: { $ref: '#/$defs/base', type: 'array' }, base: { minItems: 1 } }, $ref: '#/$defs/arr', xml: { wrapped: true } }, - ], - [ - 'removes an element node on a $ref to a non-array', - { $defs: { obj: { type: 'object' } }, $ref: '#/$defs/obj', xml: { nodeType: 'element' } }, - { $defs: { obj: { type: 'object' } }, $ref: '#/$defs/obj', xml: {} }, - ], - ['removes an element node on an unresolvable $ref', { $ref: '#/$defs/missing', xml: { nodeType: 'element' } }, { $ref: '#/$defs/missing', xml: {} }], - [ - 'removes an element node on a $ref loop', - { $defs: { a: { $ref: '#/$defs/b' }, b: { $ref: '#/$defs/a' } }, $ref: '#/$defs/a', xml: { nodeType: 'element' } }, - { $defs: { a: { $ref: '#/$defs/b' }, b: { $ref: '#/$defs/a' } }, $ref: '#/$defs/a', xml: {} }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - - it('maps an element node on a $ref to a component array to wrapped: true', () => { - const spec = { - components: { - schemas: { - List: { items: { type: 'string' }, type: 'array' }, - Wrapped: { $ref: '#/components/schemas/List', xml: { name: 'w', nodeType: 'element' } }, - }, - }, - info: { title: 't', version: '1' }, - openapi: '3.2.0', - } satisfies OpenAPIV3_2.OpenAPIObject - expect(downgradeSpecV32ToV31(spec).components?.schemas?.Wrapped).toEqual({ - $ref: '#/components/schemas/List', - xml: { name: 'w', wrapped: true }, - }) + ['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 }) }) }) 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') From e21aa4b009d0dd1896e0be6361d208b7fb8fa091 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:14:13 +0000 Subject: [PATCH 3/4] fix(downgrader): resolve the XML wrapper $ref against the enclosing $id Since $refs inside a schema with an $id resolve against that $id, look up whether an `element` node's $ref targets an array from the rebased $ref, as inlineSchema does, rather than from the document root. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CfwrBWKobQGsT53QRjUbBm --- packages/downgrader/src/shared.ts | 2 +- .../tests/v3.2-to-v3.1/schema/keywords.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index c6ddeee..c4f99fc 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -179,7 +179,7 @@ function describesArray(schema: Record, ctx: Context): boolean if (hasType(schema.type, 'array')) { return true } - const ref = getRef(schema) + const ref = rebasedRef(schema, ctx.base) if (ref === undefined) { return false } 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 cdda774..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 @@ -50,6 +50,19 @@ describe('xml.nodeType', () => { ])('%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', () => { From a74a484e5058844dd7ffda3b33c4626cff8ddb05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:15:44 +0000 Subject: [PATCH 4/4] refactor(downgrader): check xml.wrapped with has() Matches how the converters now test for a key, treating one that holds undefined as missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CfwrBWKobQGsT53QRjUbBm --- packages/downgrader/src/v3.1-to-v3.0.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3a4c58d..79f7bf0 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -245,7 +245,7 @@ function convertSchemaRef(ref: string, ctx: Context): unknown { function takeArrayFields(out: Record): Record { const fields: Record = { items: out.items ?? {} } delete out.items - if (isRecord(out.xml) && Object.hasOwn(out.xml, 'wrapped')) { + if (isRecord(out.xml) && has(out.xml, 'wrapped')) { const { wrapped: _, ...xml } = out.xml fields.xml = out.xml out.xml = xml