Skip to content
52 changes: 26 additions & 26 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,21 @@ export function allOfItems(allOf: unknown): unknown[] {
return allOf === undefined ? [] : [{ allOf }]
}

export function convertXml(value: unknown, _ctx: Context, schema: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>): unknown {
if (!isRecord(value)) {
return clone(value)
}
Expand All @@ -200,9 +214,14 @@ export function convertXml(value: unknown, _ctx: Context, schema: Record<string,
if (nodeType === 'attribute') {
out.attribute = true
}
else if (nodeType === 'element' && hasType(schema.type, 'array')) {
else if (nodeType === 'element' && describesArray(schema, ctx)) {
out.wrapped = true
}
else if (nodeType === 'text' || nodeType === 'cdata' || nodeType === 'none') {
// 3.2 ignores `name` on these, but an older version would name an element
// after it.
delete out.name
}
return out
}

Expand Down
20 changes: 16 additions & 4 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,20 @@ function convertSchemaRef(ref: string, ctx: Context): unknown {
return out === DROP ? loosened({}) : out
}

// 3.0 applies `items` and `xml.wrapped` only beside `type: array`, so a type
// union moves them into its array branch. The rest of `xml` names the element
// whatever the type, so it stays.
function takeArrayFields(out: Record<string, unknown>): Record<string, unknown> {
const fields: Record<string, unknown> = { 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<string, unknown>, type: unknown): boolean {
if (typeof type === 'string' && type !== 'null') {
out.type = type
Expand All @@ -273,14 +287,12 @@ function convertType(out: Record<string, unknown>, 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]
Expand Down
Loading
Loading