diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d746931..cd982a4 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -18,13 +18,14 @@ -`@openapi-spec/downgrader` downgrades [OpenAPI Specification](https://spec.openapis.org/) documents one minor version at a time: 3.2 to 3.1 and 3.1 to 3.0. Use it when you author against a newer version than your tools accept, such as a code generator, gateway, or validator that stops at 3.0 or 3.1. Each converter handles a whole document or a single Schema Object. +`@openapi-spec/downgrader` downgrades [OpenAPI Specification](https://spec.openapis.org/) documents one minor version at a time: 3.2 to 3.1 and 3.1 to 3.0. Use it when your tools, such as a code generator, gateway, or validator, only support an older version. Each step converts a whole document or a single Schema Object. Every converter follows the same contract: -- **Never throws.** Malformed parts are deep-copied through unchanged instead of failing the whole conversion. Cyclic object graphs, such as the output of a `$ref` dereferencer, convert with their cycles preserved. Only pathologically deep nesting (thousands of levels) can still exhaust the call stack. -- **Never mutates.** The input is left untouched and the result is a new object. Objects shared within the input, such as a dereferenced schema used in several places, may stay shared within the result, and so may a target inlined at several references. -- **Preserves extensions, never invents them.** `x-` keys and unknown keys survive. Constructs the target version cannot express are converted where an equivalent exists and removed otherwise. +- **Loses detail, never meaning.** Anything the target version lacks is converted to an equivalent or, failing that, removed. A downgraded schema accepts every value the original accepts, and possibly more. The exceptions are `contentEncoding` or `contentMediaType` without a `type`, which 3.0 can only express with `type: string`, and the [known limitations](#known-limitations). +- **Adds no dangling references.** A local `$ref` whose target is removed or moved is replaced by the converted target. [References](#references) lists the exceptions. +- **Never throws, never mutates.** The result is a new object. Unexpected shapes are copied through as they are, references inside them included. Cyclic input, such as a dereferenced document, stays cyclic, and the result may reuse one object in several places. Only nesting thousands of levels deep can overflow the stack. +- **Keeps extensions.** `x-` keys and unknown keys survive, except on Reference Objects that are inlined or downgraded to 3.0. ## Usage @@ -58,89 +59,94 @@ All types come from [`@openapi-spec/types`](https://github.com/middleapi/openapi ## 3.2 → 3.1 -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. +Schema Objects change only in `xml.nodeType`, `discriminator.defaultMapping`, and references into removed or moved parts. Converted: -| 3.2 construct | 3.1 result | -| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 | 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 | +| 3.2 construct | 3.1 result | +| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | +| `$ref` in a `content` map | the converted Media Type Object; an external, missing, or looping one is removed, along with a parameter or header left without `content` | +| media type `itemSchema` without `schema` | `schema: { type: "array", items: … }` | +| response without `description` | `description` from `summary`, or `""` | +| example `dataValue` / `serializedValue` without `value` / `externalValue` | `value`, preferring `dataValue` | +| XML `nodeType: "attribute"`, or `"element"` on an array | `attribute: true`, or `wrapped: true` | +| parameter `style: "cookie"` | removed, so the 3.1 default `form` applies | Removed, with no 3.1 equivalent: -- `$self` -- server `name` -- tag `summary`, `parent`, and `kind` +- `$self` and `components.mediaTypes` +- server `name`, and 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 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 -- `itemSchema` beside an existing `schema`, and response `summary` beside an existing `description` -- OAuth `deviceAuthorization` flows -- security scheme `oauth2MetadataUrl` and `deprecated` - -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. +- `in: "querystring"` parameters, and `allowReserved` on non-query parameters +- parameter and header `example` / `examples` beside `content` +- media type `description`, `prefixEncoding`, `itemEncoding`, and Encoding Object `encoding` +- `itemSchema`, response `summary`, and example `dataValue` / `serializedValue`, after the conversions above +- other XML `nodeType` values and `discriminator.defaultMapping` +- OAuth `deviceAuthorization` flows, and security scheme `oauth2MetadataUrl` and `deprecated` ## 3.1 → 3.0 Converted: -| 3.1 construct | 3.0 result | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `openapi: 3.1.x` | `openapi: 3.0.4` | -| missing `paths` | `{}` (required in 3.0) | -| missing operation `responses` | `{ "default": { "description": "" } }` (required and non-empty in 3.0) | -| path parameters without `required: true` | `required: true` added (mandatory for `in: "path"`) | -| Reference Object `summary` / `description` | applied to an inlined target whose type has the field, removed otherwise (3.0 references carry no overrides) | -| security requirement scopes on `apiKey` and `http` schemes | emptied to `[]` | +| 3.1 construct | 3.0 result | +| ---------------------------------------------------------- | -------------------------------------- | +| `openapi: 3.1.x` | `openapi: 3.0.4` | +| missing `paths` | `{}` | +| missing operation `responses` | `{ "default": { "description": "" } }` | +| path parameter without `required: true` | `required: true` | +| security requirement scopes on `apiKey` and `http` schemes | `[]` | Removed, with no 3.0 equivalent: -- `webhooks` and `components.pathItems`, after same-document references into them are resolved: - - Reference Objects, Path Item `$ref`s, and Schema `$ref`s are replaced by their target in converted form, following reference chains. A chain that reaches a `$ref` outside them ends at that `$ref`, and a Path Item's own fields win over inlined ones. - - A target referenced from several places is converted once and shared. Anything reached again while it is still being converted, through a reference or an object shared within the input, is cut: a Schema Object becomes `{}`, a Path Item reference keeps only its own fields, and anything else is removed. A recursive schema keeps one level, and where a cycle is cut can depend on document order. - - A Link `operationRef` into them becomes the target operation's `operationId` when an operation with that `operationId` remains, such as one inlined into `paths`. Otherwise the link is removed, together with Link references that lead to it. - - `discriminator.mapping` entries pointing into them are removed. - - A reference whose target is missing, is not an object (a boolean Schema target converts as usual), or forms a reference loop is left as written, and so is a Path Item `$ref` with a hop that is not a `webhooks` or `components.pathItems` entry or a callback expression. -- `jsonSchemaDialect` +- `jsonSchemaDialect`, `webhooks`, and `components.pathItems` - `info.summary` and `license.identifier` -- `mutualTLS` security schemes, reference aliases included. Their names are stripped from every security requirement, a requirement left empty is removed, and a `security` list left empty is removed entirely, since an explicit empty list means "no security required" and would make the operation public. - -Schema Objects: - -| 3.1 construct | 3.0 result | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `true` / `false` boolean schemas | `{}` / `{ not: {} }` | -| `$ref` with sibling keywords | siblings kept, `$ref` moved into `allOf` | -| `type: ["T", "null"]` | `type: "T"` plus `nullable: true` | -| `type` with several non-null entries | `anyOf` of single-type schemas, each `nullable` when `null` was listed. A sibling `items` moves into the `array` variant | -| `type: "null"` | `enum: [null]`, since 3.0 ignores `nullable` without a `type`. A sibling `enum` or `const` is intersected with the null type: an `enum` containing `null` collapses to `[null]`, and one excluding it yields `not: {}`, since the source accepted no value | -| `const` | single-value `enum` | -| numeric `exclusiveMinimum` / `exclusiveMaximum` | `minimum` / `maximum` plus the boolean flag; a tighter existing bound wins | -| `examples` | first entry becomes `example` when none exists | -| `contentEncoding: base64` | `format: byte` unless `format` exists, plus `type: string` when `type` is missing. Skipped when `type` excludes `string` | -| `contentMediaType` without `contentEncoding` | as above, with `format: binary` | -| `type: "array"` without `items` | `items: {}` added (required in 3.0) | -| `enum: []` | removed (3.0 requires a non-empty `enum`) | -| `required: []` / duplicate `required` entries | removed / deduplicated (3.0 requires a non-empty, unique `required`) | -| XML `nodeType`, carried over from a 3.2 chain | `attribute: true` / `wrapped: true` where expressible, then removed (3.0 forbids unknown XML Object fields) | - -Removed, with no 3.0 equivalent: `$schema`, `$id`, `$defs`, `$anchor`, `$dynamicRef`, `$dynamicAnchor`, `$vocabulary`, `$comment`, `if` / `then` / `else`, `dependentSchemas`, `dependentRequired`, `prefixItems` (with its trailing `items`), `contains`, `minContains`, `maxContains`, `patternProperties` (with its sibling `additionalProperties`, whose meaning would otherwise tighten onto the pattern-matched keys), `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `contentSchema`, and a non-`base64` `contentEncoding` (`base64url` included) with its `contentMediaType`. In positive schema positions dropping these only loosens validation, the safe direction for a downgrade. - -Known limitations: - -- `$ref`s into dropped keywords outside `webhooks` and `components.pathItems` (`#/…/$defs/…` pointers, `$anchor` targets, `$id`-based bases) will dangle. Hoist reusable subschemas into `components.schemas` before downgrading. -- A pointer into `webhooks` or `components.pathItems` that passes through another `$ref` is not followed: a `$ref` keeps it and dangles, while a Link `operationRef` or `discriminator.mapping` entry of that shape is removed. A Link naming a removed operation only by `operationId` is kept, and a Path Item inlined in several places repeats its `operationId`s, which 3.0 requires to be unique. -- Non-standard schema keywords are preserved per the extension contract, even though the official 3.0 schema forbids unknown Schema Object fields. -- Dropping keywords inside `not`, where loosening the operand tightens the whole, or inside `oneOf` branches, where loosening one branch can break exclusivity, can change what validates. +- Reference Object fields other than `$ref`, such as `summary`, `description`, and extensions +- `mutualTLS` security schemes and their names in security requirements. An emptied requirement or `security` list is removed, because an empty one would mean no security. An operation then falls back to the root `security`. + +### Schema Objects + +A schema is _loosened_ when the conversion removes a restriction from it or a subschema, or when it contains an object cycle, as in a dereferenced document. + +Converted: + +| 3.1 construct | 3.0 result | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `true` / `false`, except as `additionalProperties` | `{}` / `{ not: {} }` | +| `$ref` with sibling keywords | siblings kept, `$ref` moved into `allOf` | +| `type: ["T", "null"]` | `type: "T"` plus `nullable: true` | +| `type` with several non-null entries | `anyOf` of single-type schemas, each `nullable` when `null` was listed; a sibling `items` moves into the `array` variant | +| `type: "null"` | `enum: [null]`, or `not: {}` when a sibling `enum` or `const` excludes `null` | +| `const` | single-value `enum` | +| numeric `exclusiveMinimum` / `exclusiveMaximum` | `minimum` / `maximum` plus the boolean flag; a tighter existing bound wins | +| `examples` | its first entry becomes `example` unless `example` exists | +| `contentEncoding: base64` | `format: byte` unless `format` exists, plus `type: string` when `type` is missing; nothing when `type` excludes `string` | +| `contentMediaType` without `contentEncoding` | as above, with `format: binary` | +| `type: "array"` without `items` | `items: {}` | +| `enum: []` / `required: []` | removed | +| duplicate `required` entries | deduplicated | +| `not` over a loosened schema | removed, since negating a looser schema would reject values the original accepts | +| `oneOf` with a loosened branch | `anyOf`, since looser branches may overlap | +| XML `nodeType` (a 3.2 field) | `attribute: true` / `wrapped: true` where expressible, then removed | + +Removed, with no 3.0 equivalent: `$schema`, `$id`, `$defs`, `$anchor`, `$dynamicRef`, `$dynamicAnchor`, `$vocabulary`, `$comment`, `if` / `then` / `else`, `dependentSchemas`, `dependentRequired`, `prefixItems` with its `items`, `contains`, `minContains`, `maxContains`, `patternProperties` with its `additionalProperties`, `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `contentSchema`, `contentEncoding`, `contentMediaType`, and `examples`. + +## References + +Both converters treat local `$ref`s the same way: + +- A `$ref` whose target is removed or moved is replaced by its converted target, following the reference chain until it leaves the removed part. Beside other schema keywords, the target joins `allOf`. When a Path Item `$ref` is replaced, its own fields win over the target's. This covers `webhooks`, `components.pathItems`, `components.mediaTypes`, `$defs`, `itemSchema`, `query` and `additionalOperations` operations, and parameter lists that lost entries. +- A target inlined in several places is converted once and shared. Where it refers back to itself, the inner reference becomes `{}` in a schema, keeps only its own fields on a Path Item, and is removed elsewhere. +- A `$ref` to an object the target version cannot express, such as a `querystring` parameter or a `mutualTLS` scheme, is removed with it. So are Links and discriminator `mapping` entries that point into a removed part. +- Inlining ignores the Reference Object's own fields, such as `summary`, `description`, and extensions. +- Left as written, even if they then dangle: external references, `$anchor` references, references that already dangle, `$ref` chains that loop, references to values other than objects and boolean schemas, and Path Item `$ref`s whose target is not a Path Item. The exception is a `$ref` in a 3.2 `content` map, which is removed because 3.1 cannot hold a reference there. + +## Known limitations + +- Both: a Link that names a removed operation (`query`, `additionalOperations`, a webhook) by `operationId` is kept, and a Path Item inlined in several places repeats its `operationId`s. +- 3.2 → 3.1: security requirements keyed by URI, `$self`-relative references, and a `$schema` naming the 3.2 dialect pass through unchanged. Where recursion becomes `{}`, an enclosing `not`, `oneOf`, `if`, or `unevaluated*` can reject values the original accepts. +- 3.1 → 3.0: `$ref`s to an `$anchor` or resolved against an `$id` base are left as written and dangle, so rewrite them as JSON pointers first. A `not` or `oneOf` that reaches a loosened schema through a `$ref` kept in the output can reject values the original accepts. Non-standard schema keywords are kept, although the official 3.0 schema forbids them. ## Sponsors diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 347e83b..38a6da2 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -1,21 +1,23 @@ -import type { FieldTable } from './shared' +import type { Context } from './shared' import { dig } from '../tests/helpers' import { - convertInlined, - convertRecord, - deepClone, + allOfItems, + child, + clone, + convertMappingRef, + convertObject, + defineFields, + downgrade, DROP, - getChild, getRef, - HTTP_METHODS_UP_TO_V31, - isConverting, + inline, isRecord, - mapArray, - mapRecord, - operationFields, - parseLocalRef, - resolveLocalRef, + list, + map, + refOr, + removedPrefixes, + resolve, setOwn, } from './shared' @@ -23,11 +25,56 @@ function identity(value: T): T { return value } -function convertNode(value: unknown): unknown { - return convertRecord(value, { - name: () => 'converted', - self: item => convertNode(item), - }) +function createContext(root: unknown = {}): Context { + return { + aliasEnd: () => undefined, + converting: [], + copies: new Map(), + dangles: () => false, + inlined: new Map(), + inlining: new Set(), + isRemovedPart: () => false, + markDangling: () => {}, + removals: new Map(), + resolve: ref => resolve(root, ref), + seen: new Map(), + } +} + +const NODE_FIELDS = defineFields({ + name: () => 'converted', + self: convertNode, +}) + +function convertNode(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, NODE_FIELDS) +} + +const convertItemRef = refOr(convertItem) + +const ITEM_FIELDS = defineFields({ + next: convertItemRef, + secret: DROP, +}) + +const DOCUMENT_FIELDS = defineFields({ + items: list(convertItemRef), + links: map(convertMappingRef), + named: map(convertItemRef, key => !key.startsWith('x-')), + removed: DROP, +}) + +function convertItem(value: unknown, ctx: Context): unknown { + return isRecord(value) && value.drop === true ? DROP : convertObject(value, ctx, ITEM_FIELDS) +} + +function convertDocument(value: unknown, removed?: string[]): { out: any, passes: number } { + let passes = 0 + const out = downgrade(value, (item, ctx) => { + passes += 1 + return convertObject(item, ctx, DOCUMENT_FIELDS) + }, removed) + return { out, passes } } describe('isRecord', () => { @@ -63,46 +110,44 @@ describe('isRecord', () => { }) }) -describe('deepClone', () => { +describe('clone', () => { it('deep-copies nested plain objects and arrays without sharing references', () => { const input = { list: [{ deep: { value: 1 } }, [2, 3]], nested: { inner: { leaf: 'x' } }, } - const clone = deepClone(input) - expect(clone).toEqual(input) - expect(clone).not.toBe(input) - expect(clone.list).not.toBe(input.list) - expect(clone.list[0]).not.toBe(input.list[0]) - expect(clone.list[1]).not.toBe(input.list[1]) - expect(clone.nested).not.toBe(input.nested) - expect(clone.nested.inner).not.toBe(input.nested.inner) + const copy = clone(input) as typeof input + expect(copy).toEqual(input) + expect(copy).not.toBe(input) + expect(copy.list).not.toBe(input.list) + expect(copy.list[0]).not.toBe(input.list[0]) + expect(copy.list[1]).not.toBe(input.list[1]) + expect(copy.nested).not.toBe(input.nested) + expect(copy.nested.inner).not.toBe(input.nested.inner) }) it('keeps functions and class instances by reference', () => { const date = new Date() - const map = new Map() - const clone = deepClone({ date, fn: identity, map }) - expect(clone.fn).toBe(identity) - expect(clone.date).toBe(date) - expect(clone.map).toBe(map) + const values = new Map() + const copy = clone({ date, fn: identity, values }) as Record + expect(copy.fn).toBe(identity) + expect(copy.date).toBe(date) + expect(copy.values).toBe(values) }) it('returns primitives as-is', () => { - expect(deepClone(1)).toBe(1) - expect(deepClone('a')).toBe('a') - expect(deepClone(null)).toBe(null) - expect(deepClone(true)).toBe(true) + expect(clone(1)).toBe(1) + expect(clone('a')).toBe('a') + expect(clone(null)).toBe(null) + expect(clone(true)).toBe(true) }) it('copies a hostile __proto__ own key as a plain own data property without prototype pollution', () => { const input: unknown = JSON.parse('{"__proto__": {"polluted": true}}') - const clone = deepClone(input) - expect(Object.getOwnPropertyNames(clone)).toContain('__proto__') - expect(Object.getOwnPropertyDescriptor(clone, '__proto__')?.value).toEqual({ - polluted: true, - }) - expect(Object.getPrototypeOf(clone)).toBe(Object.prototype) + const copy = clone(input) as object + expect(Object.getOwnPropertyNames(copy)).toContain('__proto__') + expect(Object.getOwnPropertyDescriptor(copy, '__proto__')?.value).toEqual({ polluted: true }) + expect(Object.getPrototypeOf(copy)).toBe(Object.prototype) expect('polluted' in {}).toBe(false) }) @@ -111,73 +156,68 @@ describe('deepClone', () => { input.zebra = 1 input.apple = 2 input.mango = 3 - expect(Object.keys(deepClone(input))).toEqual(['zebra', 'apple', 'mango']) + expect(Object.keys(clone(input) as object)).toEqual(['zebra', 'apple', 'mango']) }) it('preserves object cycles instead of recursing forever', () => { - const child: Record = {} - const node: Record = { child, name: 'root' } - child.parent = node - const clone = deepClone(node) - expect(clone).not.toBe(node) - expect(clone.name).toBe('root') - expect(dig(clone, 'child', 'parent')).toBe(clone) + const inner: Record = {} + const node: Record = { child: inner, name: 'root' } + inner.parent = node + const copy = clone(node) as Record + expect(copy).not.toBe(node) + expect(copy.name).toBe('root') + expect(dig(copy, 'child', 'parent')).toBe(copy) }) it('preserves array cycles', () => { - const list: unknown[] = [1] - list.push(list) - const clone = deepClone(list) - expect(clone).not.toBe(list) - expect(clone[0]).toBe(1) - expect(clone[1]).toBe(clone) + const items: unknown[] = [1] + items.push(items) + const copy = clone(items) as unknown[] + expect(copy).not.toBe(items) + expect(copy[0]).toBe(1) + expect(copy[1]).toBe(copy) }) it('clones shared references once', () => { const shared = { a: 1 } - const clone = deepClone({ x: shared, y: shared }) - expect(clone.x).toEqual({ a: 1 }) - expect(clone.x).not.toBe(shared) - expect(clone.x).toBe(clone.y) + const copy = clone({ x: shared, y: shared }) as Record + expect(copy.x).toEqual({ a: 1 }) + expect(copy.x).not.toBe(shared) + expect(copy.x).toBe(copy.y) }) it('returns a fresh copy on every call', () => { const shared = { a: 1 } - expect(deepClone(shared)).not.toBe(deepClone(shared)) + expect(clone(shared)).not.toBe(clone(shared)) }) }) -describe('convertRecord', () => { +describe('convertObject', () => { it('routes listed fields through their converters and deep-clones the rest', () => { const extra = { deep: true } - const result = convertRecord( - { a: 1, b: 2, extra }, - { a: item => [item], b: () => 'converted' }, - ) as Record + const result = convertObject({ a: 1, b: 2, extra }, createContext(), defineFields({ a: item => [item], b: () => 'converted' })) as Record expect(result).toEqual({ a: [1], b: 'converted', extra: { deep: true } }) expect(result.extra).not.toBe(extra) }) it('removes fields mapped to DROP and fields whose converter returns DROP', () => { - const result = convertRecord( - { gone: 1, kept: 2, maybe: 3 }, - { gone: DROP, maybe: item => (item === 3 ? DROP : item) }, - ) + const result = convertObject({ gone: 1, kept: 2, maybe: 3 }, createContext(), defineFields({ gone: DROP, maybe: item => (item === 3 ? DROP : item) })) expect(result).toEqual({ kept: 2 }) }) it('passes the whole source record to converters and to finish', () => { const source = { flag: true, value: 1 } - const result = convertRecord( + const result = convertObject( source, - { value: (item, record) => (record.flag ? item : DROP) }, + createContext(), + defineFields({ value: (item, _ctx, record) => (record.flag ? item : DROP) }), (out, record) => ({ ...out, sameSource: record === source }), ) expect(result).toEqual({ flag: true, sameSource: true, value: 1 }) }) it('lets finish replace the whole result', () => { - expect(convertRecord({ a: 1 }, {}, () => DROP)).toBe(DROP) + expect(convertObject({ a: 1 }, createContext(), defineFields({}), () => DROP)).toBe(DROP) }) it('preserves key order', () => { @@ -185,33 +225,27 @@ describe('convertRecord', () => { input.zebra = 1 input.apple = 2 input.mango = 3 - const result = convertRecord(input, { apple: identity }) as Record + const result = convertObject(input, createContext(), defineFields({ apple: identity })) as Record expect(Object.keys(result)).toEqual(['zebra', 'apple', 'mango']) }) it('deep-clones non-object input without consulting the table', () => { const convert = vi.fn(identity) - const list = [{ a: 1 }] - const result = convertRecord(list, { a: convert }) - expect(result).toEqual(list) - expect(result).not.toBe(list) - expect(convertRecord('text', { a: convert })).toBe('text') - expect(convertRecord(null, { a: convert })).toBe(null) + const items = [{ a: 1 }] + const result = convertObject(items, createContext(), defineFields({ a: convert })) + expect(result).toEqual(items) + expect(result).not.toBe(items) + expect(convertObject('text', createContext(), defineFields({ a: convert }))).toBe('text') + expect(convertObject(null, createContext(), defineFields({ a: convert }))).toBe(null) expect(convert).not.toHaveBeenCalled() }) it('does not look up table entries through the prototype chain', () => { - const input: unknown = JSON.parse( - '{"constructor": 1, "toString": 2, "__proto__": {"polluted": true}}', - ) - const result = convertRecord(input, {}) - expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe( - 1, - ) + const input: unknown = JSON.parse('{"constructor": 1, "toString": 2, "__proto__": {"polluted": true}}') + const result = convertObject(input, createContext(), defineFields({})) as object + expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(2) - expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toEqual( - { polluted: true }, - ) + expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toEqual({ polluted: true }) expect(Object.getPrototypeOf(result)).toBe(Object.prototype) expect('polluted' in {}).toBe(false) }) @@ -219,7 +253,7 @@ describe('convertRecord', () => { it('points a cyclic reference at the converted ancestor when re-entered for the same object', () => { const node: Record = { name: 'root' } node.self = node - const result = convertNode(node) as Record + const result = convertNode(node, createContext()) as Record expect(result.name).toBe('converted') expect(result.self).toBe(result) expect(node.self).toBe(node) @@ -227,183 +261,207 @@ describe('convertRecord', () => { it('converts a cycle that closes several levels down', () => { const grandchild: Record = { name: 'grandchild' } - const child: Record = { name: 'child', self: grandchild } - const root: Record = { name: 'root', self: child } - grandchild.self = child - const result = convertNode(root) as Record + const inner: Record = { name: 'child', self: grandchild } + const root: Record = { name: 'root', self: inner } + grandchild.self = inner + const result = convertNode(root, createContext()) as Record const convertedChild = result.self as Record const convertedGrandchild = convertedChild.self as Record expect(convertedChild.name).toBe('converted') expect(convertedGrandchild.name).toBe('converted') expect(convertedGrandchild.self).toBe(convertedChild) - expect(child.self).toBe(grandchild) + expect(inner.self).toBe(grandchild) }) it('releases the cycle guard once a conversion finishes', () => { const node: Record = { name: 'root' } node.self = node - const first = convertNode(node) as Record - const second = convertNode(node) as Record + const first = convertNode(node, createContext()) as Record + const second = convertNode(node, createContext()) as Record expect(second).not.toBe(first) expect(second.self).toBe(second) }) it('converts a shared reference once per call and reuses the result', () => { const shared = { name: 'x' } - const fields: FieldTable = { name: () => 'converted' } - const convert = (item: unknown) => convertRecord(item, fields) - const result = convertRecord({ a: shared, b: shared }, { a: convert, b: convert }) + const fields = defineFields({ name: () => 'converted' }) + const convert = (item: unknown, ctx: Context): unknown => convertObject(item, ctx, fields) + const result = convertObject({ a: shared, b: shared }, createContext(), defineFields({ a: convert, b: convert })) expect(result).toEqual({ a: { name: 'converted' }, b: { name: 'converted' } }) expect(dig(result, 'b')).toBe(dig(result, 'a')) }) it('clones a shared reference once per call', () => { const shared = { deep: true } - const result = convertRecord({ a: shared, b: [shared] }, {}) + const result = convertObject({ a: shared, b: [shared] }, createContext(), defineFields({})) expect(result).toEqual({ a: { deep: true }, b: [{ deep: true }] }) expect(dig(result, 'b', '0')).toBe(dig(result, 'a')) expect(dig(result, 'a')).not.toBe(shared) }) - it('reuses a finished result only for the same field table and finish', () => { + it('reuses a finished result only for the same field table', () => { const shared = { name: 'x' } - const fields: FieldTable = { name: () => 'converted' } - const wrap = (out: Record) => ({ wrapped: out }) - const result = convertRecord( - { a: shared, b: shared, c: shared, d: shared }, - { - a: item => convertRecord(item, fields, wrap), - b: item => convertRecord(item, fields, wrap), - c: item => convertRecord(item, fields), - d: item => convertRecord(item, {}), - }, - ) + const fields = defineFields({ name: () => 'converted' }) + const wrap = (out: Record): unknown => ({ wrapped: out }) + const result = convertObject({ a: shared, b: shared, c: shared }, createContext(), defineFields({ + a: (item, ctx) => convertObject(item, ctx, fields, wrap), + b: (item, ctx) => convertObject(item, ctx, fields, wrap), + c: (item, ctx) => convertObject(item, ctx, defineFields({})), + })) expect(result).toEqual({ a: { wrapped: { name: 'converted' } }, b: { wrapped: { name: 'converted' } }, - c: { name: 'converted' }, - d: { name: 'x' }, + c: { name: 'x' }, }) expect(dig(result, 'b')).toBe(dig(result, 'a')) }) + it('remembers the finished result, including DROP, for objects seen again', () => { + const ctx = createContext() + const fields = defineFields({}) + const finish = vi.fn(() => DROP) + const input = { a: 1 } + expect(convertObject(input, ctx, fields, finish)).toBe(DROP) + expect(convertObject(input, ctx, fields, finish)).toBe(DROP) + expect(finish).toHaveBeenCalledTimes(1) + }) + it('returns fresh results on every call', () => { const shared = { name: 'x' } - const fields: FieldTable = { name: () => 'converted' } - expect(convertRecord(shared, fields)).not.toBe(convertRecord(shared, fields)) - expect(dig(convertRecord({ a: shared }, {}), 'a')).not.toBe(dig(convertRecord({ a: shared }, {}), 'a')) + const fields = defineFields({ name: () => 'converted' }) + expect(convertObject(shared, createContext(), fields)).not.toBe(convertObject(shared, createContext(), fields)) + expect(dig(convertObject({ a: shared }, createContext(), defineFields({})), 'a')).not.toBe(dig(convertObject({ a: shared }, createContext(), defineFields({})), 'a')) }) it('releases the cycle guard when a converter throws', () => { const value = { a: 1 } - expect(() => - convertRecord(value, { - a: () => { - throw new Error('boom') - }, - }), - ).toThrow('boom') - expect(convertRecord(value, { a: () => 2 })).toEqual({ a: 2 }) + expect(() => convertObject(value, createContext(), defineFields({ + a: () => { + throw new Error('boom') + }, + }))).toThrow('boom') + expect(convertObject(value, createContext(), defineFields({ a: () => 2 }))).toEqual({ a: 2 }) }) it('forgets reused results and clones when a converter throws', () => { const shared = { name: 'x' } const convert = vi.fn(() => 'converted') - const fields: FieldTable = { name: convert } - let clone: unknown - expect(() => - convertRecord({ a: shared }, { - a: (item) => { - convertRecord(item, fields) - clone = deepClone(item) - throw new Error('boom') - }, - }), - ).toThrow('boom') - const result = convertRecord({ a: shared, b: shared }, { a: item => convertRecord(item, fields) }) + const fields = defineFields({ name: convert }) + let copy: unknown + expect(() => convertObject({ a: shared }, createContext(), defineFields({ + a: (item, ctx) => { + convertObject(item, ctx, fields) + copy = clone(item, ctx) + throw new Error('boom') + }, + }))).toThrow('boom') + const result = convertObject({ a: shared, b: shared }, createContext(), defineFields({ a: (item, ctx) => convertObject(item, ctx, fields) })) expect(convert).toHaveBeenCalledTimes(2) - expect(dig(result, 'b')).not.toBe(clone) + expect(dig(result, 'b')).not.toBe(copy) }) -}) -describe('operationFields', () => { - it('routes every HTTP method of a path item to the converter', () => { - const fields = operationFields(identity) - expect(Object.keys(fields)).toEqual([...HTTP_METHODS_UP_TO_V31]) - expect(Object.values(fields).every(entry => entry === identity)).toBe( - true, - ) + it('cuts an object still being converted when it is reached from another context', () => { + const source = { child: 'x' } + const node: Record = { name: 'root' } + node.self = node + const ctx = createContext({ node }) + const result = convertObject(source, ctx, defineFields({ + child: (_item, c) => ({ back: convertObject(source, c, defineFields({})), node: inline('#/node', c, convertNode) }), + })) + expect(dig(result, 'child', 'back')).toBe(DROP) + expect(dig(result, 'child', 'node', 'self')).toBe(dig(result, 'child', 'node')) }) -}) -describe('mapRecord', () => { - it('applies the converter to every value with the key as second argument', () => { - const calls: [unknown, string][] = [] - const result = mapRecord({ a: 1, b: 2 }, (item, key) => { - calls.push([item, key]) - return (item as number) * 10 + it('converts again, outside an inlined target, an object that was cut inside it', () => { + const LINK_FIELDS = defineFields({ + cut: (_item, c) => inline('#/child', c, convertLink), + self: convertLink, }) - expect(result).toEqual({ a: 10, b: 20 }) - expect(calls).toEqual([ - [1, 'a'], - [2, 'b'], - ]) + function convertLink(value: unknown, c: Context): unknown { + return convertObject(value, c, LINK_FIELDS) + } + const node: Record = { cut: 'x' } + const inner = { self: node } + node.self = inner + const result = convertLink(node, createContext({ child: inner })) + expect(dig(result, 'cut')).toEqual({}) + expect(dig(result, 'self', 'self')).toBe(result) + }) + + it('tracks only source records whose conversion is still in progress', () => { + const inner = { a: 1 } + const source = { child: inner } + const ctx = createContext() + const flags: boolean[] = [] + convertObject(source, ctx, defineFields({ + child: (item, c) => { + flags.push(c.converting.includes(source), c.converting.includes(item)) + return item + }, + })) + expect(flags).toEqual([true, false]) + expect(ctx.converting).toEqual([]) + }) +}) + +describe('map', () => { + it('applies the converter to the entries isEntry selects and clones the others', () => { + const keys: string[] = [] + const result = map(item => (item as number) * 10, (key) => { + keys.push(key) + return key !== 'raw' + })({ a: 1, b: 2, raw: 3 }, createContext()) + expect(result).toEqual({ a: 10, b: 20, raw: 3 }) + expect(keys).toEqual(['a', 'b', 'raw']) }) it('leaves out entries whose converter returns DROP', () => { - const result = mapRecord({ a: 1, b: 2, c: 3 }, item => - item === 2 ? DROP : item) - expect(result).toEqual({ a: 1, c: 3 }) + expect(map(item => (item === 2 ? DROP : item))({ a: 1, b: 2, c: 3 }, createContext())).toEqual({ a: 1, c: 3 }) }) it('preserves key order', () => { const input: Record = {} input.zebra = 1 input.apple = 2 - const result = mapRecord(input, identity) as Record - expect(Object.keys(result)).toEqual(['zebra', 'apple']) + expect(Object.keys(map(identity)(input, createContext()) as object)).toEqual(['zebra', 'apple']) }) it('deep-clones non-object input unchanged without calling the converter', () => { const convert = vi.fn(identity) - const array = [{ nested: true }] - const result = mapRecord(array, convert) - expect(result).toEqual(array) - expect(result).not.toBe(array) - expect(mapRecord('text', convert)).toBe('text') - expect(mapRecord(null, convert)).toBe(null) + const items = [{ nested: true }] + const result = map(convert)(items, createContext()) + expect(result).toEqual(items) + expect(result).not.toBe(items) + expect(map(convert)('text', createContext())).toBe('text') + expect(map(convert)(null, createContext())).toBe(null) expect(convert).not.toHaveBeenCalled() }) }) -describe('mapArray', () => { +describe('list', () => { it('applies the converter to every element', () => { - const result = mapArray([1, 2, 3], item => (item as number) + 1) - expect(result).toEqual([2, 3, 4]) + expect(list(item => (item as number) + 1)([1, 2, 3], createContext())).toEqual([2, 3, 4]) }) it('leaves out elements whose converter returns DROP', () => { - const result = mapArray([1, 2, 3], item => (item === 2 ? DROP : item)) - expect(result).toEqual([1, 3]) + expect(list(item => (item === 2 ? DROP : item))([1, 2, 3], createContext())).toEqual([1, 3]) }) it('deep-clones non-array input unchanged without calling the converter', () => { const convert = vi.fn(identity) const record = { nested: { deep: true } } - const result = mapArray(record, convert) + const result = list(convert)(record, createContext()) expect(result).toEqual(record) expect(result).not.toBe(record) - expect(mapArray(7, convert)).toBe(7) - expect(mapArray(undefined, convert)).toBe(undefined) + expect(list(convert)(7, createContext())).toBe(7) + expect(list(convert)(undefined, createContext())).toBe(undefined) expect(convert).not.toHaveBeenCalled() }) }) describe('getRef', () => { it('returns the $ref string of a reference-shaped object', () => { - expect(getRef({ $ref: '#/components/schemas/Pet' })).toBe( - '#/components/schemas/Pet', - ) + expect(getRef({ $ref: '#/components/schemas/Pet' })).toBe('#/components/schemas/Pet') }) it('returns undefined for non-objects', () => { @@ -422,109 +480,57 @@ describe('getRef', () => { }) }) -describe('convertInlined', () => { - it('drops a conversion still in progress outside the inline and keeps cycles inside it', () => { - const node: Record = { name: 'root' } - node.self = node - const source = { child: 'x' } - const result = convertRecord(source, { - child: () => convertInlined(() => ({ back: convertRecord(source, {}), node: convertNode(node) })), - }) - expect(dig(result, 'child', 'back')).toBe(DROP) - expect(dig(result, 'child', 'node', 'self')).toBe(dig(result, 'child', 'node')) - }) - - it('converts again, outside the inline, a result that was cut inside it', () => { - const node: Record = { name: 'root' } - const child = { self: node } - node.self = child - const convertChild = (item: unknown): unknown => convertRecord(item, { self: convertNode }) - const result = convertRecord(node, { - name: () => convertInlined(() => convertChild(child)), - self: convertChild, - }) - expect(dig(result, 'name')).toEqual({}) - expect(dig(result, 'self', 'self')).toBe(result) - }) -}) - -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', () => { +describe('child', () => { it('reads own record keys, including __proto__', () => { - expect(getChild({ a: 1 }, 'a')).toBe(1) - expect(getChild(JSON.parse('{"__proto__": 2}'), '__proto__')).toBe(2) + expect(child({ a: 1 }, 'a')).toBe(1) + expect(child(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() + const items = ['a', 'b'] + expect(child(items, '1')).toBe('b') + expect(child(items, '2')).toBeUndefined() + expect(child(items, '01')).toBeUndefined() + expect(child(items, '-')).toBeUndefined() + expect(child(items, 'length')).toBeUndefined() // eslint-disable-next-line no-sparse-arrays - expect(getChild([, 'b'], '0')).toBeUndefined() + expect(child([, '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() + expect(child({}, 'hasOwnProperty')).toBeUndefined() + expect(child('text', 'length')).toBeUndefined() + expect(child(null, 'a')).toBeUndefined() }) }) -describe('resolveLocalRef', () => { - const root = { a: [{ 'b/c': 1 }] } +describe('resolve', () => { + const root = { 'a': [{ 'b/c': 1 }], '': { empty: true }, 'a~b': 2, 'components': { schemas: { Pet: 3 } }, 'paths': { '/pets/{id}': 4 }, '~1': 5 } + + it('resolves a local pointer against the root, unescaping ~1 and ~0', () => { + expect(resolve(root, '#/a/0/b~1c')).toBe(1) + expect(resolve(root, '#/components/schemas/Pet')).toBe(3) + expect(resolve(root, '#/paths/~1pets~1{id}')).toBe(4) + expect(resolve(root, '#/a~0b')).toBe(2) + expect(resolve(root, '#/~01')).toBe(5) + }) + + it('percent-decodes the fragment before splitting it', () => { + expect(resolve(root, '#/paths/~1pets~1%7Bid%7D')).toBe(4) + expect(resolve({ a: { b: 6 } }, '#/a%2Fb')).toBe(6) + }) - it('resolves a local pointer against the root', () => { - expect(resolveLocalRef(root, '#/a/0/b~1c')).toBe(1) - expect(resolveLocalRef(root, '#')).toBe(root) + it('resolves the whole-document pointer and the empty key', () => { + expect(resolve(root, '#')).toBe(root) + expect(resolve(root, '#/')).toEqual({ empty: true }) }) - 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() + it('returns undefined for unresolvable, non-local, anchor, and malformed pointers', () => { + expect(resolve(root, '#/a/1')).toBeUndefined() + expect(resolve(root, '#/x/y/z')).toBeUndefined() + expect(resolve(root, 'other.json#/a')).toBeUndefined() + expect(resolve(root, '#anchor')).toBeUndefined() + expect(resolve(root, '#/%E0%A4%A')).toBeUndefined() }) }) @@ -532,12 +538,7 @@ describe('setOwn', () => { it('defines an enumerable, writable, configurable own property', () => { const target: Record = {} setOwn(target, 'name', 'value') - expect(Object.getOwnPropertyDescriptor(target, 'name')).toEqual({ - configurable: true, - enumerable: true, - value: 'value', - writable: true, - }) + expect(Object.getOwnPropertyDescriptor(target, 'name')).toEqual({ configurable: true, enumerable: true, value: 'value', writable: true }) }) it('shadows Object.prototype members with own data properties', () => { @@ -566,3 +567,161 @@ describe('setOwn', () => { expect('polluted' in {}).toBe(false) }) }) + +describe('allOfItems', () => { + it('returns allOf entries, nesting a malformed allOf instead of discarding it', () => { + const entries = [{ type: 'string' }] + expect(allOfItems(entries)).toBe(entries) + expect(allOfItems(undefined)).toEqual([]) + expect(allOfItems('junk')).toEqual([{ allOf: 'junk' }]) + }) +}) + +describe('removedPrefixes', () => { + it('lists pointer prefixes of the fields each table drops', () => { + expect(removedPrefixes({ + '': defineFields({ kept: clone, removed: DROP }), + '/nested': defineFields({ gone: DROP }), + })).toEqual(['#/removed/', '#/nested/gone/']) + }) +}) + +describe('downgrade', () => { + it('keeps references that still resolve and inlines references into removed parts', () => { + expect(convertDocument({ + items: [{ $ref: '#/named/a' }, { $ref: '#/removed/b' }], + named: { a: { value: 'a' } }, + removed: { b: { value: 'b' } }, + }).out).toEqual({ + items: [{ $ref: '#/named/a' }, { value: 'b' }], + named: { a: { value: 'a' } }, + }) + }) + + it('inlines a reference into a shifted list entry that still exists', () => { + expect(convertDocument({ + items: [{ drop: true }, { value: 'one' }, { value: 'two' }], + named: { a: { $ref: '#/items/1' } }, + }).out).toEqual({ + items: [{ value: 'one' }, { value: 'two' }], + named: { a: { value: 'one' } }, + }) + }) + + it('inlines references into dropped fields and shifted list entries', () => { + expect(convertDocument({ + items: [{ drop: true }, { secret: { value: 's' }, value: 'kept' }], + named: { a: { $ref: '#/items/1' }, b: { $ref: '#/items/1/secret' } }, + }).out).toEqual({ + items: [{ value: 'kept' }], + named: { a: { value: 'kept' }, b: { value: 's' } }, + }) + }) + + it('leaves missing, external, anchor, and malformed references as written', () => { + const items = [{ $ref: '#/missing' }, { $ref: 'other.json#/a' }, { $ref: '#anchor' }, { $ref: '#/%E0%A4%A' }] + expect(convertDocument({ items }).out).toEqual({ items }) + }) + + it('follows reference chains through removed parts', () => { + expect(convertDocument({ + items: [{ $ref: '#/removed/a' }], + removed: { a: { $ref: '#/removed/b' }, b: { value: 'b' } }, + }).out).toEqual({ items: [{ value: 'b' }] }) + }) + + it('removes references to a removed target through a chain of aliases in two passes', () => { + const named = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`a${index + 1}`, { $ref: `#/named/a${index}` }])) + const { out, passes } = convertDocument({ items: [{ $ref: '#/named/a20' }, { value: 'kept' }], named: { ...named, a0: { drop: true } } }) + expect(out).toEqual({ items: [{ value: 'kept' }], named: {} }) + expect(passes).toBe(2) + }) + + it('re-runs when a reference kept earlier in a pass turns out to target a removed alias', () => { + const { out } = convertDocument({ + links: { alias: '#/named/alias' }, + named: { alias: { $ref: '#/named/gone' }, gone: { drop: true } }, + items: [{ $ref: '#/named/alias' }], + }) + expect(out).toEqual({ links: {}, named: {}, items: [] }) + }) + + it('follows long alias chains without deep recursion', () => { + const named: Record = {} + for (let index = 5000; index > 0; index -= 1) { + named[`a${index}`] = { $ref: `#/named/a${index - 1}` } + } + named.a0 = { drop: true } + const { out, passes } = convertDocument({ items: [{ $ref: '#/named/a5000' }], named }) + expect(out).toEqual({ items: [], named: {} }) + expect(passes).toBe(2) + }) + + it('keeps an alias whose target is only cut by a cycle', () => { + const { out } = convertDocument({ + items: [{ $ref: '#/removed/target' }], + named: { alias: { $ref: '#/removed/target' } }, + removed: { target: { next: { $ref: '#/named/alias' }, value: 't' } }, + }) + expect(out).toEqual({ + items: [{ next: { $ref: '#/named/alias' }, value: 't' }], + named: { alias: { next: { $ref: '#/named/alias' }, value: 't' } }, + }) + }) + + it('keeps a loop of aliases as written', () => { + const named = { a: { $ref: '#/named/b' }, b: { $ref: '#/named/a' } } + expect(convertDocument({ items: [{ $ref: '#/named/a' }], named }).out).toEqual({ items: [{ $ref: '#/named/a' }], named }) + }) + + it('removes references to removed targets, cascading through aliases', () => { + expect(convertDocument({ + items: [{ $ref: '#/named/alias' }, { $ref: '#/named/gone' }, { value: 'kept' }], + named: { alias: { $ref: '#/named/gone' }, gone: { drop: true }, other: { $ref: '#/items/2' } }, + }).out).toEqual({ + items: [{ value: 'kept' }], + named: { other: { value: 'kept' } }, + }) + }) + + it('cuts a reference cycle at its first repeat, however the target is spelled', () => { + const removed = { a: { next: { $ref: '#/removed/b' }, value: 'a' }, b: { next: { $ref: '#/removed/%61' }, value: 'b' } } + expect(convertDocument({ items: [{ $ref: '#/removed/a' }], removed }).out).toEqual({ items: [{ next: { value: 'b' }, value: 'a' }] }) + }) + + it('converts a target inlined from several places once, however it is spelled', () => { + const { out } = convertDocument({ + items: [{ $ref: '#/removed/a' }, { $ref: '#/removed/a' }, { $ref: '#/removed/%61' }], + removed: { a: { value: 'a' } }, + }) + expect(out.items[0]).toBe(out.items[1]) + expect(out.items[0]).toBe(out.items[2]) + }) + + it('resolves references nested in inlined targets without a pass per level', () => { + const removed = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`r${index}`, { next: { $ref: `#/removed/r${index + 1}` } }])) + const { out, passes } = convertDocument({ items: [{ $ref: '#/removed/r0' }], removed: { ...removed, r20: { value: 'end' } } }) + expect(JSON.stringify(out)).toContain('"end"') + expect(JSON.stringify(out)).not.toContain('$ref') + expect(passes).toBe(2) + }) + + it('treats references under removed prefixes as dangling from the first pass', () => { + const doc = { items: [{ $ref: '#/removed/a' }, { $ref: '#/removed/missing' }], removed: { a: { value: 'a' } } } + const { out, passes } = convertDocument(doc, ['#/removed/']) + expect(out).toEqual({ items: [{ value: 'a' }, { $ref: '#/removed/missing' }] }) + expect(passes).toBe(1) + }) + + it('preserves cycles and sharing of the input graph without mutating it', () => { + const shared: Record = { value: 'shared' } + shared.next = shared + const input = { items: [shared], named: { 'a': shared, 'x-raw': { $ref: '#/removed/a' } }, removed: { a: {} } } + const before = structuredClone(input) + const { out } = convertDocument(input) + expect(out.items[0]?.next).toBe(out.items[0]) + expect(out.named.a).toBe(out.items[0]) + expect(out.named['x-raw']).toEqual({ $ref: '#/removed/a' }) + expect(input).toEqual(before) + }) +}) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index caba5e8..81767bc 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -1,23 +1,30 @@ -export const DROP = Symbol('drop') +export const DROP: unique symbol = Symbol('drop') -export type FieldConverter = (item: unknown, source: Record) => unknown +const PLACEHOLDERS = new WeakSet() -export type FieldTable = Readonly> +export interface Context { + readonly resolve: (ref: string) => unknown + readonly aliasEnd: (ref: string) => string | undefined + readonly dangles: (ref: string) => boolean + readonly isRemovedPart: (ref: string) => boolean + readonly markDangling: (ref: string) => void + readonly converting: unknown[] + readonly copies: Map + readonly inlined: Map> + readonly inlining: Set + readonly removals: Map + readonly seen: Map> +} -export const HTTP_METHODS_UP_TO_V31 = [ - 'delete', - 'get', - 'head', - 'options', - 'patch', - 'post', - 'put', - 'trace', -] as const +export type Convert = (value: unknown, ctx: Context) => unknown -export function operationFields(convert: FieldConverter): FieldTable { - return Object.fromEntries(HTTP_METHODS_UP_TO_V31.map(method => [method, convert])) -} +export type Field = (value: unknown, ctx: Context, parent: Record) => unknown + +export type Fields = ReadonlyMap + +export type Finish = (out: Record, source: Record, ctx: Context) => unknown + +export const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const export function isRecord(value: unknown): value is Record { if (typeof value !== 'object' || value === null) { @@ -27,169 +34,162 @@ export function isRecord(value: unknown): value is Record { return proto === Object.prototype || proto === null } -export function setOwn(object: object, key: PropertyKey, value: unknown): void { +export function setOwn(target: Record, key: string, value: unknown): void { if (key === '__proto__') { - Object.defineProperty(object, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) + Object.defineProperty(target, key, { configurable: true, enumerable: true, value, writable: true }) } else { - (object as Record)[key] = value + target[key] = value } } -type Finish = (out: Record, source: Record) => unknown - -interface Conversion { - cutAt: number - depth: number - done: boolean - fields: FieldTable - finish: Finish | undefined - result: unknown +export function defineFields(table: Readonly>): Fields { + return new Map(Object.entries(table)) } -const conversions = new Map() -const clones = new Map() -const active: Conversion[] = [] -let depth = 0 +export function clone(value: unknown, ctx?: Context): unknown { + return Array.isArray(value) || isRecord(value) ? copy(value, ctx?.copies ?? new Map()) : value +} -function cloneValue(value: unknown, seen: Map): unknown { +function copy(value: unknown, seen: Map): unknown { if (!(Array.isArray(value) || isRecord(value))) { return value } - const existing = seen.get(value) - if (existing !== undefined) { - return existing + const known = seen.get(value) + if (known !== undefined) { + return known } if (Array.isArray(value)) { const out: unknown[] = [] seen.set(value, out) for (const item of value) { - out.push(cloneValue(item, seen)) + out.push(copy(item, seen)) } return out } const out: Record = {} seen.set(value, out) for (const [key, item] of Object.entries(value)) { - setOwn(out, key, cloneValue(item, seen)) + setOwn(out, key, copy(item, seen)) } return out } -export function deepClone(value: T): T { - if (!(Array.isArray(value) || isRecord(value))) { - return value - } - return cloneValue(value, conversions.size > 0 ? clones : new Map()) as T -} - -export function convertRecord(value: unknown, fields: FieldTable, finish?: Finish): unknown { +export function convertObject(value: unknown, ctx: Context, fields: Fields, finish?: Finish): unknown { if (!isRecord(value)) { - return deepClone(value) + return clone(value, ctx) } - const known = conversions.get(value) - if (known !== undefined && !known.done) { - if (known.depth === depth) { - return known.result - } - for (const conversion of active) { - if (conversion.depth > known.depth) { - conversion.cutAt = Math.max(conversion.cutAt, known.depth) - } - } - return DROP + let seen = ctx.seen.get(fields) + if (seen === undefined) { + seen = new Map() + ctx.seen.set(fields, seen) + } + const known = seen.get(value) + if (known !== undefined) { + return known } - if (known !== undefined && known.fields === fields && known.finish === finish && depth > known.cutAt) { - return known.result + if (ctx.converting.includes(value)) { + return DROP } const out: Record = {} - const conversion: Conversion = { cutAt: -1, depth, done: false, fields, finish, result: out } - const outermost = conversions.size === 0 - conversions.set(value, conversion) - active.push(conversion) - try { + seen.set(value, out) + ctx.converting.push(value) + for (const [key, item] of Object.entries(value)) { + const field = fields.get(key) + const converted = field === undefined ? clone(item, ctx) : field === DROP ? DROP : field(item, ctx, value) + if (converted !== DROP) { + setOwn(out, key, converted) + } + } + const result = finish === undefined ? out : finish(out, value, ctx) + ctx.converting.pop() + if (result !== out) { + seen.set(value, result) + } + return result +} + +export function map(convert: Convert, isEntry: (key: string) => boolean = () => true): Convert { + return (value, ctx) => { + if (!isRecord(value)) { + return clone(value, ctx) + } + const out: Record = {} for (const [key, item] of Object.entries(value)) { - const convert = Object.hasOwn(fields, key) ? fields[key] : undefined - if (convert === DROP) { - continue - } - const converted = convert === undefined ? deepClone(item) : convert(item, value) + const converted = isEntry(key) ? convert(item, ctx) : clone(item, ctx) if (converted !== DROP) { setOwn(out, key, converted) } } - conversion.result = finish === undefined ? out : finish(out, value) - conversion.done = true - return conversion.result - } - finally { - active.pop() - if (outermost) { - conversions.clear() - clones.clear() - } + return out } } -export function convertInlined(convert: () => T): T { - depth += 1 - try { - return convert() - } - finally { - depth -= 1 - } +export function list(convert: Convert): Convert { + return (value, ctx) => Array.isArray(value) ? value.map(item => convert(item, ctx)).filter(item => item !== DROP) : clone(value, ctx) +} + +export function isPath(key: string): boolean { + return key.startsWith('/') +} + +export function isNotExtension(key: string): boolean { + return !key.startsWith('x-') +} + +export function hasType(type: unknown, name: string): boolean { + return type === name || (Array.isArray(type) && type.includes(name)) +} + +export function placeholder(): Record { + const out = {} + PLACEHOLDERS.add(out) + return out } -export function isConverting(value: unknown): boolean { - return isRecord(value) && conversions.get(value)?.done === false +export function allOfItems(allOf: unknown): unknown[] { + if (Array.isArray(allOf)) { + return allOf + } + return allOf === undefined ? [] : [{ allOf }] } -export function mapRecord(value: unknown, convert: (item: unknown, key: string) => unknown): unknown { +export function convertXml(value: unknown, _ctx: Context, schema: Record): unknown { if (!isRecord(value)) { - return deepClone(value) + return clone(value) } - const out: Record = {} - for (const [key, item] of Object.entries(value)) { - const converted = convert(item, key) - if (converted !== DROP) { - setOwn(out, key, converted) - } + const { nodeType, ...rest } = value + const out = clone(rest) as Record + if (nodeType === 'attribute') { + out.attribute = true + } + else if (nodeType === 'element' && hasType(schema.type, 'array')) { + out.wrapped = true } return out } -export function mapArray(value: unknown, convert: (item: unknown) => unknown): unknown { - if (!Array.isArray(value)) { - return deepClone(value) - } - return value.map(item => convert(item)).filter(item => item !== DROP) +export function getRef(value: unknown): string | undefined { + return isRecord(value) && typeof value.$ref === 'string' ? value.$ref : undefined } -export function getRef(value: unknown): string | undefined { - if (isRecord(value) && typeof value.$ref === 'string') { - return value.$ref +export function child(value: unknown, token: string): unknown { + if (Array.isArray(value)) { + return /^(?:0|[1-9]\d*)$/.test(token) ? value[Number(token)] : undefined } - return undefined + return isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined } -export function parseLocalRef(ref: string): string[] | undefined { +function parsePointer(ref: string): string[] | undefined { if (!ref.startsWith('#')) { return undefined } - let pointer = ref.slice(1) - if (pointer.includes('%')) { - try { - pointer = decodeURIComponent(pointer) - } - catch { - return undefined - } + let pointer: string + try { + pointer = decodeURIComponent(ref.slice(1)) + } + catch { + return undefined } if (pointer === '') { return [] @@ -197,17 +197,268 @@ export function parseLocalRef(ref: string): string[] | undefined { if (!pointer.startsWith('/')) { return undefined } - const tokens = pointer.slice(1).split('/') - return pointer.includes('~') ? tokens.map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) : tokens + return pointer.slice(1).split('/').map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) } -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 +export function resolve(root: unknown, ref: string): unknown { + return parsePointer(ref)?.reduce(child, root) +} + +export function inline(ref: string, ctx: Context, convert: Convert): unknown { + const target = ctx.resolve(ref) + if (target === undefined || ctx.inlining.has(target) || ctx.converting.includes(target)) { + return DROP } - return isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined + let cache = ctx.inlined.get(convert) + if (cache === undefined) { + cache = new Map() + ctx.inlined.set(convert, cache) + } + if (cache.has(target)) { + return cache.get(target) + } + ctx.inlining.add(target) + const out = convert(target, { ...ctx, seen: new Map() }) + ctx.inlining.delete(target) + cache.set(target, out) + return out } -export function resolveLocalRef(root: unknown, ref: string): unknown { - return parseLocalRef(ref)?.reduce((node, token) => getChild(node, token), root) +function convertsToDrop(ref: string, ctx: Context, convert: Convert): boolean { + if (ctx.removals.has(ref)) { + return ctx.removals.get(ref) === true + } + ctx.removals.set(ref, undefined) + const removed = inline(ref, { ...ctx, converting: [], inlined: new Map(), inlining: new Set(), seen: new Map() }, convert) === DROP + ctx.removals.set(ref, removed) + return removed +} + +function isRemovedAlias(ref: string, ctx: Context, convert: Convert): boolean { + if (getRef(ctx.resolve(ref)) === undefined) { + return false + } + const end = ctx.aliasEnd(ref) + return end !== undefined && ctx.dangles(end) && convertsToDrop(end, ctx, convert) +} + +export function skipAliases(ref: string, ctx: Context, follow: (next: string, target: Record) => boolean): string { + const hops = new Set([ref]) + let hop = ref + for (;;) { + const target = ctx.resolve(hop) + const next = getRef(target) + if (next === undefined || hops.has(next) || !follow(next, target as Record)) { + return hop + } + hops.add(next) + hop = next + } +} + +export function inlineSchema(ref: string, ctx: Context, convert: Convert): unknown { + return inline(skipAliases(ref, ctx, (next, target) => Object.keys(target).length === 1 && ctx.dangles(next)), ctx, convert) +} + +export function refOr(convert: Convert, keep: (value: Record) => unknown = clone): Convert { + const self: Convert = (value, ctx) => { + const ref = getRef(value) + if (ref === undefined) { + return convert(value, ctx) + } + if (isRemovedAlias(ref, ctx, self)) { + ctx.markDangling(ref) + return DROP + } + if (ctx.dangles(ref)) { + return inline(skipAliases(ref, ctx, next => ctx.dangles(next)), ctx, self) + } + return keep(value as Record) + } + return self +} + +function isGone(ref: string, ctx: Context): boolean { + return ctx.isRemovedPart(ref) || ctx.dangles(ref) +} + +export function convertMappingRef(value: unknown, ctx: Context): unknown { + return typeof value === 'string' && isGone(value, ctx) ? DROP : clone(value) +} + +export function hasDanglingOperationRef(link: unknown, ctx: Context): boolean { + return isRecord(link) && typeof link.operationRef === 'string' && isGone(link.operationRef, ctx) +} + +function isOperationPointer(tokens: readonly string[]): boolean { + const key = tokens.at(-1) as string + if ((HTTP_METHODS as readonly string[]).includes(key) || key === 'query') { + return isPathItemPointer(tokens.slice(0, -1)) + } + return tokens.at(-2) === 'additionalOperations' && isPathItemPointer(tokens.slice(0, -2)) +} + +function isPathItemPointer(tokens: readonly string[]): boolean { + const [first, second] = tokens + if (tokens.length === 2) { + return first === 'paths' || first === 'webhooks' + } + if (tokens.length === 3 && first === 'components' && second === 'pathItems') { + return true + } + return tokens.length > 3 + && tokens.at(-3) === 'callbacks' + && !(tokens.at(-1) as string).startsWith('x-') + && (tokens.length === 4 ? first === 'components' : isOperationPointer(tokens.slice(0, -3))) +} + +function mergeMissing(out: Record, target: unknown): void { + if (isRecord(target)) { + for (const [key, item] of Object.entries(target)) { + if (!Object.hasOwn(out, key)) { + setOwn(out, key, item) + } + } + } +} + +function followsPathItem(ref: string | undefined, ctx: Context): ref is string { + const tokens = ref === undefined ? undefined : parsePointer(ref) + return tokens !== undefined && isPathItemPointer(tokens) && ctx.dangles(ref as string) +} + +export function mergeRef(convert: Convert): Finish { + return (out, source, ctx) => { + let ref = getRef(source) + if (!followsPathItem(ref, ctx)) { + return out + } + delete out.$ref + const hops: unknown[] = [] + for (;;) { + const target = ctx.resolve(ref) + const next = getRef(target) + if (!followsPathItem(next, ctx)) { + mergeMissing(out, inline(ref, ctx, convert)) + break + } + if (!ctx.inlining.has(target) && !ctx.converting.includes(target)) { + const { $ref: _, ...own } = target as Record + ctx.inlining.add(target) + hops.push(target) + mergeMissing(out, convert(own, { ...ctx, seen: new Map() })) + } + ref = next + } + for (const hop of hops) { + ctx.inlining.delete(hop) + } + return out + } +} + +export function removedPrefixes(tables: Readonly>): string[] { + return Object.entries(tables).flatMap(([base, fields]) => + [...fields].filter(([, field]) => field === DROP).map(([key]) => `#${base}/${key}/`), + ) +} + +function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | undefined): boolean { + 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 = child(from, token) + to = child(to, token) + if (from === undefined) { + return false + } + } + return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) +} + +export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown { + const targets = new Map() + const resolveRef = (ref: string): unknown => { + if (!targets.has(ref)) { + targets.set(ref, resolve(root, ref)) + } + return targets.get(ref) + } + const ends = new Map() + const aliasEnd = (ref: string): string | undefined => { + const path = new Set() + let hop = ref + let end: string | undefined + for (;;) { + if (ends.has(hop)) { + end = ends.get(hop) + break + } + if (path.has(hop)) { + break + } + path.add(hop) + const next = getRef(resolveRef(hop)) + if (next === undefined) { + end = hop + break + } + hop = next + } + for (const visited of path) { + ends.set(visited, end) + } + return end + } + const isInlinable = (ref: string): boolean => { + const end = aliasEnd(ref) + const target = end === undefined ? undefined : resolveRef(end) + return isRecord(target) || typeof target === 'boolean' + } + const dangling = new Set() + const isRemovedPart = (ref: string): boolean => removed.some(prefix => ref.startsWith(prefix)) + let previous = root + for (;;) { + const kept = new Set() + const out = convert(root, { + aliasEnd, + converting: [], + copies: new Map(), + dangles: (ref) => { + if (!dangling.has(ref) && !kept.has(ref)) { + if ((isRemovedPart(ref) || (previous !== root && danglesIn(previous, root, parsePointer(ref)))) && isInlinable(ref)) { + dangling.add(ref) + } + else { + kept.add(ref) + } + } + return dangling.has(ref) + }, + inlined: new Map(), + inlining: new Set(), + isRemovedPart, + removals: new Map(), + markDangling: ref => dangling.add(ref), + resolve: resolveRef, + seen: new Map(), + }) + let stale = false + for (const ref of kept) { + if ((dangling.has(ref) || danglesIn(out, root, parsePointer(ref))) && isInlinable(ref)) { + dangling.add(ref) + stale = true + } + } + if (!stale) { + return out + } + previous = out + } } diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index e9be57f..0d11cce 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -361,7 +361,7 @@ describe('downgradeSpecV31ToV30', () => { parameters: [hookParameter, { in: 'query', name: 'q', schema: { enum: ['x'] } }], responses: { 200: { description: 'ok' }, - 201: { description: 'created', links: { l1: { operationId: 'newPetHook' } } }, + 201: { description: 'created', links: {} }, }, }, }, @@ -474,7 +474,7 @@ describe('downgradeSpecV31ToV30', () => { }) }) - it('applies the outermost summary and description override where the target has that field', () => { + it('ignores summary and description overrides when it inlines a reference', () => { const result = convertSpec({ components: { callbacks: { C: { $ref: '#/webhooks/newPet/x-callback', description: 'ignored' } }, @@ -492,8 +492,8 @@ describe('downgradeSpecV31ToV30', () => { }) expect(result.components).toEqual({ callbacks: { C: { '{$url}': { summary: 's' } } }, - examples: { E: { description: 'd', summary: 'outer', value: 1 } }, - parameters: { P: { ...hookParameter, description: 'outer' } }, + examples: { E: { description: 'd', summary: 's', value: 1 } }, + parameters: { P: hookParameter }, }) }) @@ -668,7 +668,7 @@ describe('downgradeSpecV31ToV30', () => { }) expect( dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema'), - ).toBe(dig(result, 'components', 'schemas', 'Tree')) + ).toEqual(dig(result, 'components', 'schemas', 'Tree')) expect(dig(result, 'paths', '/ping', 'post', 'callbacks')).toEqual({ pong: { '{$request.body#/url}': {} }, self: { '{$request.body#/url}': {} }, @@ -755,7 +755,7 @@ describe('downgradeSpecV31ToV30', () => { ]) }) - it('rewrites links into the removed parts to the operationId of an operation still in the output and removes the rest', () => { + it('removes links whose operationRef points into the removed parts, together with references to them', () => { const result = convertSpec({ components: { callbacks: { Hook: { '{$url}': { $ref: '#/webhooks/callbackHook' } } }, @@ -819,24 +819,15 @@ describe('downgradeSpecV31ToV30', () => { }) expect(result.components).toEqual({ callbacks: { Hook: { '{$url}': { post: { operationId: 'callbackHookOp', responses: {} } } } }, - links: { - ByComponentCallback: { operationId: 'callbackHookOp' }, - Kept: { description: 'kept', operationId: 'newPetHook' }, - }, + links: {}, }) expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'links')).toEqual({ - both: { operationId: 'newPetHook' }, - byCallback: { operationId: 'getItem', parameters: { id: '$response.body#/id' } }, byId: { operationId: 'orphanHook' }, byPath: { operationRef: '#/paths/~1b/post' }, external: { $ref: 'https://example.com/links.json#/Kept' }, - inlined: { operationId: 'newPetHook' }, - refKept: { $ref: '#/components/links/Kept' }, refUnknown: { $ref: '#/components/links/Unknown' }, }) - expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({ - self: { operationId: 'newPetHook' }, - }) + expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({}) expect(JSON.stringify(result)).not.toMatch(removedPointer) }) @@ -1068,7 +1059,7 @@ describe('downgradeSpecV31ToV30', () => { }) }) - it('expands an enclosing path item once before cutting the reference back into it', () => { + it('cuts a callback that reaches back into the path item that contains it', () => { const result = convertSpec({ components: { callbacks: { C: { $ref: '#/webhooks/ping/post/callbacks/self' } } }, webhooks: { @@ -1076,7 +1067,7 @@ describe('downgradeSpecV31ToV30', () => { }, }) expect(dig(result, 'components', 'callbacks', 'C')).toEqual({ - expr: { post: { callbacks: { self: { expr: {} } }, responses: {} } }, + expr: { post: { callbacks: { self: {} }, responses: {} } }, }) }) @@ -1519,11 +1510,8 @@ describe('downgradeSpecV31ToV30', () => { ping: { $ref: '#/components/securitySchemes/pong' }, pong: { $ref: '#/components/securitySchemes/ping' }, } - expect( - convertSpec({ components: { securitySchemes } }).components, - ).toEqual({ - securitySchemes, - }) + const security = [{ dangling: ['a'], external: ['b'], junk: ['c'], nested: ['d'], ping: ['e'] }] + expect(convertSpec({ components: { securitySchemes }, security })).toMatchObject({ components: { securitySchemes }, security }) }) it('empties roles on non-OAuth schemes and keeps them elsewhere', () => { @@ -1604,6 +1592,20 @@ describe('downgradeSpecV31ToV30', () => { }) }) + describe('shared objects', () => { + it('converts an object shared between an operation and a schema as each', () => { + const empty = {} + for (const fields of [ + { components: { schemas: { S: empty } }, paths: { '/a': { get: empty } } }, + { paths: { '/a': { get: empty } }, components: { schemas: { S: empty } } }, + ]) { + const result = convertSpec(fields) + expect(dig(result, 'paths', '/a', 'get')).toEqual({ responses: { default: { description: '' } } }) + expect(dig(result, 'components', 'schemas', 'S')).toEqual({}) + } + }) + }) + describe('robustness', () => { it('never mutates the input document', () => { const input: OpenAPIV3_1.OpenAPIObject = { @@ -1707,9 +1709,9 @@ describe('downgradeSchemaV31ToV30', () => { { allOf: [{ $ref: '#/c/s' }, { type: 'string' }] }, ], [ - 'keeps a malformed allOf and leaves the $ref in place', - { $ref: '#/c/s', allOf: 'junk' }, + 'nests a malformed allOf and moves the $ref into allOf', { $ref: '#/c/s', allOf: 'junk' }, + { allOf: [{ $ref: '#/c/s' }, { allOf: 'junk' }] }, ], [ 'passes a non-string $ref through unchanged', @@ -1836,14 +1838,17 @@ describe('downgradeSchemaV31ToV30', () => { }, ], [ - 'drops the type union when anyOf exists and allOf is malformed', + 'nests a malformed allOf beside the type union when anyOf exists', { allOf: 'junk', anyOf: [{ type: 'string' }], items: { type: 'integer' }, type: ['array', 'string'], }, - { allOf: 'junk', anyOf: [{ type: 'string' }], items: { type: 'integer' } }, + { + allOf: [{ allOf: 'junk' }, { anyOf: [{ items: { type: 'integer' }, type: 'array' }, { type: 'string' }] }], + anyOf: [{ type: 'string' }], + }, ], [ 'deduplicates type array entries', @@ -2200,6 +2205,75 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + describe('references into dropped keywords', () => { + it('inlines $refs into $defs, cutting recursion into {}', () => { + expect(convertSchema({ + $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, + $ref: '#/$defs/node', + })).toEqual({ allOf: [{ properties: { next: {} }, type: 'object' }] }) + expect(convertSchema({ $defs: { a: { type: 'string' } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ items: { type: 'string' }, type: 'array' }) + }) + + it('inlines a $ref to items removed beside prefixItems instead of the items placeholder', () => { + expect(convertSchema({ + properties: { + cell: { $ref: '#/properties/row/items' }, + notCell: { not: { $ref: '#/properties/row/items' } }, + row: { items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' }, + }, + })).toEqual({ + properties: { + cell: { type: 'integer' }, + notCell: { not: { type: 'integer' } }, + row: { items: {}, type: 'array' }, + }, + }) + }) + }) + + describe('never tightening what validates', () => { + it.each([ + ['drops a not whose operand lost a keyword', { not: { patternProperties: { a: {} } } }, {}], + ['drops a not whose operand is loosened deeper down', { not: { properties: { a: { if: {} } } } }, {}], + ['drops a not whose operand is a cut recursion', { $defs: { a: { not: { $ref: '#/$defs/a' } } }, $ref: '#/$defs/a' }, { allOf: [{}] }], + ['drops a not whose operand had an empty enum', { not: { enum: [] } }, {}], + ['drops a not whose null-only type has a malformed enum', { not: { enum: 'junk', type: 'null' } }, {}], + ['drops a not whose const falls outside its enum', { not: { const: 1, enum: [2] } }, {}], + ['keeps a not whose const lies inside its enum', { not: { const: 1, enum: [1, 2] } }, { not: { enum: [1] } }], + ['keeps a not whose operand converts exactly', { not: { type: ['string', 'null'] } }, { not: { nullable: true, type: 'string' } }], + ['keeps a not whose null-only operand matches nothing exactly', { not: { const: 'a', type: 'null' } }, { not: { enum: ['a'], not: {} } }], + ['drops both nots of a loosened double negation', { not: { not: { prefixItems: [] } } }, {}], + [ + 'turns a oneOf with a loosened branch into anyOf', + { oneOf: [{ prefixItems: [] }, { type: 'string' }] }, + { anyOf: [{}, { type: 'string' }] }, + ], + [ + 'nests that anyOf in allOf beside an existing anyOf', + { anyOf: [{ type: 'string' }], oneOf: [{ unevaluatedProperties: false }] }, + { allOf: [{ anyOf: [{}] }], anyOf: [{ type: 'string' }] }, + ], + [ + 'propagates loosening through items, additionalProperties, allOf, and anyOf', + { not: { allOf: [{ anyOf: [{ additionalProperties: { items: { contains: {} } } }] }] } }, + {}, + ], + ])('%s', (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected) + }) + + it('treats a cycle of the input graph as loosened under not and oneOf', () => { + const negated: any = { not: { properties: {} }, patternProperties: { '^x': { type: 'string' } } } + negated.not.properties.p = negated + expect(convertSchema(negated)).toEqual({}) + const tree: any = { oneOf: [{ required: ['value'], type: 'object' }], unevaluatedProperties: false } + tree.oneOf.push({ properties: { children: { items: tree, type: 'array' } }, type: 'object' }) + const out = convertSchema(tree) as any + expect(out.oneOf).toBeUndefined() + expect(out.anyOf[1].properties.children.items).toBe(out) + }) + }) + describe('xml nodeType carried over from 3.2', () => { it.each([ [ diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3990b37..013ca53 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -1,236 +1,293 @@ import type * as OpenAPIV3_0 from '@openapi-spec/types/v3.0' import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' -import type { FieldConverter, FieldTable } from './shared' +import type { Context } from './shared' import { - convertInlined, - convertRecord, - deepClone, + allOfItems, + child, + clone, + convertMappingRef, + convertObject, + convertXml, + defineFields, + downgrade, DROP, getRef, - HTTP_METHODS_UP_TO_V31, - isConverting, + hasDanglingOperationRef, + hasType, + HTTP_METHODS, + inlineSchema, + isNotExtension, + isPath, isRecord, - mapArray, - mapRecord, - operationFields, - parseLocalRef, - resolveLocalRef, + list, + map, + mergeRef, + placeholder, + refOr, + removedPrefixes, + setOwn, } from './shared' -const HTTP_METHODS = new Set(HTTP_METHODS_UP_TO_V31) -const DESCRIPTION = ['description'] -const SUMMARY_AND_DESCRIPTION = ['summary', 'description'] - -interface Context { - convertSchema: (value: unknown) => unknown - document: Record | undefined - inlined: Map> - inlining: Set - linkChecks: [links: Record, name: string, operationId: string | typeof DROP][] - schemeTypes: ReadonlyMap -} - -type Convert = (item: unknown, context: Context) => unknown - -interface Chain { - fields: Record - target: unknown -} - -function parseRemovedRef(ref: string, context: Context): string[] | undefined { - if (context.document === undefined || !(ref.startsWith('#/webhooks') || ref.startsWith('#/components/pathItems') || ref.includes('%'))) { - return undefined - } - const tokens = parseLocalRef(ref) - const removed = tokens?.[0] === 'webhooks' || (tokens?.[0] === 'components' && tokens[1] === 'pathItems') - return removed ? tokens : undefined -} - -function isPureRef(value: unknown): value is { $ref: string } { - return isRecord(value) && typeof value.$ref === 'string' && Object.keys(value).length === 1 -} - -function isPathItemLocation(tokens: readonly string[]): boolean { - if (tokens.length === (tokens[0] === 'webhooks' ? 2 : 3)) { - return true - } - const [method = '', callbacks, , expression = 'x-'] = tokens.slice(-4) - return callbacks === 'callbacks' - && HTTP_METHODS.has(method) - && !expression.startsWith('x-') - && isPathItemLocation(tokens.slice(0, -4)) -} - -function followRefs(value: Record, context: Context, kind: 'pathItem' | 'reference' | 'schema'): Chain | undefined { - const seen = new Set() - let fields: Record = {} - let target: unknown = value - while (isRecord(target) && typeof target.$ref === 'string') { - const tokens = parseRemovedRef(target.$ref, context) - if (tokens === undefined || (kind === 'schema' && !isPureRef(target))) { - break +const LOOSENING_KEYWORDS = new Set([ + '$dynamicRef', + 'contains', + 'dependentRequired', + 'dependentSchemas', + 'else', + 'if', + 'maxContains', + 'minContains', + 'patternProperties', + 'prefixItems', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', +]) + +const ANNOTATION_KEYWORDS = [ + '$anchor', + '$comment', + '$defs', + '$dynamicAnchor', + '$id', + '$schema', + '$vocabulary', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'examples', +] + +const LOOSE = new WeakSet() + +const convertCallback = map(convertPathItem, isNotExtension) +const convertContent = map(convertMediaType) +const convertRequirements = list(convertRequirement) +const finishPathItem = mergeRef(convertPathItem) +const convertCallbackRef = refOr(convertCallback, reference) +const convertExampleRef = refOr(clone, reference) +const convertLinkRef = refOr(convertLink, reference) +const convertParameterRef = refOr(convertParameter, reference) +const convertRequestBodyRef = refOr(convertRequestBody, reference) +const convertResponseRef = refOr(convertResponse, reference) +const convertSecuritySchemeRef = refOr(convertSecurityScheme, reference) + +const DISCRIMINATOR_FIELDS = defineFields({ + mapping: map(convertMappingRef), +}) + +const SCHEMA_FIELDS = defineFields({ + ...Object.fromEntries([...LOOSENING_KEYWORDS, ...ANNOTATION_KEYWORDS].map(key => [key, DROP])), + $ref: item => (typeof item === 'string' ? DROP : clone(item)), + additionalProperties: (item, ctx, schema) => { + if ('patternProperties' in schema) { + return DROP } - if (seen.has(target) || (kind === 'pathItem' && !isPathItemLocation(tokens))) { - return undefined + return typeof item === 'boolean' ? item : convertSchema(item, ctx) + }, + allOf: list(convertSchema), + anyOf: list(convertSchema), + const: DROP, + discriminator: (item, ctx) => convertObject(item, ctx, DISCRIMINATOR_FIELDS), + enum: item => (Array.isArray(item) && item.length === 0 ? DROP : clone(item)), + exclusiveMaximum: item => (typeof item === 'number' ? DROP : clone(item)), + exclusiveMinimum: item => (typeof item === 'number' ? DROP : clone(item)), + items: (item, ctx, schema) => ('prefixItems' in schema ? DROP : convertSchema(item, ctx)), + not: convertSchema, + oneOf: list(convertSchema), + properties: map(convertSchema), + required: (item) => { + if (!Array.isArray(item)) { + return clone(item) } - seen.add(target) - const { $ref: ref, ...own } = target - fields = { ...own, ...fields } - target = resolveLocalRef(context.document, ref) - } - return target === value || target === undefined ? undefined : { fields, target } + return item.length === 0 ? DROP : clone([...new Set(item)]) + }, + type: DROP, + xml: convertXml, +}) + +const PARAMETER_FIELDS = defineFields({ + content: convertContent, + examples: map(convertExampleRef), + schema: convertSchema, +}) + +const MEDIA_TYPE_FIELDS = defineFields({ + encoding: map(convertEncoding), + examples: map(convertExampleRef), + schema: convertSchema, +}) + +const ENCODING_FIELDS = defineFields({ + headers: map(convertParameterRef), +}) + +const REQUEST_BODY_FIELDS = defineFields({ + content: convertContent, +}) + +const RESPONSE_FIELDS = defineFields({ + content: convertContent, + headers: map(convertParameterRef), + links: map(convertLinkRef), +}) + +const OPERATION_FIELDS = defineFields({ + callbacks: map(convertCallbackRef), + parameters: list(convertParameterRef), + requestBody: convertRequestBodyRef, + responses: map(convertResponseRef, isNotExtension), + security: convertSecurity, +}) + +const PATH_ITEM_FIELDS = defineFields({ + ...Object.fromEntries(HTTP_METHODS.map(method => [method, convertOperation])), + parameters: list(convertParameterRef), +}) + +const COMPONENTS_FIELDS = defineFields({ + callbacks: map(convertCallbackRef), + examples: map(convertExampleRef), + headers: map(convertParameterRef), + links: map(convertLinkRef), + parameters: map(convertParameterRef), + pathItems: DROP, + requestBodies: map(convertRequestBodyRef), + responses: map(convertResponseRef), + schemas: map(convertSchema), + securitySchemes: map(convertSecuritySchemeRef), +}) + +const LICENSE_FIELDS = defineFields({ + identifier: DROP, +}) + +const INFO_FIELDS = defineFields({ + license: (item, ctx) => convertObject(item, ctx, LICENSE_FIELDS), + summary: DROP, +}) + +const DOCUMENT_FIELDS = defineFields({ + components: (item, ctx) => convertObject(item, ctx, COMPONENTS_FIELDS), + info: (item, ctx) => convertObject(item, ctx, INFO_FIELDS), + jsonSchemaDialect: DROP, + paths: map(convertPathItem, isPath), + security: convertSecurity, + webhooks: DROP, +}) + +const REMOVED = removedPrefixes({ '': DOCUMENT_FIELDS, '/components': COMPONENTS_FIELDS }) + +function reference(value: Record): unknown { + return { $ref: value.$ref } +} + +function isLoose(value: unknown): boolean { + return LOOSE.has(value as object) +} + +function hasLoose(value: unknown): boolean { + return typeof value === 'object' && value !== null && Object.values(value).some(isLoose) +} + +function loosened(out: object): object { + LOOSE.add(out) + return out } -function resolveRefChain(value: unknown, document: unknown): unknown { - const seen = new Set() - let target = value - while (isRecord(target) && typeof target.$ref === 'string' && !seen.has(target)) { - seen.add(target) - target = resolveLocalRef(document, target.$ref) - } - return target +function isLooseSchema(out: Record, schema: Record): boolean { + return Object.keys(schema).some(key => LOOSENING_KEYWORDS.has(key)) + || (Array.isArray(schema.enum) && schema.enum.length === 0) + || isLoose(out.items) + || isLoose(out.additionalProperties) + || hasLoose(out.properties) + || hasLoose(out.allOf) + || hasLoose(out.anyOf) } -function inline(target: unknown, context: Context, convert: Convert): unknown { - const cache = context.inlined.get(convert) ?? new Map() - context.inlined.set(convert, cache) - if (cache.has(target)) { - return cache.get(target) - } - if (isConverting(target) || context.inlining.has(target)) { - return DROP - } - context.inlining.add(target) - try { - const out = convertInlined(() => convert(target, context)) - cache.set(target, out) - return out +function addAnyOf(out: Record, variants: unknown): void { + if (out.anyOf === undefined) { + out.anyOf = variants } - finally { - context.inlining.delete(target) + else { + out.allOf = [...allOfItems(out.allOf), { anyOf: variants }] } } -function pickFields(fields: Record, keys: readonly string[]): Record { - return Object.fromEntries(keys.filter(key => Object.hasOwn(fields, key)).map(key => [key, deepClone(fields[key])])) -} - -function convertRefOr(value: unknown, context: Context, convert: Convert, overrides: readonly string[] = DESCRIPTION): unknown { - if (!isRecord(value) || typeof value.$ref !== 'string') { - return convert(value, context) - } - const chain = followRefs(value, context, 'reference') - if (chain === undefined || !isRecord(chain.target)) { - return { $ref: value.$ref } - } - const ref = getRef(chain.target) - if (ref !== undefined) { +function convertSchemaRef(ref: string, ctx: Context): unknown { + if (!ctx.dangles(ref)) { return { $ref: ref } } - const out = inline(chain.target, context, convert) - const own = pickFields(chain.fields, overrides) - return out === DROP || Object.keys(own).length === 0 ? out : { ...out as Record, ...own } -} - -function refMap(context: Context, convert: Convert, overrides?: readonly string[]): FieldConverter { - return item => mapRecord(item, entry => convertRefOr(entry, context, convert, overrides)) -} - -function refList(context: Context, convert: Convert): FieldConverter { - return item => mapArray(item, entry => convertRefOr(entry, context, convert)) + const out = inlineSchema(ref, ctx, convertSchema) + return out === DROP ? loosened({}) : out } -function applyTypes(types: string[], schema: Record, out: Record): void { +function convertType(out: Record, type: unknown): boolean { + if (typeof type === 'string' && type !== 'null') { + out.type = type + return false + } + const types = (Array.isArray(type) ? type : [type]).filter(item => typeof item === 'string') + if (types.length === 0) { + if (type !== undefined && !(Array.isArray(type) && type.length === 0)) { + out.type = clone(type) + } + return false + } const nullable = types.includes('null') - const rest = types.filter(item => item !== 'null') + const rest = [...new Set(types.filter(item => item !== 'null'))] if (rest.length === 1) { out.type = rest[0] if (nullable) { out.nullable = true } - return } - if (rest.length === 0) { - if (!nullable) { - return - } - if ('const' in schema) { - if (schema.const !== null) { - out.not = {} - } - } - else if (Array.isArray(schema.enum)) { - if (schema.enum.includes(null)) { - out.enum = [null] - } - else { - out.not = {} - } - } - else if (!('enum' in schema)) { - out.enum = [null] + else if (rest.length > 1) { + addAnyOf(out, rest.map(item => ({ + type: item, + ...(item === 'array' && { items: out.items ?? {} }), + ...(nullable && { nullable: true }), + }))) + if (rest.includes('array')) { + delete out.items } - return } - if (out.anyOf !== undefined && out.allOf !== undefined && !Array.isArray(out.allOf)) { - return + else if (out.enum === undefined) { + out.enum = [null] } - const variants = rest.map((item) => { - const variant: Record = { type: item } - if (item === 'array') { - variant.items = out.items === undefined ? {} : out.items - delete out.items - } - if (nullable) { - variant.nullable = true - } - return variant - }) - if (out.anyOf === undefined) { - out.anyOf = variants + else if (!Array.isArray(out.enum)) { + return true + } + else if (out.enum.includes(null)) { + out.enum = [null] } else { - out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), { anyOf: variants }] + out.not = {} } + return false } -function hasType(type: unknown, name: string): boolean { - return type === name || (Array.isArray(type) && type.includes(name)) -} - -function convertType(schema: Record, out: Record): void { - const { type } = schema - if (type === undefined) { - return +function finishSchema(out: Record, schema: Record, ctx: Context): unknown { + if (typeof schema.$ref === 'string') { + out.allOf = [convertSchemaRef(schema.$ref, ctx), ...allOfItems(out.allOf)] } - if (typeof type === 'string') { - applyTypes([type], schema, out) - return + let loose = isLooseSchema(out, schema) + if (isLoose(out.not)) { + delete out.not + loose = true } - if (Array.isArray(type)) { - const types = [...new Set(type.filter(item => typeof item === 'string'))] - if (types.length > 0 || type.length === 0) { - applyTypes(types, schema, out) - return - } + if (hasLoose(out.oneOf)) { + addAnyOf(out, out.oneOf) + delete out.oneOf + loose = true } - out.type = deepClone(type) -} - -function convertConst(schema: Record, out: Record): void { if ('const' in schema) { - out.enum = [deepClone(schema.const)] - } -} - -function convertExamples(schema: Record, out: Record): void { - if (Array.isArray(schema.examples) && schema.examples.length > 0 && !('example' in schema)) { - out.example = deepClone(schema.examples[0]) + loose ||= 'enum' in schema && !(Array.isArray(schema.enum) && schema.enum.includes(schema.const)) + out.enum = [clone(schema.const)] } -} - -function convertExclusiveBounds(schema: Record, out: Record): void { + loose = convertType(out, schema.type) || loose const { exclusiveMaximum, exclusiveMinimum, maximum, minimum } = schema if (typeof exclusiveMinimum === 'number' && !(typeof minimum === 'number' && minimum > exclusiveMinimum)) { out.minimum = exclusiveMinimum @@ -240,418 +297,132 @@ function convertExclusiveBounds(schema: Record, out: Record): string | undefined { - if (schema.contentEncoding === 'base64') { - return 'byte' - } - if (schema.contentEncoding === undefined && typeof schema.contentMediaType === 'string') { - return 'binary' - } - return undefined -} - -function convertContentKeywords(schema: Record, out: Record): void { - const format = getContentFormat(schema) - const { type } = schema - if (format === undefined || (type !== undefined && !hasType(type, 'string'))) { - return - } - if (type === undefined) { - out.type = 'string' - } - if (out.format === undefined) { - out.format = format - } -} - -function convertXml(value: unknown, schemaType: unknown): unknown { - return convertRecord(value, { nodeType: DROP }, (out, xml) => { - if (xml.nodeType === 'attribute') { - out.attribute = true - } - else if (xml.nodeType === 'element' && hasType(schemaType, 'array')) { - out.wrapped = true - } - return out - }) -} - -function finishSchema(out: Record, schema: Record, context: Context): Record { - convertType(schema, out) - convertConst(schema, out) - convertExamples(schema, out) - convertExclusiveBounds(schema, out) - convertContentKeywords(schema, out) - if (out.type === 'array' && out.items === undefined) { - out.items = {} - } - if (typeof schema.$ref === 'string') { - if (out.allOf === undefined || Array.isArray(out.allOf)) { - out.allOf = [convertSchemaRef({ $ref: schema.$ref }, context), ...(Array.isArray(out.allOf) ? out.allOf : [])] - } - else { - out.$ref = schema.$ref + if (Array.isArray(schema.examples) && schema.examples.length > 0 && !('example' in schema)) { + out.example = clone(schema.examples[0]) + } + const format = schema.contentEncoding === 'base64' + ? 'byte' + : schema.contentEncoding === undefined && typeof schema.contentMediaType === 'string' ? 'binary' : undefined + if (format !== undefined && (schema.type === undefined || hasType(schema.type, 'string'))) { + out.format ??= format + if (schema.type === undefined) { + out.type = 'string' } } - return out -} - -function createSchemaFields(context: Context): FieldTable { - const convert = context.convertSchema - const convertSubschemas = (item: unknown): unknown => mapArray(item, convert) - return { - $anchor: DROP, - $comment: DROP, - $defs: DROP, - $dynamicAnchor: DROP, - $dynamicRef: DROP, - $id: DROP, - $ref: item => (typeof item === 'string' ? DROP : deepClone(item)), - $schema: DROP, - $vocabulary: DROP, - additionalProperties: (item, schema) => { - if ('patternProperties' in schema) { - return DROP - } - return typeof item === 'boolean' ? item : convert(item) - }, - allOf: convertSubschemas, - anyOf: convertSubschemas, - const: DROP, - contains: DROP, - contentEncoding: DROP, - contentMediaType: DROP, - contentSchema: DROP, - dependentRequired: DROP, - dependentSchemas: DROP, - discriminator: item => convertRecord(item, { - mapping: mapping => mapRecord(mapping, value => (typeof value === 'string' && parseRemovedRef(value, context) !== undefined ? DROP : deepClone(value))), - }), - else: DROP, - enum: item => (Array.isArray(item) && item.length === 0 ? DROP : deepClone(item)), - examples: DROP, - exclusiveMaximum: item => (typeof item === 'number' ? DROP : deepClone(item)), - exclusiveMinimum: item => (typeof item === 'number' ? DROP : deepClone(item)), - if: DROP, - items: (item, schema) => ('prefixItems' in schema ? DROP : convert(item)), - maxContains: DROP, - minContains: DROP, - not: convert, - oneOf: convertSubschemas, - patternProperties: DROP, - prefixItems: DROP, - properties: item => mapRecord(item, convert), - propertyNames: DROP, - required: (item) => { - if (!Array.isArray(item)) { - return deepClone(item) - } - return item.length === 0 ? DROP : deepClone([...new Set(item)]) - }, - then: DROP, - type: DROP, - unevaluatedItems: DROP, - unevaluatedProperties: DROP, - xml: (item, schema) => convertXml(item, schema.type), + if (out.type === 'array' && out.items === undefined) { + out.items = placeholder() } + return loose ? loosened(out) : out } -function convertSchemaRef(value: { $ref: string }, context: Context): unknown { - const target = followRefs(value, context, 'schema')?.target - if (isPureRef(target)) { - return { $ref: target.$ref } +function convertSchema(value: unknown, ctx: Context): unknown { + if (typeof value === 'boolean') { + return value ? {} : { not: {} } } - if (!isRecord(target) && typeof target !== 'boolean') { - return { $ref: value.$ref } + const ref = getRef(value) + if (ref !== undefined && Object.keys(value as object).length === 1) { + return convertSchemaRef(ref, ctx) } - const out = inline(target, context, context.convertSchema) - return out === DROP ? {} : out -} - -const STANDALONE_CONTEXT = createContext(undefined) - -export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return STANDALONE_CONTEXT.convertSchema(schema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject + const cyclic = ctx.converting.includes(value) + const out = convertObject(value, ctx, SCHEMA_FIELDS, finishSchema) + return cyclic ? loosened(out === DROP ? {} : out as object) : out } -function createContext(spec: unknown): Context { - const document = isRecord(spec) ? spec : undefined - const components = document?.components - const schemes = isRecord(components) ? components.securitySchemes : undefined - const schemeTypes = new Map() - if (isRecord(schemes)) { - for (const [name, scheme] of Object.entries(schemes)) { - const target = resolveRefChain(scheme, document) - if (isRecord(target) && typeof target.type === 'string') { - schemeTypes.set(name, target.type) - } - } +function finishParameter(out: Record, parameter: Record): unknown { + if (parameter.in === 'path') { + out.required = true } - const context: Context = { - convertSchema, - document, - inlined: new Map(), - inlining: new Set(), - linkChecks: [], - schemeTypes, - } - const fields = createSchemaFields(context) - const finish = (out: Record, schema: Record): unknown => finishSchema(out, schema, context) - function convertSchema(value: unknown): unknown { - if (value === true) { - return {} - } - if (value === false) { - return { not: {} } - } - if (isPureRef(value)) { - return convertSchemaRef(value, context) - } - const out = convertRecord(value, fields, finish) - return out === DROP ? {} : out - } - return context + return out } -function isMutualTls(name: string, context: Context): boolean { - return context.schemeTypes.get(name) === 'mutualTLS' +function convertParameter(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, PARAMETER_FIELDS, finishParameter) } -function convertRequirement(value: unknown, context: Context): unknown { - if (!isRecord(value)) { - return deepClone(value) - } - const entries = Object.entries(value) - const kept = entries.filter(([name]) => !isMutualTls(name, context)) - if (kept.length === 0 && entries.length > 0) { - return DROP - } - return Object.fromEntries(kept.map(([name, scopes]) => { - const type = context.schemeTypes.get(name) - const scoped = type === undefined || type === 'oauth2' || type === 'openIdConnect' - return [name, Array.isArray(scopes) && !scoped ? [] : deepClone(scopes)] - })) +function convertMediaType(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, MEDIA_TYPE_FIELDS) } -function convertSecurity(value: unknown, context: Context): unknown { - if (!Array.isArray(value)) { - return deepClone(value) - } - const out = value.map(item => convertRequirement(item, context)).filter(item => item !== DROP) - return value.length > 0 && out.length === 0 ? DROP : out +function convertEncoding(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, ENCODING_FIELDS) } -function convertInfo(value: unknown): unknown { - return convertRecord(value, { - license: item => convertRecord(item, { identifier: DROP }), - summary: DROP, - }) +function convertRequestBody(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, REQUEST_BODY_FIELDS) } -function convertParameterOrHeader(value: unknown, context: Context): unknown { - return convertRecord( - value, - { - content: item => convertContent(item, context), - examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), - schema: context.convertSchema, - }, - (out, parameter) => { - if (parameter.in === 'path') { - out.required = true - } - return out - }, - ) +function convertResponse(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, RESPONSE_FIELDS) } -function convertEncoding(value: unknown, context: Context): unknown { - return convertRecord(value, { headers: refMap(context, convertParameterOrHeader) }) +function convertLink(value: unknown, ctx: Context): unknown { + return hasDanglingOperationRef(value, ctx) ? DROP : clone(value) } -function convertMediaType(value: unknown, context: Context): unknown { - return convertRecord(value, { - encoding: item => mapRecord(item, entry => convertEncoding(entry, context)), - examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), - schema: context.convertSchema, - }) +function finishOperation(out: Record): unknown { + out.responses ??= { default: { description: '' } } + return out } -function convertContent(item: unknown, context: Context): unknown { - return mapRecord(item, entry => convertMediaType(entry, context)) +function convertOperation(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, OPERATION_FIELDS, finishOperation) } -function convertRequestBody(value: unknown, context: Context): unknown { - return convertRecord(value, { content: item => convertContent(item, context) }) +function convertPathItem(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, PATH_ITEM_FIELDS, finishPathItem) } -function linkedOperationId(link: unknown, context: Context): string | typeof DROP | undefined { - const target = resolveRefChain(link, context.document) - const operationRef = isRecord(target) ? target.operationRef : undefined - if (typeof operationRef !== 'string' || parseRemovedRef(operationRef, context) === undefined) { - return undefined +function schemeType(name: string, ctx: Context): unknown { + let scheme = child(ctx.resolve('#/components/securitySchemes'), name) + const ref = getRef(scheme) + if (ref !== undefined) { + const end = ctx.aliasEnd(ref) + scheme = end === undefined ? undefined : ctx.resolve(end) } - const operation = resolveLocalRef(context.document, operationRef) - return isRecord(operation) && typeof operation.operationId === 'string' ? operation.operationId : DROP + return isRecord(scheme) ? scheme.type : undefined } -function convertLink(value: unknown, context: Context): unknown { - const operationId = linkedOperationId(value, context) - if (typeof operationId !== 'string') { - return deepClone(value) - } - return convertRecord(value, { operationRef: DROP }, (out) => { - out.operationId = operationId - return out - }) +function convertSecurityScheme(value: unknown): unknown { + return isRecord(value) && value.type === 'mutualTLS' ? DROP : clone(value) } -function convertLinks(value: unknown, context: Context): unknown { - const out = mapRecord(value, item => convertRefOr(item, context, convertLink)) - if (isRecord(value)) { - for (const [name, item] of Object.entries(value)) { - const operationId = linkedOperationId(item, context) - if (operationId !== undefined) { - context.linkChecks.push([out as Record, name, operationId]) - } +function convertRequirement(value: unknown, ctx: Context): unknown { + if (!isRecord(value)) { + return clone(value) + } + const out: Record = {} + let removed = false + for (const [name, scopes] of Object.entries(value)) { + const type = schemeType(name, ctx) + if (type === 'mutualTLS') { + removed = true + } + else { + setOwn(out, name, Array.isArray(scopes) && (type === 'apiKey' || type === 'http') ? [] : clone(scopes)) } } - return out -} - -function convertResponse(value: unknown, context: Context): unknown { - return convertRecord(value, { - content: item => convertContent(item, context), - headers: refMap(context, convertParameterOrHeader), - links: item => convertLinks(item, context), - }) -} - -function convertResponses(item: unknown, context: Context): unknown { - return mapRecord(item, (entry, key) => key.startsWith('x-') ? deepClone(entry) : convertRefOr(entry, context, convertResponse)) -} - -function convertOperation(value: unknown, context: Context): unknown { - return convertRecord( - value, - { - callbacks: refMap(context, convertCallback, []), - parameters: refList(context, convertParameterOrHeader), - requestBody: item => convertRefOr(item, context, convertRequestBody), - responses: item => convertResponses(item, context), - security: item => convertSecurity(item, context), - }, - (out) => { - if (out.responses === undefined) { - out.responses = { default: { description: '' } } - } - return out - }, - ) -} - -function convertCallback(value: unknown, context: Context): unknown { - return mapRecord(value, (item, key) => key.startsWith('x-') ? deepClone(item) : convertPathItem(item, context)) -} - -function convertPathItemFields(value: unknown, context: Context): unknown { - return convertRecord(value, { - ...operationFields(item => convertOperation(item, context)), - parameters: refList(context, convertParameterOrHeader), - }) + return removed && Object.keys(out).length === 0 ? DROP : out } -function convertPathItem(value: unknown, context: Context): unknown { - const chain = isRecord(value) ? followRefs(value, context, 'pathItem') : undefined - if (!isRecord(value) || chain === undefined || !isRecord(chain.target)) { - return convertPathItemFields(value, context) - } - const out = inline(chain.target, context, convertPathItem) - if (Object.keys(chain.fields).length === 0) { - return out === DROP ? {} : out - } - const { $ref: _, ...own } = value - const inherited = Object.fromEntries(Object.entries(chain.fields).filter(([key]) => !Object.hasOwn(own, key))) - return { - ...(out === DROP ? {} : out) as Record, - ...convertInlined(() => convertPathItemFields(inherited, context)) as Record, - ...convertPathItemFields(own, context) as Record, - } +function convertSecurity(value: unknown, ctx: Context): unknown { + const out = convertRequirements(value, ctx) + return Array.isArray(value) && value.length > 0 && (out as unknown[]).length === 0 ? DROP : out } -function convertPaths(value: unknown, context: Context): unknown { - return mapRecord(value, (item, key) => key.startsWith('/') ? convertPathItem(item, context) : deepClone(item)) +function finishDocument(out: Record): unknown { + out.openapi = '3.0.4' + out.paths ??= {} + return out } -function convertComponents(value: unknown, context: Context): unknown { - return convertRecord(value, { - callbacks: refMap(context, convertCallback, []), - examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), - headers: refMap(context, convertParameterOrHeader), - links: item => convertLinks(item, context), - parameters: refMap(context, convertParameterOrHeader), - pathItems: DROP, - requestBodies: refMap(context, convertRequestBody), - responses: refMap(context, convertResponse), - schemas: item => mapRecord(item, context.convertSchema), - securitySchemes: item => mapRecord(item, (scheme, name) => isMutualTls(name, context) ? DROP : convertRefOr(scheme, context, deepClone)), - }) +function convertDocument(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, DOCUMENT_FIELDS, finishDocument) } -function collectOperationIds(pathItems: unknown, ids: Set, seen: WeakSet): void { - for (const [key, pathItem] of isRecord(pathItems) ? Object.entries(pathItems) : []) { - if (key.startsWith('x-') || !isRecord(pathItem) || seen.has(pathItem)) { - continue - } - seen.add(pathItem) - for (const method of HTTP_METHODS_UP_TO_V31) { - const operation = pathItem[method] - if (isRecord(operation) && typeof operation.operationId === 'string') { - ids.add(operation.operationId) - } - for (const callback of isRecord(operation) && isRecord(operation.callbacks) ? Object.values(operation.callbacks) : []) { - collectOperationIds(callback, ids, seen) - } - } - } +export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject { + return downgrade(spec, convertDocument, REMOVED) as OpenAPIV3_0.OpenAPIObject } -export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject { - const context = createContext(spec) - const converted = convertRecord( - spec, - { - components: item => convertComponents(item, context), - info: convertInfo, - jsonSchemaDialect: DROP, - paths: item => convertPaths(item, context), - security: item => convertSecurity(item, context), - webhooks: DROP, - }, - (out) => { - out.openapi = '3.0.4' - if (out.paths === undefined) { - out.paths = {} - } - return out - }, - ) - if (context.linkChecks.length > 0) { - const { components, paths } = converted as Record - const operationIds = new Set() - const seen = new WeakSet() - collectOperationIds(paths, operationIds, seen) - const callbacks = isRecord(components) ? components.callbacks : undefined - for (const callback of isRecord(callbacks) ? Object.values(callbacks) : []) { - collectOperationIds(callback, operationIds, seen) - } - for (const [links, name, operationId] of context.linkChecks) { - if (operationId === DROP || !operationIds.has(operationId)) { - delete links[name] - } - } - } - return converted as OpenAPIV3_0.OpenAPIObject +export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { + return downgrade(schema, convertSchema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } 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 46f7013..12cdafc 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -377,7 +377,7 @@ describe('downgradeSpecV32ToV31', () => { { allowReserved: true, schema: {} }, ], [ - 'keeps parameter schemas verbatim and converts example maps', + 'maps parameter schema xml nodeType and converts example maps', { examples: { inline: { dataValue: 1 }, @@ -394,7 +394,7 @@ describe('downgradeSpecV32ToV31', () => { }, in: 'query', name: 'q', - schema: { type: 'string', xml: { nodeType: 'attribute' } }, + schema: { type: 'string', xml: { attribute: true } }, }, ], ])('%s', (_name, input, expected) => { @@ -416,6 +416,39 @@ describe('downgradeSpecV32ToV31', () => { }) }) + describe('parameters and headers with content', () => { + it('removes parameter and header examples beside content', () => { + const content = { 'a/b': { schema: { type: 'object' } } } + const operation = dig(convertSpec({ + paths: { + '/a': { + get: { + parameters: [ + { content, example: { a: 1 }, in: 'query', name: 'moved' }, + { content: { 'a/b': { example: 'own' } }, examples: { e: { dataValue: 1 } }, in: 'query', name: 'kept' }, + { content: { 'a/b': {}, 'c/d': {} }, example: 1, in: 'query', name: 'many' }, + { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, + ], + responses: { 200: { description: 'ok', headers: { X: { content, examples: { e: { dataValue: 2 } } } } } }, + }, + }, + }, + }), 'paths', '/a', 'get') + expect(dig(operation, 'parameters')).toEqual([ + { content, in: 'query', name: 'moved' }, + { content: { 'a/b': { example: 'own' } }, in: 'query', name: 'kept' }, + { content: { 'a/b': {}, 'c/d': {} }, in: 'query', name: 'many' }, + { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, + ]) + expect(dig(operation, 'responses', '200', 'headers', 'X')).toEqual({ content }) + }) + + it('keeps a parameter whose content was already empty', () => { + const parameter = { content: {}, in: 'query', name: 'q' } + expect(dig(convertSpec({ paths: { '/a': { get: { parameters: [parameter] } } } }), 'paths', '/a', 'get', 'parameters')).toEqual([parameter]) + }) + }) + describe('components.mediaTypes inlining', () => { it('inlines a media type reference with the converted media type', () => { expect( @@ -767,7 +800,7 @@ describe('downgradeSpecV32ToV31', () => { expect(result).toEqual({ 'application/jsonl': { schema: { - items: { type: 'object', xml: { nodeType: 'text' } }, + items: { type: 'object', xml: {} }, type: 'array', }, }, @@ -1215,12 +1248,15 @@ describe('downgradeSpecV32ToV31', () => { }) }) - it('clones components.schemas entries unchanged, keeping 3.2 OAS vocabulary fields', () => { + it('maps xml nodeType and removes discriminator defaultMapping in components.schemas entries', () => { const schema = { discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, xml: { nodeType: 'attribute' }, } - expect(convertComponent('schemas', schema)).toEqual(schema) + expect(convertComponent('schemas', schema)).toEqual({ + discriminator: { propertyName: 'kind' }, + xml: { attribute: true }, + }) }) it('clones unknown component keys and passes non-object components through', () => { @@ -1236,6 +1272,7 @@ describe('downgradeSpecV32ToV31', () => { describe('references into removed parts', () => { const petRef = { $ref: '#/components/mediaTypes/Pet/schema' } const pet = { type: 'object', xml: { nodeType: 'element' } } + const convertedPet = { type: 'object', xml: {} } it('inlines schema $refs at every subschema position', () => { const everyPosition = (schema: unknown) => ({ @@ -1263,7 +1300,7 @@ describe('downgradeSpecV32ToV31', () => { convertComponent('schemas', everyPosition(petRef), { mediaTypes: { Pet: { schema: pet } }, }), - ).toEqual(everyPosition(pet)) + ).toEqual(everyPosition(convertedPet)) }) it('inlines schema $refs in parameter, header, media type, and itemSchema positions', () => { @@ -1284,13 +1321,13 @@ describe('downgradeSpecV32ToV31', () => { }, }).components, ).toEqual({ - headers: { H: { schema: pet } }, - parameters: { P: { in: 'query', name: 'p', schema: pet } }, + headers: { H: { schema: convertedPet } }, + parameters: { P: { in: 'query', name: 'p', schema: convertedPet } }, requestBodies: { B: { content: { - 'application/json': { schema: pet }, - 'application/jsonl': { schema: { items: pet, type: 'array' } }, + 'application/json': { schema: convertedPet }, + 'application/jsonl': { schema: { items: convertedPet, type: 'array' } }, }, }, }, @@ -1314,24 +1351,24 @@ describe('downgradeSpecV32ToV31', () => { schemas: { S: schema }, }, }).components, - ).toEqual({ headers: { H: { schema: pet } }, schemas: { S: schema } }) + ).toEqual({ headers: { H: { schema: convertedPet } }, schemas: { S: schema } }) }) it.each([ [ 'adds allOf beside sibling annotations', { $ref: petRef.$ref, description: 'd' }, - { allOf: [pet], description: 'd' }, + { allOf: [convertedPet], description: 'd' }, ], [ 'appends to an existing allOf, keeping its indices', { $ref: petRef.$ref, allOf: [{ required: ['a'] }] }, - { allOf: [{ required: ['a'] }, pet] }, + { allOf: [{ required: ['a'] }, convertedPet] }, ], [ 'nests the siblings when allOf is malformed', { $ref: petRef.$ref, allOf: 'junk' }, - { allOf: [{ allOf: 'junk' }, pet] }, + { allOf: [{ allOf: 'junk' }, convertedPet] }, ], ])('merges a dangling schema $ref with its siblings: %s', (_name, schema, expected) => { expect( @@ -1472,7 +1509,7 @@ describe('downgradeSpecV32ToV31', () => { expect(result.paths).toEqual({ '/a': {}, '/b': {} }) }) - it('removes a Reference Object whose inlining cycles, together with references to it', () => { + it('leaves a Reference Object whose chain loops through removed parts as written', () => { const result = convertSpec({ components: { responses: { @@ -1486,8 +1523,17 @@ describe('downgradeSpecV32ToV31', () => { '/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: {} } } }) + expect(result.components).toEqual({ + responses: { + Keep: { description: 'k' }, + Loop: { $ref: '#/paths/~1a/query/responses/200' }, + }, + }) + expect(result.paths).toEqual({ + '/a': {}, + '/b': {}, + '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, + }) }) it('cuts a recursive schema at its first repeat by removing only the $ref keyword', () => { @@ -1568,7 +1614,7 @@ describe('downgradeSpecV32ToV31', () => { }, }) expect(() => JSON.stringify(result)).not.toThrow() - expect(result).toEqual({ items: {}, type: 'array' }) + expect(result).toEqual({ items: { properties: { children: {} }, type: 'object' }, type: 'array' }) }) it('inlines references into a parameter list that lost entries, since its indices shift', () => { @@ -1635,7 +1681,7 @@ describe('downgradeSpecV32ToV31', () => { ).toEqual({ headers: {}, parameters: {} }) }) - it('removes references whose alias chain cycles, since they can never resolve', () => { + it('leaves references whose alias chain loops as written, since they never resolve', () => { expect( convertSpec({ components: { @@ -1645,10 +1691,15 @@ describe('downgradeSpecV32ToV31', () => { }, }, }).components, - ).toEqual({ parameters: {} }) + ).toEqual({ + parameters: { + A: { $ref: '#/components/parameters/B' }, + B: { $ref: '#/components/parameters/A' }, + }, + }) }) - it('removes a looping reference in both passes, so later parameter indices stay correct', () => { + it('leaves a looping reference as written, so later parameter indices stay correct', () => { const result = convertSpec({ components: { parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }, paths: { @@ -1662,8 +1713,11 @@ describe('downgradeSpecV32ToV31', () => { '/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' } } }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ + { $ref: '#/paths/~1b/query/parameters/0' }, + { in: 'query', name: 'b' }, + ]) + expect(result.components).toEqual({ parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }) }) it('leaves external, anchor, root, unparseable, and already dangling references untouched', () => { @@ -1684,7 +1738,7 @@ describe('downgradeSpecV32ToV31', () => { schemas, }, }).components, - ).toEqual({ headers: { H: { schema: pet } }, schemas }) + ).toEqual({ headers: { H: { schema: convertedPet } }, schemas }) }) it('decodes escaped and percent-encoded pointer tokens', () => { @@ -1749,6 +1803,103 @@ describe('downgradeSpecV32ToV31', () => { }).components?.schemas, ).toEqual({ Moved: { type: 'string' }, Removed: { type: 'number' } }) }) + it('keeps a header alias whose target is only cut by a media type cycle', () => { + const result = convertSpec({ + components: { + headers: { A: { $ref: '#/components/mediaTypes/M/encoding/e/headers/h' } }, + mediaTypes: { + M: { + encoding: { e: { headers: { h: { content: { 'a/b': { $ref: '#/components/mediaTypes/M' } } }, x: { $ref: '#/components/headers/A' } } } }, + schema: { type: 'string' }, + }, + }, + }, + paths: { + '/p': { + get: { + responses: { + 200: { + content: { 'a/b': { $ref: '#/components/mediaTypes/M' } }, + description: 'ok', + headers: { X: { $ref: '#/components/headers/A' } }, + }, + }, + }, + }, + }, + }) + expect(dig(result, 'paths', '/p', 'get', 'responses', '200', 'headers')).toEqual({ X: { $ref: '#/components/headers/A' } }) + expect(dig(result, 'components', 'headers', 'A', 'content', 'a/b', 'schema')).toEqual({ type: 'string' }) + }) + + it('removes links and discriminator mappings that point into removed parts', () => { + const result = convertSpec({ + components: { + links: { gone: { operationRef: '#/paths/~1a/query' }, kept: { operationRef: '#/paths/~1a/get' } }, + mediaTypes: { M: { schema: {} } }, + schemas: { + Pet: { + discriminator: { + mapping: { cat: '#/components/schemas/Cat', item: '#/components/mediaTypes/M/schema' }, + propertyName: 'kind', + }, + }, + }, + }, + paths: { '/a': { get: {}, query: {} } }, + }) + expect(dig(result, 'components', 'links')).toEqual({ kept: { operationRef: '#/paths/~1a/get' } }) + expect(dig(result, 'components', 'schemas', 'Pet', 'discriminator')).toEqual({ mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }) + }) + + it('follows schema alias chains through removed parts, stopping at an alias with siblings', () => { + const result = convertSpec({ + components: { + mediaTypes: { + A: { schema: { $ref: '#/components/mediaTypes/B/schema' } }, + B: { schema: { $ref: '#/components/mediaTypes/C/schema', description: 'b' } }, + C: { schema: { type: 'string' } }, + }, + schemas: { S: { $ref: '#/components/mediaTypes/A/schema' } }, + }, + }) + expect(dig(result, 'components', 'schemas', 'S')).toEqual({ allOf: [{ type: 'string' }], description: 'b' }) + }) + + it('inlines a path item $ref that points into a removed operation, keeping own fields', () => { + const callbacks = { c: { '{$url}': { description: 'inlined', summary: 'Inlined' } } } + const result = convertSpec({ + components: { + pathItems: { + copy: { $ref: '#/paths/~1a/additionalOperations/COPY/callbacks/c/{$url}' }, + query: { $ref: '#/paths/~1a/query/callbacks/c/{$url}', summary: 'Own' }, + }, + }, + paths: { '/a': { additionalOperations: { COPY: { callbacks } }, query: { callbacks } } }, + }) + expect(dig(result, 'components', 'pathItems')).toEqual({ + copy: { description: 'inlined', summary: 'Inlined' }, + query: { description: 'inlined', summary: 'Own' }, + }) + }) + + it('inlines a path item $ref into a removed operation of a callbacks component', () => { + const result = convertSpec({ + components: { + callbacks: { + C: { '{$url}': { query: { callbacks: { d: { '{$v}': { description: 'inlined' } } } } }, 'x-cb': { query: {} } }, + }, + pathItems: { + P: { $ref: '#/components/callbacks/C/{$url}/query/callbacks/d/{$v}' }, + X: { $ref: '#/components/callbacks/C/x-cb' }, + }, + }, + }) + expect(dig(result, 'components', 'pathItems')).toEqual({ + P: { description: 'inlined' }, + X: { $ref: '#/components/callbacks/C/x-cb' }, + }) + }) }) describe('robustness', () => { @@ -1837,7 +1988,7 @@ describe('downgradeSpecV32ToV31', () => { }) describe('downgradeSchemaV32ToV31', () => { - it('deep-clones schemas, preserving discriminator defaultMapping and xml nodeType verbatim', () => { + it('deep-clones schemas, removing discriminator defaultMapping and mapping xml nodeType', () => { const source = { discriminator: { defaultMapping: 'Dog', @@ -1849,7 +2000,12 @@ describe('downgradeSchemaV32ToV31', () => { xml: { nodeType: 'attribute' }, } satisfies OpenAPIV3_2.SchemaObject const result = downgradeSchemaV32ToV31(source) - expect(result).toEqual(source) + expect(result).toEqual({ + discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, + properties: { a: { xml: {} } }, + type: 'object', + xml: { attribute: true }, + }) expect(result).not.toBe(source) expect(dig(result, 'discriminator')).not.toBe(source.discriminator) expect(dig(result, 'properties')).not.toBe(source.properties) @@ -1866,12 +2022,43 @@ describe('downgradeSchemaV32ToV31', () => { items: { xml: { nodeType: 'cdata' } }, } const result = downgradeSchemaV32ToV31(source as any) - expect(result).toEqual(source) + expect(result).toEqual({ allOf: [{ discriminator: {} }, true], items: { xml: {} } }) expect(dig(result, 'allOf')).not.toBe(source.allOf) expect(dig(result, 'allOf', '0')).not.toBe(source.allOf[0]) expect(dig(result, 'items')).not.toBe(source.items) }) + it.each([ + ['maps an attribute node', { xml: { name: 'n', nodeType: 'attribute' } }, { xml: { attribute: true, name: 'n' } }], + ['maps an element node on an array to wrapped', { type: ['array', 'null'], xml: { nodeType: 'element' } }, { type: ['array', 'null'], xml: { wrapped: true } }], + ['drops an element node elsewhere', { type: 'object', xml: { nodeType: 'element' } }, { type: 'object', xml: {} }], + ['drops nodes 3.1 cannot express', { xml: { nodeType: 'text' } }, { xml: {} }], + ['passes a malformed xml through', { xml: 'junk' }, { xml: 'junk' }], + ])('xml: %s', (_name, input, expected) => { + expect(downgradeSchemaV32ToV31(input as any)).toEqual(expected) + }) + + it('converts nested schemas at every subschema position', () => { + const inner = { discriminator: { defaultMapping: 'A', propertyName: 'kind' } } + const out = { discriminator: { propertyName: 'kind' } } + const keywords = ['additionalProperties', 'contains', 'contentSchema', 'else', 'if', 'items', 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties'] + const lists = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] + const maps = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'] + expect(downgradeSchemaV32ToV31({ + ...Object.fromEntries(keywords.map(key => [key, inner])), + ...Object.fromEntries(lists.map(key => [key, [inner, true]])), + ...Object.fromEntries(maps.map(key => [key, { a: inner }])), + 'const': inner, + 'x-extension': inner, + } as any)).toEqual({ + ...Object.fromEntries(keywords.map(key => [key, out])), + ...Object.fromEntries(lists.map(key => [key, [out, true]])), + ...Object.fromEntries(maps.map(key => [key, { a: out }])), + 'const': inner, + 'x-extension': inner, + }) + }) + it('keeps unknown schema keywords, validation keywords, and extensions unchanged', () => { const source = { 'customKeyword': { nested: true }, diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 0b94308..4122c3d 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -1,380 +1,309 @@ 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, FieldTable } from './shared' +import type { Context } from './shared' import { - convertRecord, - deepClone, + allOfItems, + clone, + convertMappingRef, + convertObject, + convertXml, + defineFields, + downgrade, DROP, - getChild, getRef, - isConverting, + hasDanglingOperationRef, + HTTP_METHODS, + inline, + inlineSchema, + isNotExtension, + isPath, isRecord, - mapArray, - mapRecord, - operationFields, - parseLocalRef, - resolveLocalRef, + list, + map, + mergeRef, + refOr, + removedPrefixes, + skipAliases, } from './shared' 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 { - 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 { - 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] } +const convertCallback = map(convertPathItem, isNotExtension) +const convertContent = map(convertContentEntry) +const convertServers = list(convertServer) +const finishPathItem = mergeRef(convertPathItem) +const convertCallbackRef = refOr(convertCallback) +const convertExampleRef = refOr(convertExample) +const convertLinkRef = refOr(convertLink) +const convertParameterRef = refOr(convertParameter) +const convertRequestBodyRef = refOr(convertRequestBody) +const convertResponseRef = refOr(convertResponse) +const convertSecuritySchemeRef = refOr(convertSecurityScheme) + +const SERVER_FIELDS = defineFields({ + name: DROP, +}) + +const TAG_FIELDS = defineFields({ + kind: DROP, + parent: DROP, + summary: DROP, +}) + +const DISCRIMINATOR_FIELDS = defineFields({ + defaultMapping: DROP, + mapping: map(convertMappingRef), +}) + +const SCHEMA_FIELDS = defineFields({ + $defs: map(convertSchema), + $ref: (item, ctx) => (typeof item === 'string' && ctx.dangles(item) ? DROP : clone(item)), + additionalProperties: convertSchema, + allOf: list(convertSchema), + anyOf: list(convertSchema), + contains: convertSchema, + contentSchema: convertSchema, + dependentSchemas: map(convertSchema), + discriminator: (item, ctx) => convertObject(item, ctx, DISCRIMINATOR_FIELDS), + else: convertSchema, + if: convertSchema, + items: convertSchema, + not: convertSchema, + oneOf: list(convertSchema), + patternProperties: map(convertSchema), + prefixItems: list(convertSchema), + properties: map(convertSchema), + propertyNames: convertSchema, + then: convertSchema, + unevaluatedItems: convertSchema, + unevaluatedProperties: convertSchema, + xml: convertXml, +}) + +const EXAMPLE_FIELDS = defineFields({ + dataValue: DROP, + serializedValue: DROP, +}) + +const PARAMETER_FIELDS = defineFields({ + allowReserved: (item, _ctx, parameter) => (!('in' in parameter) || parameter.in === 'query' ? clone(item) : DROP), + content: convertContent, + examples: map(convertExampleRef), + schema: convertSchema, + style: item => (item === 'cookie' ? DROP : clone(item)), +}) + +const ENCODING_FIELDS = defineFields({ + encoding: DROP, + headers: map(convertParameterRef), + itemEncoding: DROP, + prefixEncoding: DROP, +}) + +const MEDIA_TYPE_FIELDS = defineFields({ + description: DROP, + encoding: map(convertEncoding), + examples: map(convertExampleRef), + itemEncoding: DROP, + itemSchema: DROP, + prefixEncoding: DROP, + schema: convertSchema, +}) + +const REQUEST_BODY_FIELDS = defineFields({ + content: convertContent, +}) + +const RESPONSE_FIELDS = defineFields({ + content: convertContent, + headers: map(convertParameterRef), + links: map(convertLinkRef), + summary: DROP, +}) + +const LINK_FIELDS = defineFields({ + server: convertServer, +}) + +const FLOWS_FIELDS = defineFields({ + deviceAuthorization: DROP, +}) + +const SECURITY_SCHEME_FIELDS = defineFields({ + deprecated: DROP, + flows: (item, ctx) => convertObject(item, ctx, FLOWS_FIELDS), + oauth2MetadataUrl: DROP, +}) + +const OPERATION_FIELDS = defineFields({ + callbacks: map(convertCallbackRef), + parameters: list(convertParameterRef), + requestBody: convertRequestBodyRef, + responses: map(convertResponseRef, isNotExtension), + servers: convertServers, +}) + +const PATH_ITEM_FIELDS = defineFields({ + ...Object.fromEntries(HTTP_METHODS.map(method => [method, convertOperation])), + additionalOperations: DROP, + parameters: list(convertParameterRef), + query: DROP, + servers: convertServers, +}) + +const COMPONENTS_FIELDS = defineFields({ + callbacks: map(convertCallbackRef), + examples: map(convertExampleRef), + headers: map(convertParameterRef), + links: map(convertLinkRef), + mediaTypes: DROP, + parameters: map(convertParameterRef), + pathItems: map(convertPathItem), + requestBodies: map(convertRequestBodyRef), + responses: map(convertResponseRef), + schemas: map(convertSchema), + securitySchemes: map(convertSecuritySchemeRef), +}) + +const DOCUMENT_FIELDS = defineFields({ + $self: DROP, + components: (item, ctx) => convertObject(item, ctx, COMPONENTS_FIELDS), + jsonSchemaDialect: item => (typeof item === 'string' && item.startsWith(V32_DIALECT_PREFIX) ? V31_DIALECT : clone(item)), + paths: map(convertPathItem, isPath), + servers: convertServers, + tags: list(convertTag), + webhooks: map(convertPathItem), +}) + +const REMOVED = removedPrefixes({ '': DOCUMENT_FIELDS, '/components': COMPONENTS_FIELDS }) + +function convertServer(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, SERVER_FIELDS) +} + +function convertTag(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, TAG_FIELDS) +} + +function finishSchema(out: Record, schema: Record, ctx: Context): unknown { + if (typeof schema.$ref === 'string' && !('$ref' in out)) { + const target = inlineSchema(schema.$ref, ctx, convertSchema) + if (target !== DROP) { + out.allOf = [...allOfItems(out.allOf), target] + } } - out.allOf = [...allOf, target] return out } -function convertServer(value: unknown): unknown { - return convertRecord(value, { name: DROP }) -} - -function convertTag(value: unknown): unknown { - return convertRecord(value, { kind: DROP, parent: DROP, summary: DROP }) -} - -function convertLink(value: unknown): unknown { - return convertRecord(value, { server: convertServer }) -} - -function convertSecurityScheme(value: unknown): unknown { - return convertRecord(value, { - deprecated: DROP, - flows: item => convertRecord(item, { deviceAuthorization: DROP }), - oauth2MetadataUrl: DROP, - }) +function convertSchema(value: unknown, ctx: Context): unknown { + const ref = getRef(value) + const out = ref !== undefined && Object.keys(value as object).length === 1 && ctx.dangles(ref) + ? inlineSchema(ref, ctx, convertSchema) + : convertObject(value, ctx, SCHEMA_FIELDS, finishSchema) + return out === DROP ? {} : out } -function convertExample(value: unknown): unknown { - return convertRecord(value, { dataValue: DROP, serializedValue: DROP }, (out, example) => { - if ('value' in example || 'externalValue' in example) { - return out - } +function finishExample(out: Record, example: Record): unknown { + if (!('value' in example || 'externalValue' in example)) { if ('dataValue' in example) { - out.value = deepClone(example.dataValue) + out.value = clone(example.dataValue) } else if ('serializedValue' in example) { - out.value = deepClone(example.serializedValue) + out.value = clone(example.serializedValue) } - return out - }) + } + return out } -function isQuerystringParameter(value: unknown): boolean { - return isRecord(value) && value.in === 'querystring' +function convertExample(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, EXAMPLE_FIELDS, finishExample) } -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 finishParameter(out: Record, parameter: Record): unknown { + if (!isRecord(parameter.content)) { + return out } -} - -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) + if (Object.keys(out.content as object).length === 0) { + return Object.keys(parameter.content).length > 0 ? DROP : out } - 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), - schema: context.convertSchema, - style: item => (item === 'cookie' ? DROP : deepClone(item)), - }) -} - -function convertParameterEntry(value: unknown, context: Context): unknown { - return isRemovedParameter(value, context) ? DROP : convertRefOr(value, context, convertParameterOrHeader) -} - -function convertHeaderMap(value: unknown, context: Context): unknown { - return mapRecord(value, item => isRemovedHeader(item, context) ? DROP : convertRefOr(item, context, convertParameterOrHeader)) -} - -function convertEncoding(value: unknown, context: Context): unknown { - return convertRecord(value, { - encoding: DROP, - headers: item => convertHeaderMap(item, context), - itemEncoding: DROP, - prefixEncoding: DROP, - }) -} - -function convertMediaType(value: unknown, context: Context): unknown { - return convertRecord( - value, - { - description: DROP, - encoding: item => mapRecord(item, entry => convertEncoding(entry, context)), - examples: refMap(context, convertExample), - itemEncoding: DROP, - itemSchema: DROP, - prefixEncoding: DROP, - schema: context.convertSchema, - }, - (out, mediaType) => { - if ('itemSchema' in mediaType && out.schema === undefined) { - out.schema = { items: context.convertSchema(mediaType.itemSchema), type: 'array' } - } - return out - }, - ) + delete out.example + delete out.examples + return out } -function resolveMediaType(value: unknown, context: Context): unknown { - const target = resolveRefChain(value, context) - return target === undefined ? DROP : target +function convertParameter(value: unknown, ctx: Context): unknown { + if (isRecord(value) && value.in === 'querystring') { + return DROP + } + return convertObject(value, ctx, PARAMETER_FIELDS, finishParameter) } -function convertContentMap(value: unknown, context: Context): unknown { - return mapRecord(value, (item) => { - const target = resolveMediaType(item, context) - return target === DROP ? DROP : convertMediaType(target, context) - }) +function convertEncoding(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, ENCODING_FIELDS) } -function convertRequestBody(value: unknown, context: Context): unknown { - return convertRecord(value, { content: item => convertContentMap(item, context) }) +function finishMediaType(out: Record, mediaType: Record, ctx: Context): unknown { + if ('itemSchema' in mediaType && !('schema' in mediaType)) { + out.schema = { items: convertSchema(mediaType.itemSchema, ctx), type: 'array' } + } + return out } -function convertResponse(value: unknown, context: Context): unknown { - return convertRecord( - value, - { - content: item => convertContentMap(item, context), - headers: item => convertHeaderMap(item, context), - links: refMap(context, convertLink), - summary: DROP, - }, - (out, response) => { - if (out.description === undefined) { - out.description = typeof response.summary === 'string' ? response.summary : '' - } - return out - }, - ) +function convertMediaType(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, MEDIA_TYPE_FIELDS, finishMediaType) } -function convertResponses(value: unknown, context: Context): unknown { - return mapRecord(value, (item, key) => key.startsWith('x-') ? deepClone(item) : convertRefOr(item, context, convertResponse)) +function convertContentEntry(value: unknown, ctx: Context): unknown { + const ref = getRef(value) + return ref === undefined ? convertMediaType(value, ctx) : inline(skipAliases(ref, ctx, () => true), ctx, convertContentEntry) } -function convertOperation(value: unknown, context: Context): unknown { - return convertRecord(value, { - callbacks: refMap(context, convertCallback), - parameters: item => mapArray(item, entry => convertParameterEntry(entry, context)), - requestBody: item => convertRefOr(item, context, convertRequestBody), - responses: item => convertResponses(item, context), - servers: item => mapArray(item, convertServer), - }) +function convertRequestBody(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, REQUEST_BODY_FIELDS) } -function convertCallback(value: unknown, context: Context): unknown { - return mapRecord(value, (item, key) => key.startsWith('x-') ? deepClone(item) : convertPathItem(item, context)) +function finishResponse(out: Record, response: Record): unknown { + if (!('description' in out)) { + out.description = typeof response.summary === 'string' ? response.summary : '' + } + return out } -function convertPathItem(value: unknown, context: Context): unknown { - return convertRecord(value, { - ...operationFields(item => convertOperation(item, context)), - additionalOperations: DROP, - parameters: item => mapArray(item, entry => convertParameterEntry(entry, context)), - query: DROP, - servers: item => mapArray(item, convertServer), - }) +function convertResponse(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, RESPONSE_FIELDS, finishResponse) } -function convertPaths(value: unknown, context: Context): unknown { - return mapRecord(value, (item, key) => key.startsWith('/') ? convertPathItem(item, context) : deepClone(item)) +function convertLink(value: unknown, ctx: Context): unknown { + return hasDanglingOperationRef(value, ctx) ? DROP : convertObject(value, ctx, LINK_FIELDS) } -function convertComponents(value: unknown, context: Context): unknown { - return convertRecord(value, { - callbacks: refMap(context, convertCallback), - examples: refMap(context, convertExample), - headers: item => convertHeaderMap(item, context), - links: refMap(context, convertLink), - mediaTypes: DROP, - parameters: item => mapRecord(item, entry => convertParameterEntry(entry, context)), - 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 convertSecurityScheme(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, SECURITY_SCHEME_FIELDS) } -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, context) === DROP) +function convertOperation(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, OPERATION_FIELDS) } -function convertJsonSchemaDialect(value: unknown): unknown { - return typeof value === 'string' && value.startsWith(V32_DIALECT_PREFIX) ? V31_DIALECT : deepClone(value) +function convertPathItem(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, PATH_ITEM_FIELDS, finishPathItem) } -function convertSpec(spec: unknown, context: Context): unknown { - return convertRecord( - spec, - { - $self: DROP, - components: item => convertComponents(item, context), - jsonSchemaDialect: convertJsonSchemaDialect, - paths: item => convertPaths(item, context), - servers: item => mapArray(item, convertServer), - tags: item => mapArray(item, convertTag), - webhooks: item => mapRecord(item, entry => convertPathItem(entry, context)), - }, - (out) => { - out.openapi = '3.1.2' - return out - }, - ) +function finishDocument(out: Record): unknown { + out.openapi = '3.1.2' + return out } -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 convertDocument(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, DOCUMENT_FIELDS, finishDocument) } -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 { + return downgrade(spec, convertDocument, REMOVED) as OpenAPIV3_1.OpenAPIObject } -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 +export function downgradeSchemaV32ToV31(schema: OpenAPIV3_2.SchemaObject): OpenAPIV3_1.SchemaObject { + return downgrade(schema, convertSchema) as OpenAPIV3_1.SchemaObject } diff --git a/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap b/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap index 6051a63..eea66fd 100644 --- a/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap +++ b/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap @@ -397,7 +397,7 @@ exports[`3.1 example documents downgraded to 3.0 > converts the webhook example, } `; -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts the 3.2 mega document, preserving the discriminator defaultMapping in the schema > v3.0 1`] = ` +exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts the 3.2 mega document, removing the discriminator defaultMapping from the schema > v3.0 1`] = ` { "components": { "schemas": { @@ -438,7 +438,7 @@ exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts t } `; -exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts the 3.2 mega document, preserving the discriminator defaultMapping in the schema > v3.1 1`] = ` +exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts the 3.2 mega document, removing the discriminator defaultMapping from the schema > v3.1 1`] = ` { "components": { "pathItems": { @@ -454,7 +454,6 @@ exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > converts t }, ], "discriminator": { - "defaultMapping": "Bar", "mapping": { "foo": "Foo", }, diff --git a/packages/downgrader/tests/corpus.test.ts b/packages/downgrader/tests/corpus.test.ts index c1f923b..b1f2843 100644 --- a/packages/downgrader/tests/corpus.test.ts +++ b/packages/downgrader/tests/corpus.test.ts @@ -76,7 +76,7 @@ import { doc as tagObjectExampleV32 } from '../../types/tests/schema-tests-3.2/t import { doc as validSchemaTypesV32 } from '../../types/tests/schema-tests-3.2/valid-schema-types' import { doc as webhookExampleV32 } from '../../types/tests/schema-tests-3.2/webhook-example' import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '../src/index' -import { expectValidAs } from './helpers' +import { expectNoNewDanglingRefs, expectValidAs } from './helpers' // Excluded: security-scheme-object-examples (external $ref the validator cannot resolve) // and style-defaults (x-comment in an Encoding Object, rejected by the official 3.0 schema). @@ -187,13 +187,14 @@ const corpus32: readonly (readonly [ describe('3.1 corpus downgraded to 3.0', () => { it.each(corpus31)( - 'converts %s to a valid 3.0 document without mutating the input', + 'converts %s to a valid 3.0 document without new dangling references or mutating the input', async (_name, doc) => { await expectValidAs(doc, '3.1') const before = structuredClone(doc) const v30 = downgradeSpecV31ToV30(doc) expect(v30.openapi).toBe('3.0.4') await expectValidAs(v30, '3.0') + expectNoNewDanglingRefs(doc, v30) expect(doc).toEqual(before) }, ) @@ -201,16 +202,18 @@ describe('3.1 corpus downgraded to 3.0', () => { describe('3.2 corpus downgraded to 3.1 and chained to 3.0', () => { it.each(corpus32)( - 'converts %s to valid 3.1 and 3.0 documents without mutating the input', + 'converts %s to valid 3.1 and 3.0 documents without new dangling references or mutating the input', async (_name, doc) => { await expectValidAs(doc, '3.2') const before = structuredClone(doc) const v31 = downgradeSpecV32ToV31(doc) expect(v31.openapi).toBe('3.1.2') await expectValidAs(v31, '3.1') + expectNoNewDanglingRefs(doc, v31) const v30 = downgradeSpecV31ToV30(v31) expect(v30.openapi).toBe('3.0.4') await expectValidAs(v30, '3.0') + expectNoNewDanglingRefs(v31, v30) expect(doc).toEqual(before) }, ) diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts index 2fb1ca1..5e70005 100644 --- a/packages/downgrader/tests/e2e.test.ts +++ b/packages/downgrader/tests/e2e.test.ts @@ -102,7 +102,7 @@ describe('3.1 example documents downgraded to 3.0', () => { expect(doc).toEqual(before) }) - it('resolves $refs and link operationRefs into the removed webhooks and components.pathItems so nothing dangles', async () => { + it('resolves $refs into the removed webhooks and components.pathItems and drops links into them so nothing dangles', async () => { const petSchema = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' const doc: OpenAPIV3_1.OpenAPIObject = { components: { @@ -172,13 +172,13 @@ describe('3.1 example documents downgraded to 3.0', () => { get: { parameters: [ { in: 'header', name: 'X-Signature', schema: { type: 'string' } }, - { description: 'Filter', in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }, + { in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }, ], responses: { 200: { content: { 'application/json': { schema: { items: pet, type: 'array' } } }, description: 'ok', - links: { item: { operationId: 'getItem' } }, + links: {}, }, 201: { description: 'received' }, }, @@ -387,7 +387,7 @@ describe('3.2 example documents downgraded to 3.1 and chained to 3.0', () => { expect(doc).toEqual(before) }) - it('converts the 3.2 mega document, preserving the discriminator defaultMapping in the schema', async () => { + it('converts the 3.2 mega document, removing the discriminator defaultMapping from the schema', async () => { const before = structuredClone(mega32) const v31 = downgradeSpecV32ToV31(mega32) expect(v31.openapi).toBe('3.1.2') @@ -402,10 +402,7 @@ describe('3.2 example documents downgraded to 3.1 and chained to 3.0', () => { 'schema', 'discriminator', ] - expect(v31).toHaveProperty( - [...megaDiscriminatorPath, 'defaultMapping'], - 'Bar', - ) + expect(v31).not.toHaveProperty([...megaDiscriminatorPath, 'defaultMapping']) expect(v31).toHaveProperty( [...megaDiscriminatorPath, 'propertyName'], 'type', diff --git a/packages/downgrader/tests/helpers.ts b/packages/downgrader/tests/helpers.ts index 0099c95..9644b33 100644 --- a/packages/downgrader/tests/helpers.ts +++ b/packages/downgrader/tests/helpers.ts @@ -1,6 +1,8 @@ import { Validator } from '@seriousme/openapi-schema-validator' import { expect } from 'vitest' +import { resolve } from '../src/shared' + export function dig(value: unknown, ...path: string[]): unknown { let current: unknown = value for (const key of path) { @@ -9,6 +11,28 @@ export function dig(value: unknown, ...path: string[]): unknown { return current } +function collectLocalRefs(value: unknown, refs: Set): Set { + if (typeof value === 'object' && value !== null) { + for (const [key, item] of Object.entries(value)) { + if ((key === '$ref' || key === 'operationRef') && typeof item === 'string' && item.startsWith('#')) { + refs.add(item) + } + else { + collectLocalRefs(item, refs) + } + } + } + return refs +} + +export function expectNoNewDanglingRefs(input: object, output: object): void { + for (const ref of collectLocalRefs(output, new Set())) { + if (resolve(input, ref) !== undefined) { + expect(resolve(output, ref), ref).toBeDefined() + } + } +} + export async function expectValidAs(spec: object, expectedVersion: string): Promise { const validator = new Validator() const result = await validator.validate(structuredClone(spec) as Record)