diff --git a/package.json b/package.json index a58ef69..eca6811 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "sync:sponsors": "node scripts/sync-sponsors.ts && git ls-files --modified --others --exclude-standard | xargs -r pnpm run lint:fix --no-error-on-unmatched-pattern" }, "devDependencies": { + "@seriousme/openapi-schema-validator": "^2.9.1", "@types/node": "^26.4.0", "@vitest/coverage-v8": "^4.1.11", "lint-staged": "^17.4.1", diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md new file mode 100644 index 0000000..6542055 --- /dev/null +++ b/packages/downgrader/README.md @@ -0,0 +1,86 @@ +# @oasty/downgrader + +Downgrade [OpenAPI Specification](https://spec.openapis.org/) documents one minor version at a time: 3.2 → 3.1 and 3.1 → 3.0. Each converter works on an entire document or on a single Schema Object. + +- **Never throws**: malformed parts are deep-copied through unchanged instead of failing the whole conversion, and cyclic object graphs (e.g. the output of a `$ref` dereferencer) don't recurse forever — a subtree that cycles back into an ancestor is deep-copied with its cycle preserved instead of converted. Only pathologically deep nesting (thousands of levels) can still exhaust the call stack. +- **Never mutates**: the input document is left untouched. +- **Extension-preserving, never extension-inventing**: existing `x-` keys and unknown keys always survive, while constructs the target version cannot express are converted where an equivalent exists and removed otherwise. + +## Usage + +```ts +import { + downgradeSchemaV31ToV30, + downgradeSchemaV32ToV31, + downgradeSpecV31ToV30, + downgradeSpecV32ToV31, +} from "@oasty/downgrader"; + +const v31 = downgradeSpecV32ToV31(v32Document); +const v30 = downgradeSpecV31ToV30(v31Document); + +// There is intentionally no direct 3.2 → 3.0 converter; compose the steps: +const downgraded = downgradeSpecV31ToV30(downgradeSpecV32ToV31(v32Document)); + +// Schema Objects can be converted standalone: +const schema = downgradeSchemaV31ToV30({ type: ["string", "null"] }); +// { type: "string", nullable: true } +``` + +## 3.2 → 3.1 + +Schema Objects pass through unchanged: the 3.2 Schema Object keyword set is identical to 3.1's (3.2 defines its own dialect URI, but only the OAS base vocabulary gained fields), and the 3.2-only fields (discriminator `defaultMapping`, XML `nodeType`) are deliberately retained. Two caveats: the standard OpenAPI 3.1 document schema tolerates them (Schema Object internals are open there), but the strict OAS 3.1 base-vocabulary meta-schema closes the XML and Discriminator Objects to their fixed fields plus `x-`, so a base-vocabulary validator will flag them; and 3.1 tooling will not act on them — in particular a `defaultMapping` fallback stops taking effect (`nodeType` is recovered on the 3.1 → 3.0 hop). + +Converted: + +| 3.2 construct | 3.1 result | +| --- | --- | +| `openapi: 3.2.x` | `openapi: 3.1.2` | +| `components.mediaTypes` and content-map `$ref`s to them | references inlined, the component map removed; content entries whose reference cannot be inlined (external, unknown, or cyclic targets) are removed, as 3.1 content maps cannot hold references — a parameter or header losing its entire `content` that way is removed with it (3.1 requires exactly one entry there) | +| Media type `itemSchema` without a sibling `schema` | `schema: { type: "array", items: … }` (the 3.2 sequential media type data model) | +| Response `summary` when no `description` exists | promoted to `description` (required in 3.1, so `""` is synthesized as a last resort) | +| Example `dataValue` / `serializedValue` when `value` and `externalValue` are absent | promoted to `value` (in that order) | +| Parameter `style: "cookie"` | removed, letting the 3.1 default `form` apply | + +Removed (no 3.1 equivalent): `$self`, server `name`, tag `summary`/`parent`/`kind`, the `query` operation and `additionalOperations` of Path Items, `in: "querystring"` parameters (from parameter lists and `components.parameters`, together with references to the removed component entries, following chains of reference aliases), `allowReserved` on non-query parameters, media type `description`, media type / encoding `prefixEncoding`, `itemEncoding`, and nested `encoding`, a media type `itemSchema` beside an existing `schema`, response `summary` beside an existing `description`, OAuth `deviceAuthorization` flows, and security scheme `oauth2MetadataUrl` and `deprecated`. + +Known limitations: security requirements using URI keys and `$self`-relative reference resolution are passed through unchanged. + +## 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) | +| Reference `summary` / `description` overrides | removed (3.0 references stand alone) | +| Security requirement roles on non-OAuth schemes | emptied (`[]`) | + +Removed (no 3.0 equivalent): `webhooks`, `components.pathItems` (local `$ref`s pointing at it are left untouched and will dangle), `jsonSchemaDialect`, `info.summary`, `license.identifier`, and `mutualTLS` security schemes (reference aliases to them included) — their names are stripped from every security requirement, requirements that referenced only such schemes are removed, and a `security` list emptied that way is removed entirely, since an explicit empty list means "no security required" and would make an operation public. + +Schema Objects: + +| 3.1 construct | 3.0 result | +| --- | --- | +| `true` / `false` boolean schemas | `{}` / `{ not: {} }` | +| `$ref` with sibling keywords | siblings kept, `$ref` wrapped into `allOf` | +| `type: ["T", "null"]` | `type: "T"` plus `nullable: true` | +| `enum: []` / duplicate `required` entries | `enum` removed / `required` deduplicated (3.0 requires a non-empty `enum` and unique `required`) | +| `type: "null"` | `nullable: true` plus `enum: [null]`; a sibling `enum`/`const` is intersected with the null type — an `enum` containing `null` collapses to `[null]`, and a sibling excluding `null` yields a match-nothing schema (`not: {}`), since the source accepted no value | +| `type` with several non-null entries | `anyOf` of single-type schemas | +| `const` | single-value `enum` | +| numeric `exclusiveMinimum` / `exclusiveMaximum` | bound plus boolean flag (the tighter bound wins) | +| `examples` | first entry becomes `example` when none exists | +| `contentEncoding: base64` | `format: byte` | +| `contentMediaType: application/octet-stream` | `format: binary` | +| `type: "array"` without `items` | `items: {}` is added (required in 3.0) | +| XML `nodeType` (carried over from a 3.2 chain) | `attribute: true` / `wrapped: true` where expressible, then removed (3.0 forbids unknown XML Object fields) | +| `$schema`, `$id`, `$defs`, `$anchor`, `$dynamicRef`/`$dynamicAnchor`, `$vocabulary`, `$comment`, `if`/`then`/`else`, `dependentSchemas`/`dependentRequired`, `prefixItems` (and its trailing `items`), `contains`/`minContains`/`maxContains`, `patternProperties` (and its sibling `additionalProperties`, whose meaning would otherwise tighten onto the pattern-matched keys), `propertyNames`, `unevaluatedItems`/`unevaluatedProperties`, `contentSchema` | removed — in positive schema positions dropping these only loosens validation, the safe direction for a downgrade | + +Known limitations: `$ref`s that point into dropped keywords (`#/…/$defs/…` pointers, `$anchor` targets, `$id`-based bases) will dangle — hoist reusable subschemas into `components.schemas` before downgrading. Arbitrary non-standard schema keywords are preserved per the extension-preserving 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 shift what validates. + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going through [GitHub Sponsors](https://github.com/sponsors/dinwwwh) or [Open Collective](https://opencollective.com/middleapi). Every bit helps! 🚀 diff --git a/packages/downgrader/package.json b/packages/downgrader/package.json new file mode 100644 index 0000000..cf8fc94 --- /dev/null +++ b/packages/downgrader/package.json @@ -0,0 +1,68 @@ +{ + "name": "@oasty/downgrader", + "version": "0.0.0", + "description": "Downgrade OpenAPI specifications one minor version at a time: 3.2 to 3.1 and 3.1 to 3.0, for whole documents or individual schemas", + "keywords": [ + "converter", + "downgrade", + "oas", + "oasty", + "openapi", + "openapi-3.0", + "openapi-3.1", + "openapi-3.2", + "specification", + "swagger", + "typescript" + ], + "homepage": "https://github.com/middleapi/oasty", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/oasty.git", + "directory": "packages/downgrader" + }, + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./v3.2-to-v3.1": "./src/v3.2-to-v3.1.ts", + "./v3.1-to-v3.0": "./src/v3.1-to-v3.0.ts" + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./v3.2-to-v3.1": { + "types": "./dist/v3.2-to-v3.1.d.mts", + "import": "./dist/v3.2-to-v3.1.mjs", + "default": "./dist/v3.2-to-v3.1.mjs" + }, + "./v3.1-to-v3.0": { + "types": "./dist/v3.1-to-v3.0.d.mts", + "import": "./dist/v3.1-to-v3.0.mjs", + "default": "./dist/v3.1-to-v3.0.mjs" + } + } + }, + "scripts": { + "build": "unbuild", + "prepack": "unbuild", + "type:check": "tsc -b" + }, + "dependencies": { + "@oasty/types": "workspace:^" + } +} diff --git a/packages/downgrader/src/index.ts b/packages/downgrader/src/index.ts new file mode 100644 index 0000000..ed896e4 --- /dev/null +++ b/packages/downgrader/src/index.ts @@ -0,0 +1,2 @@ +export { downgradeSchemaV31ToV30, downgradeSpecV31ToV30 } from "./v3.1-to-v3.0"; +export { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from "./v3.2-to-v3.1"; diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts new file mode 100644 index 0000000..a1a610b --- /dev/null +++ b/packages/downgrader/src/shared.test.ts @@ -0,0 +1,373 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- the helpers under test are the converters' `unknown`-typed I/O boundary, so the test doubles mirror their signatures */ + +import type { UnknownRecord } from "./shared"; +import { + convertRecord, + deepClone, + DROP, + getRef, + HTTP_METHODS, + isRecord, + mapArray, + mapRecord, + operationFields, + setKey, +} from "./shared"; + +const identity = (value: T): T => value; + +const asRecord = (value: unknown): UnknownRecord => + // SAFETY: the helpers return plain objects for plain-object input; the tests inspect their keys. + value as UnknownRecord; + +/** A converter that recurses into `self`, so a self-referencing node re-enters convertRecord. */ +const convertNode = (value: unknown): unknown => + convertRecord(value, { + name: () => "converted", + self: (item) => convertNode(item), + }); + +describe("isRecord", () => { + it("returns true for plain object literals", () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + }); + + it("returns true for objects with a null prototype", () => { + expect(isRecord(Object.create(null))).toBe(true); + }); + + it("returns false for null", () => { + expect(isRecord(null)).toBe(false); + }); + + it("returns false for arrays", () => { + expect(isRecord([])).toBe(false); + expect(isRecord([1, 2])).toBe(false); + }); + + it("returns false for primitives", () => { + expect(isRecord("text")).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord(true)).toBe(false); + expect(isRecord(Symbol("s"))).toBe(false); + expect(isRecord(10n)).toBe(false); + }); + + it("returns false for class instances", () => { + expect(isRecord(new Date())).toBe(false); + expect(isRecord(new Map())).toBe(false); + }); +}); + +describe("deepClone", () => { + 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); + }); + + 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); + }); + + 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); + }); + + 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 = asRecord(deepClone(input)); + expect(Object.getOwnPropertyNames(clone)).toContain("__proto__"); + expect(Object.getOwnPropertyDescriptor(clone, "__proto__")?.value).toEqual({ + polluted: true, + }); + expect(Object.getPrototypeOf(clone)).toBe(Object.prototype); + expect(asRecord({}).polluted).toBeUndefined(); + }); + + it("preserves key order", () => { + const input: UnknownRecord = {}; + input.zebra = 1; + input.apple = 2; + input.mango = 3; + expect(Object.keys(deepClone(input))).toEqual(["zebra", "apple", "mango"]); + }); + + it("preserves object cycles instead of recursing forever", () => { + const child: UnknownRecord = {}; + const node: UnknownRecord = { child, name: "root" }; + child.parent = node; + const clone = deepClone(node); + expect(clone).not.toBe(node); + expect(clone.name).toBe("root"); + expect(asRecord(clone.child).parent).toBe(clone); + }); + + 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); + }); + + 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); + }); +}); + +describe("convertRecord", () => { + it("routes listed fields through their converters and deep-clones the rest", () => { + const extra = { deep: true }; + const result = asRecord( + convertRecord( + { a: 1, b: 2, extra }, + { a: (item) => [item], b: () => "converted" } + ) + ); + 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) } + ); + 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( + source, + { value: (item, 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); + }); + + it("preserves key order", () => { + const input: UnknownRecord = {}; + input.zebra = 1; + input.apple = 2; + input.mango = 3; + const result = asRecord(convertRecord(input, { apple: identity })); + 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); + 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 = asRecord(convertRecord(input, {})); + 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.getPrototypeOf(result)).toBe(Object.prototype); + expect(asRecord({}).polluted).toBeUndefined(); + }); + + it("falls back to a cycle-preserving clone when re-entered for the same object", () => { + const node: UnknownRecord = { name: "root" }; + node.self = node; + const result = asRecord(convertNode(node)); + expect(result.name).toBe("converted"); + const inner = asRecord(result.self); + expect(inner).not.toBe(node); + expect(inner.name).toBe("root"); + expect(inner.self).toBe(inner); + }); + + it("converts shared acyclic references at every occurrence", () => { + const shared = { name: "x" }; + const result = convertRecord( + { a: shared, b: shared }, + { + a: (item) => convertRecord(item, { name: () => "a" }), + b: (item) => convertRecord(item, { name: () => "b" }), + } + ); + expect(result).toEqual({ a: { name: "a" }, b: { name: "b" } }); + }); + + 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 }); + }); +}); + +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]); + expect(Object.values(fields).every((entry) => entry === identity)).toBe( + true + ); + }); +}); + +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]); + // SAFETY: the test input only contains numbers. + return (item as number) * 10; + }); + expect(result).toEqual({ a: 10, b: 20 }); + expect(calls).toEqual([ + [1, "a"], + [2, "b"], + ]); + }); + + 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 }); + }); + + it("preserves key order", () => { + const input: UnknownRecord = {}; + input.zebra = 1; + input.apple = 2; + const result = asRecord(mapRecord(input, identity)); + expect(Object.keys(result)).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); + expect(convert).not.toHaveBeenCalled(); + }); +}); + +describe("mapArray", () => { + it("applies the converter to every element", () => { + const result = mapArray( + [1, 2, 3], + (item) => + // SAFETY: the test input only contains numbers. + (item as number) + 1 + ); + expect(result).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]); + }); + + 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); + expect(result).toEqual(record); + expect(result).not.toBe(record); + expect(mapArray(7, convert)).toBe(7); + expect(mapArray(undefined, convert)).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" + ); + }); + + it("returns undefined for non-objects", () => { + expect(getRef(null)).toBeUndefined(); + expect(getRef("#/ref")).toBeUndefined(); + expect(getRef(42)).toBeUndefined(); + expect(getRef([{ $ref: "#/x" }])).toBeUndefined(); + }); + + it("returns undefined when $ref is missing or not a string", () => { + expect(getRef({})).toBeUndefined(); + expect(getRef({ ref: "#/x" })).toBeUndefined(); + expect(getRef({ $ref: 42 })).toBeUndefined(); + expect(getRef({ $ref: { nested: true } })).toBeUndefined(); + expect(getRef({ $ref: null })).toBeUndefined(); + }); +}); + +describe("setKey", () => { + it("defines an enumerable, writable, configurable own property", () => { + const target: UnknownRecord = {}; + setKey(target, "name", "value"); + expect(Object.getOwnPropertyDescriptor(target, "name")).toEqual({ + configurable: true, + enumerable: true, + value: "value", + writable: true, + }); + }); + + it("sets a __proto__ key as a plain own property without prototype pollution", () => { + const target: UnknownRecord = {}; + setKey(target, "__proto__", { polluted: true }); + const descriptor = Object.getOwnPropertyDescriptor(target, "__proto__"); + expect(descriptor?.value).toEqual({ polluted: true }); + expect(descriptor?.enumerable).toBe(true); + expect(Object.getPrototypeOf(target)).toBe(Object.prototype); + expect(asRecord({}).polluted).toBeUndefined(); + }); +}); diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts new file mode 100644 index 0000000..58906db --- /dev/null +++ b/packages/downgrader/src/shared.ts @@ -0,0 +1,205 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-known-value-widening, anti-slop/no-runtime-typeof -- the converters are the I/O boundary for untrusted OpenAPI documents: they walk arbitrary input defensively and pass malformed parts through unchanged, so `unknown` values and runtime type checks are the domain contract here */ + +/** + * Internal helpers shared by the version converters. Everything is defensive: + * converters never throw on malformed input, they pass unconvertible parts + * through unchanged. + */ + +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- converters walk arbitrary user-supplied documents whose values are unknown by nature +export type UnknownRecord = Record; + +/** + * Returned by a converter to remove its entry from the surrounding object or + * array: the single signal for constructs the target version cannot express. + */ +export const DROP = Symbol("drop"); + +/** + * Converts one field of a record. The whole source record is passed along + * for decisions that depend on sibling fields. + */ +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the source is an arbitrary user-supplied object +export type FieldConverter = (item: unknown, source: UnknownRecord) => unknown; + +/** + * What happens to each known field of a record: a converter, or `DROP` to + * remove the field. Fields not listed (unknown keys, `x-` extensions) are + * deep-cloned as they are. + */ +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- the tables are keyed by arbitrary OpenAPI field names +export type FieldTable = Readonly>; + +export const HTTP_METHODS = [ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +] as const; + +/** Field-table entries routing every Operation Object of a Path Item to `convert`. */ +export const operationFields = (convert: FieldConverter): FieldTable => + Object.fromEntries(HTTP_METHODS.map((method) => [method, convert])); + +/** + * Whether the value is a plain object (the only shape the converters walk + * into). Arrays, class instances, and primitives are handled by reference or + * by dedicated array helpers. + */ +export const isRecord = (value: unknown): value is UnknownRecord => { + if (typeof value !== "object" || value === null) { + return false; + } + const proto: unknown = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +/** + * Sets a key on the output record with define-property semantics, so hostile + * key names like `__proto__` become plain own properties. + */ +export const setKey = ( + target: UnknownRecord, + key: string, + value: unknown +): void => { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +}; + +const cloneValue = ( + value: unknown, + seen: WeakMap +): unknown => { + if (!(Array.isArray(value) || isRecord(value))) { + return value; + } + const existing = seen.get(value); + if (existing !== undefined) { + return existing; + } + if (Array.isArray(value)) { + const out: unknown[] = []; + seen.set(value, out); + for (const item of value) { + out.push(cloneValue(item, seen)); + } + return out; + } + const out: UnknownRecord = {}; + seen.set(value, out); + for (const [key, item] of Object.entries(value)) { + setKey(out, key, cloneValue(item, seen)); + } + return out; +}; + +/** + * A JSON-oriented deep clone that never throws: non-plain values (class + * instances, functions, ...) are kept by reference, hostile keys like + * `__proto__` are copied as own data properties instead of being assigned, + * and cyclic or shared references are preserved in the clone instead of + * recursing forever. + */ +export const deepClone = (value: T): T => { + if (!(Array.isArray(value) || isRecord(value))) { + return value; + } + // SAFETY: cloneValue preserves the runtime shape of its input. + return cloneValue(value, new WeakMap()) as T; +}; + +/** Objects currently being converted somewhere up the call stack. */ +const converting = new WeakSet(); + +/** + * Rebuilds a plain object field by field: each key goes through its entry in + * `fields` (or is deep-cloned when it has none), entries mapped to or + * returning `DROP` are left out, and `finish` receives the result together + * with the source for fix-ups that depend on several fields. Non-object input + * is deep-cloned unchanged, and so is an object already being converted + * higher up the call stack: a cyclic reference, which would otherwise recurse + * forever. + */ +export const convertRecord = ( + value: unknown, + fields: FieldTable, + finish?: (out: UnknownRecord, source: UnknownRecord) => unknown +): unknown => { + if (!isRecord(value) || converting.has(value)) { + return deepClone(value); + } + converting.add(value); + try { + const out: UnknownRecord = {}; + 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); + if (converted !== DROP) { + setKey(out, key, converted); + } + } + return finish === undefined ? out : finish(out, value); + } finally { + converting.delete(value); + } +}; + +/** + * Applies `convert` to every value of a plain object, preserving key order + * and leaving out entries it turns into `DROP`. Non-object input is + * deep-cloned unchanged. + */ +export const mapRecord = ( + value: unknown, + convert: (item: unknown, key: string) => unknown +): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + const converted = convert(item, key); + if (converted !== DROP) { + setKey(out, key, converted); + } + } + return out; +}; + +/** + * Applies `convert` to every element of an array, leaving out elements it + * turns into `DROP`. Non-array input is deep-cloned unchanged. + */ +export const 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); +}; + +/** + * Returns the `$ref` string of a Reference-Object-shaped value, or + * `undefined` when the value is not one. + */ +export const getRef = (value: unknown): string | undefined => { + if (isRecord(value) && typeof value.$ref === "string") { + return value.$ref; + } + return undefined; +}; diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts new file mode 100644 index 0000000..73efa05 --- /dev/null +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -0,0 +1,1238 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- the helpers deliberately accept and return `unknown` so tests can feed malformed input to the graceful-degradation branches and inspect loosely-shaped output */ + +import type { OpenAPIV3_1 } from "@oasty/types"; + +import type { UnknownRecord } from "./shared"; +import { downgradeSchemaV31ToV30, downgradeSpecV31ToV30 } from "./v3.1-to-v3.0"; + +const asSpec = (value: unknown): OpenAPIV3_1.OpenAPIObject => + // SAFETY: tests deliberately feed malformed or loosely-shaped documents to exercise graceful handling. + value as OpenAPIV3_1.OpenAPIObject; + +const asSchema = (value: unknown): OpenAPIV3_1.SchemaObject => + // SAFETY: tests deliberately feed malformed or loosely-shaped schemas to exercise graceful handling. + value as OpenAPIV3_1.SchemaObject; + +const dig = (value: unknown, ...path: string[]): unknown => { + let current: unknown = value; + for (const key of path) { + // SAFETY: tests walk converter output whose shape the surrounding assertions pin down. + current = (current as UnknownRecord)[key]; + } + return current; +}; + +const info = { title: "t", version: "1" }; + +/** The smallest valid 3.1 document and its 3.0 counterpart. */ +const base = { info, openapi: "3.1.0", paths: {} }; +const converted = { info, openapi: "3.0.4", paths: {} }; + +const convertSpec = (fields: UnknownRecord) => + downgradeSpecV31ToV30(asSpec({ ...base, ...fields })); + +const convertPathItem = (pathItem: unknown): unknown => + dig(convertSpec({ paths: { "/a": pathItem } }), "paths", "/a"); + +const convertComponent = (kind: string, value: unknown): unknown => + dig( + convertSpec({ components: { [kind]: { X: value } } }), + "components", + kind, + "X" + ); + +const convertSchema = (schema: unknown): unknown => + downgradeSchemaV31ToV30(asSchema(schema)); + +describe("downgradeSpecV31ToV30", () => { + describe("document", () => { + it("rewrites the openapi version to 3.0.4", () => { + expect( + downgradeSpecV31ToV30({ info, openapi: "3.1.1", paths: {} }) + ).toEqual(converted); + }); + + it("adds openapi 3.0.4 and an empty paths object when they are missing", () => { + expect(downgradeSpecV31ToV30(asSpec({ info }))).toEqual(converted); + }); + + it("removes jsonSchemaDialect and webhooks without leaving traces", () => { + const result = convertSpec({ + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + webhooks: { newPet: { post: { summary: "s" } } }, + }); + expect(result).toEqual(converted); + expect(result).not.toHaveProperty("x-webhooks"); + }); + + it("preserves unknown top-level keys and extensions", () => { + expect(convertSpec({ future: { a: 1 }, "x-root": true })).toEqual({ + ...converted, + future: { a: 1 }, + "x-root": true, + }); + }); + + it("clones non-object input unchanged", () => { + expect(downgradeSpecV31ToV30(asSpec(null))).toBeNull(); + expect(downgradeSpecV31ToV30(asSpec(42))).toBe(42); + expect(downgradeSpecV31ToV30(asSpec("spec"))).toBe("spec"); + const list = [1, { a: 1 }]; + const result = downgradeSpecV31ToV30(asSpec(list)); + expect(result).toEqual(list); + expect(result).not.toBe(list); + }); + }); + + describe("info", () => { + it("removes summary and license.identifier and keeps the other fields", () => { + expect( + convertSpec({ + info: { + license: { + identifier: "MIT", + name: "MIT", + url: "https://opensource.org/license/mit", + }, + summary: "short", + title: "t", + version: "1", + }, + }).info + ).toEqual({ + license: { name: "MIT", url: "https://opensource.org/license/mit" }, + title: "t", + version: "1", + }); + }); + + it("clones malformed info and license values unchanged", () => { + expect(convertSpec({ info: 42 }).info).toBe(42); + expect( + convertSpec({ info: { license: "MIT", title: "t", version: "1" } }).info + ).toEqual({ + license: "MIT", + title: "t", + version: "1", + }); + }); + }); + + describe("paths", () => { + it("converts path items and clones non-path keys", () => { + expect( + convertSpec({ + paths: { + "/a": { get: { summary: "s" } }, + // Path-item-shaped on purpose: cloning must NOT convert it, so + // no responses may be synthesized inside. + "x-note": { get: { summary: "s" } }, + }, + }).paths + ).toEqual({ + "/a": { + get: { responses: { default: { description: "" } }, summary: "s" }, + }, + "x-note": { get: { summary: "s" } }, + }); + }); + + it("leaves a path item $ref field untouched, string or not", () => { + expect( + convertPathItem({ + $ref: "#/components/pathItems/Reusable", + summary: "s", + }) + ).toEqual({ + $ref: "#/components/pathItems/Reusable", + summary: "s", + }); + expect(convertPathItem({ $ref: 42 })).toEqual({ $ref: 42 }); + }); + + it("clones malformed paths, path items, operations, and nested objects unchanged", () => { + expect(convertSpec({ paths: "junk" }).paths).toBe("junk"); + const paths = { + "/a": { + get: { requestBody: 42, responses: { "200": "junk" } }, + parameters: [42], + }, + "/b": { + post: { + requestBody: { + content: { + "application/json": "junk", + "multipart/form-data": { encoding: { field: "junk" } }, + }, + }, + responses: {}, + }, + }, + "/c": { get: "junk" }, + "/junk": "junk", + }; + expect(convertSpec({ paths }).paths).toEqual(paths); + }); + }); + + describe("reference objects", () => { + it("strips reference summary and description across components maps", () => { + expect( + convertSpec({ + components: { + callbacks: { C: { $ref: "#/c/cb", summary: "s" } }, + examples: { E: { $ref: "#/c/e", description: "d" } }, + headers: { H: { $ref: "#/c/h", summary: "s" } }, + links: { L: { $ref: "#/c/l", description: "d" } }, + parameters: { + P: { $ref: "#/c/p", description: "d", summary: "s" }, + }, + requestBodies: { B: { $ref: "#/c/b", summary: "s" } }, + responses: { R: { $ref: "#/c/r", description: "d" } }, + securitySchemes: { S: { $ref: "#/c/s", description: "d" } }, + }, + }).components + ).toEqual({ + callbacks: { C: { $ref: "#/c/cb" } }, + examples: { E: { $ref: "#/c/e" } }, + headers: { H: { $ref: "#/c/h" } }, + links: { L: { $ref: "#/c/l" } }, + parameters: { P: { $ref: "#/c/p" } }, + requestBodies: { B: { $ref: "#/c/b" } }, + responses: { R: { $ref: "#/c/r" } }, + securitySchemes: { S: { $ref: "#/c/s" } }, + }); + }); + + it("strips reference overrides inside operations and path items", () => { + expect( + convertPathItem({ + get: { + callbacks: { cb: { $ref: "#/c/cb", summary: "s" } }, + parameters: [{ $ref: "#/c/p", description: "d" }], + requestBody: { $ref: "#/c/b", summary: "s" }, + responses: { "200": { $ref: "#/c/r", summary: "s" } }, + }, + parameters: [{ $ref: "#/c/pp", summary: "s" }], + }) + ).toEqual({ + get: { + callbacks: { cb: { $ref: "#/c/cb" } }, + parameters: [{ $ref: "#/c/p" }], + requestBody: { $ref: "#/c/b" }, + responses: { "200": { $ref: "#/c/r" } }, + }, + parameters: [{ $ref: "#/c/pp" }], + }); + }); + + it("strips reference overrides in response headers, links, and media type examples", () => { + expect( + convertComponent("responses", { + content: { + "application/json": { + examples: { e: { $ref: "#/c/e", summary: "s" } }, + schema: { type: ["string", "null"] }, + }, + }, + description: "ok", + headers: { H: { $ref: "#/c/h", summary: "s" } }, + links: { l: { $ref: "#/c/l", description: "d" } }, + }) + ).toEqual({ + content: { + "application/json": { + examples: { e: { $ref: "#/c/e" } }, + schema: { nullable: true, type: "string" }, + }, + }, + description: "ok", + headers: { H: { $ref: "#/c/h" } }, + links: { l: { $ref: "#/c/l" } }, + }); + }); + + it("keeps x- entries in a responses map unconverted", () => { + const responses = { + "200": { description: "ok" }, + "x-note": { $ref: "#/c/r", summary: "s" }, + }; + expect(convertPathItem({ get: { responses } })).toEqual({ + get: { responses }, + }); + }); + }); + + describe("operations", () => { + it("synthesizes a minimal default responses object when an operation lacks one", () => { + expect(convertPathItem({ get: { operationId: "getA" } })).toEqual({ + get: { + operationId: "getA", + responses: { default: { description: "" } }, + }, + }); + }); + + it("converts parameter schemas, content, and examples", () => { + expect( + convertPathItem({ + get: { + parameters: [ + { + examples: { e: { $ref: "#/c/e", summary: "s" } }, + in: "query", + name: "p", + schema: { type: ["string", "null"] }, + }, + { + content: { + "text/plain": { schema: { type: ["integer", "null"] } }, + }, + in: "query", + name: "q", + }, + ], + responses: {}, + }, + }) + ).toEqual({ + get: { + parameters: [ + { + examples: { e: { $ref: "#/c/e" } }, + in: "query", + name: "p", + schema: { nullable: true, type: "string" }, + }, + { + content: { + "text/plain": { schema: { nullable: true, type: "integer" } }, + }, + in: "query", + name: "q", + }, + ], + responses: {}, + }, + }); + }); + + it("adds required: true to path parameters that lack it", () => { + expect( + convertPathItem({ + get: { + parameters: [ + { + content: { "text/plain": { schema: { type: "string" } } }, + in: "path", + name: "id", + }, + { in: "query", name: "q", schema: {} }, + ], + responses: {}, + }, + }) + ).toEqual({ + get: { + parameters: [ + { + content: { "text/plain": { schema: { type: "string" } } }, + in: "path", + name: "id", + required: true, + }, + { in: "query", name: "q", schema: {} }, + ], + responses: {}, + }, + }); + }); + + it("converts request body content, media type encoding, and encoding headers", () => { + expect( + convertComponent("requestBodies", { + content: { + "multipart/form-data": { + encoding: { + field: { + contentType: "text/plain", + headers: { + H: { $ref: "#/c/h", summary: "s" }, + H2: { schema: { type: ["string", "null"] } }, + }, + }, + }, + example: { field: "v" }, + schema: { type: "object" }, + }, + }, + description: "body", + required: true, + }) + ).toEqual({ + content: { + "multipart/form-data": { + encoding: { + field: { + contentType: "text/plain", + headers: { + H: { $ref: "#/c/h" }, + H2: { schema: { nullable: true, type: "string" } }, + }, + }, + }, + example: { field: "v" }, + schema: { type: "object" }, + }, + }, + description: "body", + required: true, + }); + }); + + it("converts inline callback objects, cloning x- keys and junk entries", () => { + expect( + convertPathItem({ + get: { + callbacks: { + inline: { expr: { get: {} }, "x-k": { expr: { get: {} } } }, + junk: 7, + }, + responses: {}, + }, + }) + ).toEqual({ + get: { + callbacks: { + inline: { + expr: { get: { responses: { default: { description: "" } } } }, + "x-k": { expr: { get: {} } }, + }, + junk: 7, + }, + responses: {}, + }, + }); + }); + }); + + describe("components", () => { + it("removes pathItems and keeps the other component maps", () => { + const result = convertSpec({ + components: { + pathItems: { Reusable: { get: { summary: "s" } } }, + schemas: { S: { type: "string" } }, + }, + }); + expect(result.components).toEqual({ schemas: { S: { type: "string" } } }); + expect(result.components).not.toHaveProperty("x-pathItems"); + }); + + it("leaves references into components.pathItems intact apart from override stripping", () => { + expect( + convertComponent("callbacks", { + $ref: "#/components/pathItems/Reusable", + summary: "s", + }) + ).toEqual({ $ref: "#/components/pathItems/Reusable" }); + }); + + it("converts component callbacks and schemas, including boolean schemas", () => { + expect( + convertSpec({ + components: { + callbacks: { + junkCallback: 42, + realCallback: { + "x-note": { "{$expr}": { get: {} } }, + "{$request.body#/url}": { post: { summary: "s" } }, + }, + }, + schemas: { S: { type: ["string", "null"] }, T: true }, + "x-extra": { keep: true }, + }, + }).components + ).toEqual({ + callbacks: { + junkCallback: 42, + realCallback: { + "x-note": { "{$expr}": { get: {} } }, + "{$request.body#/url}": { + post: { + responses: { default: { description: "" } }, + summary: "s", + }, + }, + }, + }, + schemas: { S: { nullable: true, type: "string" }, T: {} }, + "x-extra": { keep: true }, + }); + }); + + it("clones a malformed components value unchanged", () => { + expect(convertSpec({ components: "junk" }).components).toBe("junk"); + }); + }); + + describe("security", () => { + const apiKey = { in: "header", name: "k", type: "apiKey" }; + + it("removes mutualTLS schemes and drops requirements that become empty", () => { + const result = convertSpec({ + components: { + securitySchemes: { api: apiKey, mtls: { type: "mutualTLS" } }, + }, + security: [{ mtls: [] }, { api: [], mtls: [] }, {}], + }); + expect(result.components).toEqual({ securitySchemes: { api: apiKey } }); + expect(result.security).toEqual([{ api: [] }, {}]); + }); + + it("removes reference aliases of mutualTLS schemes and their requirements", () => { + const result = convertSpec({ + components: { + securitySchemes: { + api: apiKey, + clientCert: { $ref: "#/components/securitySchemes/mtlsBase" }, + mtlsBase: { type: "mutualTLS" }, + }, + }, + security: [{ clientCert: [] }, { api: [] }], + }); + expect(result.components).toEqual({ securitySchemes: { api: apiKey } }); + expect(result.security).toEqual([{ api: [] }]); + }); + + it("survives cyclic, dangling, external, and malformed scheme aliases", () => { + const securitySchemes = { + dangling: { $ref: "#/components/securitySchemes/missing" }, + external: { $ref: "https://example.com/s.json#/schemes/a" }, + junk: 42, + nested: { $ref: "#/components/securitySchemes/a/b" }, + ping: { $ref: "#/components/securitySchemes/pong" }, + pong: { $ref: "#/components/securitySchemes/ping" }, + }; + expect( + convertSpec({ components: { securitySchemes } }).components + ).toEqual({ + securitySchemes, + }); + }); + + it("empties roles on non-OAuth schemes and keeps them elsewhere", () => { + expect( + convertSpec({ + components: { + securitySchemes: { + api: apiKey, + basic: { scheme: "basic", type: "http" }, + oauth: { flows: {}, type: "oauth2" }, + oidc: { openIdConnectUrl: "https://x", type: "openIdConnect" }, + }, + }, + security: [ + { api: ["read"], basic: ["admin"] }, + { oauth: ["read"], oidc: ["a"], unknownScheme: ["s"] }, + ], + }).security + ).toEqual([ + { api: [], basic: [] }, + { oauth: ["read"], oidc: ["a"], unknownScheme: ["s"] }, + ]); + }); + + it("converts operation-level security lists", () => { + expect( + convertSpec({ + components: { + securitySchemes: { api: apiKey, mtls: { type: "mutualTLS" } }, + }, + paths: { + "/a": { + get: { + responses: {}, + security: [{ mtls: [] }, { api: ["read"] }], + }, + }, + }, + }).paths + ).toEqual({ "/a": { get: { responses: {}, security: [{ api: [] }] } } }); + }); + + it("omits a security list that mutualTLS removal emptied instead of making it public", () => { + const result = convertSpec({ + components: { securitySchemes: { mtls: { type: "mutualTLS" } } }, + paths: { + "/admin": { get: { responses: {}, security: [{ mtls: [] }] } }, + }, + security: [{ mtls: [] }], + }); + expect(result.paths).toEqual({ "/admin": { get: { responses: {} } } }); + expect(result).not.toHaveProperty("security"); + }); + + it("keeps an explicitly empty security list", () => { + const result = convertSpec({ + paths: { "/a": { get: { responses: {}, security: [] } } }, + security: [], + }); + expect(result.paths).toEqual({ + "/a": { get: { responses: {}, security: [] } }, + }); + expect(result.security).toEqual([]); + }); + + it("clones malformed security values and scheme maps unchanged", () => { + expect( + convertSpec({ security: [{ api: [] }, "junk", 42] }).security + ).toEqual([{ api: [] }, "junk", 42]); + expect(convertSpec({ security: { api: [] } }).security).toEqual({ + api: [], + }); + expect( + convertSpec({ components: { securitySchemes: "junk" } }).components + ).toEqual({ + securitySchemes: "junk", + }); + }); + }); + + describe("robustness", () => { + it("never mutates the input document", () => { + const input = asSpec({ + components: { + pathItems: { Reusable: { get: { summary: "s" } } }, + schemas: { S: { $ref: "#/c/s", type: ["string", "null"] } }, + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + mtls: { type: "mutualTLS" }, + }, + }, + info: { + license: { identifier: "MIT", name: "MIT" }, + summary: "short", + title: "t", + version: "1", + }, + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + openapi: "3.1.0", + paths: { + "/a": { + get: { + parameters: [{ $ref: "#/c/p", summary: "s" }], + security: [{ mtls: [] }, { api: ["read"] }], + }, + }, + }, + security: [{ mtls: [] }], + webhooks: { newPet: { post: { summary: "s" } } }, + }); + const before = structuredClone(input); + downgradeSpecV31ToV30(input); + expect(input).toEqual(before); + }); + + it("converts a path item that cycles through its callbacks without throwing", () => { + const callback: UnknownRecord = {}; + const pathItem: UnknownRecord = { + get: { callbacks: { cb: callback }, responses: {} }, + }; + callback.expr = pathItem; + expect(() => convertPathItem(pathItem)).not.toThrow(); + }); + }); +}); + +describe("downgradeSchemaV31ToV30", () => { + describe("boolean and junk schemas", () => { + it("converts the boolean schemas", () => { + expect(downgradeSchemaV31ToV30(true)).toEqual({}); + expect(downgradeSchemaV31ToV30(false)).toEqual({ not: {} }); + }); + + it("clones junk input unchanged", () => { + expect(convertSchema(null)).toBeNull(); + expect(convertSchema(42)).toBe(42); + expect(convertSchema("x")).toBe("x"); + const list = [{ type: "string" }]; + const result = convertSchema(list); + expect(result).toEqual(list); + expect(result).not.toBe(list); + }); + }); + + describe("$ref", () => { + it("keeps a pure $ref as a bare reference object, wherever it points", () => { + const input = { $ref: "#/components/schemas/Pet" }; + const result = downgradeSchemaV31ToV30(input); + expect(result).toEqual(input); + expect(result).not.toBe(input); + expect(convertSchema({ $ref: "#/components/pathItems/Foo" })).toEqual({ + $ref: "#/components/pathItems/Foo", + }); + }); + + it.each([ + [ + "wraps a $ref with sibling keywords into allOf", + { $ref: "#/c/s", minLength: 1 }, + { allOf: [{ $ref: "#/c/s" }], minLength: 1 }, + ], + [ + "merges a $ref into an existing allOf", + { $ref: "#/c/s", allOf: [{ type: "string" }] }, + { allOf: [{ $ref: "#/c/s" }, { type: "string" }] }, + ], + [ + "keeps a malformed allOf and leaves the $ref in place", + { $ref: "#/c/s", allOf: "junk" }, + { $ref: "#/c/s", allOf: "junk" }, + ], + [ + "passes a non-string $ref through unchanged", + { $ref: 123, type: "string" }, + { $ref: 123, type: "string" }, + ], + [ + "passes a lone non-string $ref through unchanged", + { $ref: 123 }, + { $ref: 123 }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("type", () => { + it.each([ + ["keeps a single string type", { type: "string" }, { type: "string" }], + [ + "converts a type array with null into type plus nullable", + { type: ["string", "null"] }, + { nullable: true, type: "string" }, + ], + [ + "converts a null-only type array into nullable plus a null enum", + { type: ["null"] }, + { enum: [null], nullable: true }, + ], + [ + "converts a null-only type string into nullable plus a null enum", + { type: "null" }, + { enum: [null], nullable: true }, + ], + [ + "intersects an existing enum with a null-only type", + { enum: ["a", null], type: ["null"] }, + { enum: [null], nullable: true }, + ], + [ + "matches nothing when the enum of a null-only type excludes null", + { enum: ["a"], type: ["null"] }, + { enum: ["a"], not: {}, nullable: true }, + ], + [ + "clones a malformed enum of a null-only type through", + { enum: "junk", type: ["null"] }, + { enum: "junk", nullable: true }, + ], + [ + "keeps a null const as the enum of a null-only type", + { const: null, type: ["null"] }, + { enum: [null], nullable: true }, + ], + [ + "matches nothing when a non-null const contradicts a null-only type", + { const: 7, type: ["null"] }, + { enum: [7], not: {}, nullable: true }, + ], + [ + "converts multiple non-null types into anyOf variants", + { type: ["string", "integer"] }, + { anyOf: [{ type: "string" }, { type: "integer" }] }, + ], + [ + "converts multiple types with null into nullable anyOf variants", + { type: ["string", "integer", "null"] }, + { + anyOf: [ + { nullable: true, type: "string" }, + { nullable: true, type: "integer" }, + ], + }, + ], + [ + "gives synthesized array variants an empty items", + { type: ["array", "string"] }, + { anyOf: [{ items: {}, type: "array" }, { type: "string" }] }, + ], + [ + "copies existing items into the synthesized array variant", + { items: { type: "integer" }, type: ["array", "string", "null"] }, + { + anyOf: [ + { items: { type: "integer" }, nullable: true, type: "array" }, + { nullable: true, type: "string" }, + ], + items: { type: "integer" }, + }, + ], + [ + "wraps the type union into allOf when anyOf already exists", + { anyOf: [{ minLength: 1 }], type: ["string", "integer"] }, + { + allOf: [{ anyOf: [{ type: "string" }, { type: "integer" }] }], + anyOf: [{ minLength: 1 }], + }, + ], + [ + "appends the type union to an existing allOf when anyOf also exists", + { + allOf: [{ title: "t" }], + anyOf: [{ minLength: 1 }], + type: ["string", "integer"], + }, + { + allOf: [ + { title: "t" }, + { anyOf: [{ type: "string" }, { type: "integer" }] }, + ], + anyOf: [{ minLength: 1 }], + }, + ], + [ + "drops the type union when anyOf exists and allOf is malformed", + { + allOf: "junk", + anyOf: [{ type: "string" }], + type: ["integer", "string"], + }, + { allOf: "junk", anyOf: [{ type: "string" }] }, + ], + [ + "deduplicates type array entries", + { type: ["string", "string"] }, + { type: "string" }, + ], + [ + "ignores non-string type array entries beside valid ones", + { type: ["string", 42] }, + { type: "string" }, + ], + [ + "passes a type array of only junk entries through", + { type: [42] }, + { type: [42] }, + ], + ["passes a junk number type through", { type: 42 }, { type: 42 }], + [ + "passes a junk object type through", + { type: { a: 1 } }, + { type: { a: 1 } }, + ], + ["drops an empty type array", { type: [] }, {}], + [ + "adds empty items to an array type without items", + { type: "array" }, + { items: {}, type: "array" }, + ], + [ + "adds empty items to a nullable array type without items", + { type: ["array", "null"] }, + { items: {}, nullable: true, type: "array" }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("const", () => { + it.each([ + [ + "converts const into a single-value enum", + { const: "a" }, + { enum: ["a"] }, + ], + ["converts a zero const", { const: 0 }, { enum: [0] }], + ["converts a false const", { const: false }, { enum: [false] }], + ["converts an empty-string const", { const: "" }, { enum: [""] }], + [ + "converts a null const and marks the schema nullable", + { const: null }, + { enum: [null], nullable: true }, + ], + [ + "replaces an existing enum with the const value", + { const: 5, enum: [1, 2] }, + { enum: [5] }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("exclusive bounds", () => { + it.each([ + [ + "converts a numeric exclusiveMinimum into minimum plus flag", + { exclusiveMinimum: 3 }, + { exclusiveMinimum: true, minimum: 3 }, + ], + [ + "keeps a tighter inclusive minimum and drops the exclusive one", + { exclusiveMinimum: 3, minimum: 5 }, + { minimum: 5 }, + ], + [ + "overrides a looser inclusive minimum with the exclusive bound", + { exclusiveMinimum: 5, minimum: 3 }, + { exclusiveMinimum: true, minimum: 5 }, + ], + [ + "prefers the exclusive form for equal minimum bounds", + { exclusiveMinimum: 3, minimum: 3 }, + { exclusiveMinimum: true, minimum: 3 }, + ], + [ + "converts a numeric exclusiveMaximum into maximum plus flag", + { exclusiveMaximum: 10 }, + { exclusiveMaximum: true, maximum: 10 }, + ], + [ + "keeps a tighter inclusive maximum and drops the exclusive one", + { exclusiveMaximum: 10, maximum: 5 }, + { maximum: 5 }, + ], + [ + "overrides a looser inclusive maximum with the exclusive bound", + { exclusiveMaximum: 5, maximum: 10 }, + { exclusiveMaximum: true, maximum: 5 }, + ], + [ + "prefers the exclusive form for equal maximum bounds", + { exclusiveMaximum: 5, maximum: 5 }, + { exclusiveMaximum: true, maximum: 5 }, + ], + [ + "passes a 3.0-style boolean exclusiveMinimum through", + { exclusiveMinimum: true, minimum: 3 }, + { exclusiveMinimum: true, minimum: 3 }, + ], + [ + "passes a 3.0-style boolean exclusiveMaximum through", + { exclusiveMaximum: false, maximum: 3 }, + { exclusiveMaximum: false, maximum: 3 }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("examples", () => { + it.each([ + [ + "promotes the first examples entry to example", + { examples: ["a", "b"] }, + { example: "a" }, + ], + [ + "keeps an explicit example over the examples entries", + { example: "e", examples: ["a"] }, + { example: "e" }, + ], + ["drops empty examples arrays", { examples: [] }, {}], + ["drops non-array examples values", { examples: "junk" }, {}], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("content keywords", () => { + it.each([ + [ + "converts contentEncoding base64 into format byte", + { contentEncoding: "base64" }, + { format: "byte" }, + ], + [ + "keeps an existing format over contentEncoding", + { contentEncoding: "base64", format: "custom" }, + { format: "custom" }, + ], + ["drops other content encodings", { contentEncoding: "gzip" }, {}], + [ + "converts contentMediaType application/octet-stream into format binary", + { contentMediaType: "application/octet-stream" }, + { format: "binary" }, + ], + [ + "does not emit format binary when a contentEncoding is present", + { + contentEncoding: "gzip", + contentMediaType: "application/octet-stream", + }, + {}, + ], + [ + "drops other content media types", + { contentMediaType: "image/png" }, + {}, + ], + ["drops contentSchema", { contentSchema: { type: "string" } }, {}], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("dropped keywords", () => { + it("removes every keyword with no 3.0 equivalent", () => { + expect( + convertSchema({ + $anchor: "a", + $comment: "c", + $defs: { D: { type: "string" } }, + $dynamicAnchor: "da", + $dynamicRef: "#dr", + $id: "https://example.com/s", + $schema: "https://json-schema.org/draft/2020-12/schema", + $vocabulary: { "https://example.com/v": true }, + contains: { type: "string" }, + contentSchema: { type: "string" }, + dependentRequired: { a: ["b"] }, + dependentSchemas: { a: { type: "object" } }, + else: { title: "e" }, + if: { title: "i" }, + maxContains: 2, + minContains: 1, + patternProperties: { "^x": { type: "string" } }, + prefixItems: [{ type: "string" }], + propertyNames: { pattern: "^a" }, + // oxlint-disable-next-line no-thenable -- `then` is the JSON Schema keyword under test + then: { title: "t" }, + type: "string", + unevaluatedItems: false, + unevaluatedProperties: false, + }) + ).toEqual({ type: "string" }); + }); + + it.each([ + [ + "drops prefixItems together with its trailing items", + { items: { type: "integer" }, prefixItems: [{ type: "string" }] }, + {}, + ], + [ + "drops boolean additionalProperties together with patternProperties", + { + additionalProperties: false, + patternProperties: { "^x-": {} }, + properties: { name: { type: "string" } }, + type: "object", + }, + { properties: { name: { type: "string" } }, type: "object" }, + ], + [ + "drops schema-valued additionalProperties together with patternProperties", + { + additionalProperties: { type: "integer" }, + patternProperties: { "^x-": {} }, + type: "object", + }, + { type: "object" }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("enum and required", () => { + it.each([ + [ + "removes an empty enum", + { enum: [], type: "string" }, + { type: "string" }, + ], + [ + "keeps a non-empty enum", + { enum: ["a"], type: "string" }, + { enum: ["a"], type: "string" }, + ], + ["drops an empty required array", { required: [] }, {}], + [ + "keeps a non-empty required array", + { required: ["a"] }, + { required: ["a"] }, + ], + [ + "deduplicates required entries", + { required: ["a", "b", "a"], type: "object" }, + { required: ["a", "b"], type: "object" }, + ], + [ + "clones a non-array required value unchanged", + { required: "junk" }, + { required: "junk" }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("subschemas", () => { + it.each([ + [ + "converts nested property schemas", + { + properties: { a: { type: ["string", "null"] }, b: true }, + type: "object", + }, + { + properties: { a: { nullable: true, type: "string" }, b: {} }, + type: "object", + }, + ], + [ + "keeps boolean additionalProperties", + { additionalProperties: false }, + { additionalProperties: false }, + ], + [ + "converts schema additionalProperties", + { additionalProperties: { type: ["string", "null"] } }, + { additionalProperties: { nullable: true, type: "string" } }, + ], + [ + "converts allOf, anyOf, oneOf, and not members", + { + allOf: [true], + anyOf: [{ const: 1 }], + not: false, + oneOf: [{ type: ["integer", "null"] }], + }, + { + allOf: [{}], + anyOf: [{ enum: [1] }], + not: { not: {} }, + oneOf: [{ nullable: true, type: "integer" }], + }, + ], + [ + "clones a non-array allOf value unchanged", + { allOf: "junk" }, + { allOf: "junk" }, + ], + [ + "keeps and converts items when there are no prefixItems", + { items: { type: ["string", "null"] } }, + { items: { nullable: true, type: "string" } }, + ], + ["converts a true items schema", { items: true }, { items: {} }], + [ + "converts a false items schema", + { items: false }, + { items: { not: {} } }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("xml nodeType carried over from 3.2", () => { + it.each([ + [ + "converts nodeType attribute to attribute: true", + { type: "string", xml: { name: "n", nodeType: "attribute" } }, + { type: "string", xml: { attribute: true, name: "n" } }, + ], + [ + "converts nodeType element on an array schema to wrapped: true", + { items: {}, type: "array", xml: { nodeType: "element" } }, + { items: {}, type: "array", xml: { wrapped: true } }, + ], + [ + "converts nodeType element on a nullable array schema to wrapped: true", + { type: ["array", "null"], xml: { nodeType: "element" } }, + { items: {}, nullable: true, type: "array", xml: { wrapped: true } }, + ], + [ + "removes nodeType element on non-array schemas", + { type: "string", xml: { nodeType: "element" } }, + { type: "string", xml: {} }, + ], + [ + "removes inexpressible nodeType values", + { type: "string", xml: { name: "n", nodeType: "text" } }, + { type: "string", xml: { name: "n" } }, + ], + [ + "clones xml objects without nodeType unchanged", + { type: "string", xml: { attribute: true, name: "n" } }, + { type: "string", xml: { attribute: true, name: "n" } }, + ], + [ + "clones malformed xml values unchanged", + { type: "string", xml: "junk" }, + { type: "string", xml: "junk" }, + ], + ])("%s", (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected); + }); + }); + + describe("extensions and unknown keywords", () => { + it("preserves x- keys and unknown keywords", () => { + const input = { customKeyword: "v", title: "t", "x-foo": { a: 1 } }; + expect(convertSchema(input)).toEqual(input); + }); + + it("treats keywords named like Object.prototype members as unknown keywords", () => { + const input = { + constructor: 1, + hasOwnProperty: 2, + toString: 3, + type: "string", + }; + expect(convertSchema(input)).toEqual(input); + }); + }); + + describe("robustness", () => { + it("never mutates the input schema", () => { + const input = asSchema({ + $ref: "#/c/s", + allOf: [{ type: "string" }], + const: null, + examples: ["a"], + exclusiveMinimum: 5, + minimum: 3, + prefixItems: [{ type: "string" }], + properties: { a: { type: ["string", "null"] } }, + type: ["object", "null"], + }); + const before = structuredClone(input); + downgradeSchemaV31ToV30(input); + expect(input).toEqual(before); + }); + + it("converts deeply nested schemas without throwing", () => { + let deep = asSchema({ type: "string" }); + for (let index = 0; index < 1000; index += 1) { + deep = asSchema({ items: deep, type: "array" }); + } + expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow(); + }); + + it("converts a schema whose subtree cycles back to itself without throwing", () => { + const properties: UnknownRecord = {}; + const node: UnknownRecord = { properties, type: "object" }; + properties.self = node; + expect(convertSchema(node)).toHaveProperty( + ["properties", "self", "type"], + "object" + ); + }); + }); +}); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts new file mode 100644 index 0000000..c718edd --- /dev/null +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -0,0 +1,662 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-known-value-widening, anti-slop/no-runtime-typeof -- this converter is the I/O boundary for untrusted OpenAPI documents: it walks arbitrary input defensively and passes malformed parts through unchanged, so `unknown` values and runtime type checks are the domain contract here */ + +/** + * Converts OpenAPI 3.1 documents and schemas to OpenAPI 3.0 (targeting the + * latest patch release, 3.0.4). + * + * The conversion never throws: parts that do not match the expected shape + * are deep-copied through unchanged, a subtree that cycles back into an + * ancestor object is deep-copied with its cycle preserved instead of + * converted, and existing specification extensions (`x-` keys) as well as + * unknown keys are always preserved. Constructs 3.0 cannot express are + * converted where an equivalent exists and removed otherwise — the converter + * never invents `x-` keys of its own: + * + * - Removed: `webhooks`, `components.pathItems`, `jsonSchemaDialect`, + * `info.summary`, and `license.identifier`. + * - Schema keywords are rewritten where 3.0 has an equivalent (`type` arrays + * with `"null"` become `nullable`, `const` becomes a single-value `enum`, + * numeric exclusive bounds become bound + boolean, the first entry of + * `examples` becomes `example`, `contentEncoding: "base64"` and + * `contentMediaType: "application/octet-stream"` become `format: "byte"` + * and `format: "binary"`), and dropped where dropping merely loosens + * validation (`if`/`then`/`else`, `prefixItems`, `patternProperties`, + * `unevaluated*`, `$defs`, `$dynamic*`, ...). + * - Boolean schemas become `{}` / `{ not: {} }`, and a schema `$ref` with + * sibling keywords is wrapped in `allOf` (3.0 references must stand alone). + * - `mutualTLS` security schemes (and the security requirements referencing + * them) are removed, and non-OAuth security requirements lose their role + * names, as 3.0 supports neither. + * + * @see {@link https://spec.openapis.org/oas/v3.1.2.html} + * @see {@link https://spec.openapis.org/oas/v3.0.4.html} + */ + +import type { OpenAPIV3_0, OpenAPIV3_1 } from "@oasty/types"; + +import type { FieldConverter, FieldTable, UnknownRecord } from "./shared"; +import { + convertRecord, + deepClone, + DROP, + getRef, + isRecord, + mapArray, + mapRecord, + operationFields, + setKey, +} from "./shared"; + +/** + * 3.0 references stand alone: a Reference Object is reduced to its `$ref` + * (no `summary`/`description` overrides), anything else is converted. + */ +const convertRefOr = ( + value: unknown, + convert: (item: unknown) => unknown +): unknown => { + const ref = getRef(value); + return ref === undefined ? convert(value) : { $ref: ref }; +}; + +/** Field converter for a map of reference-or-object entries. */ +const refMap = + (convert: (item: unknown) => unknown): FieldConverter => + (item) => + mapRecord(item, (entry) => convertRefOr(entry, convert)); + +/** Field converter for a list of reference-or-object entries. */ +const refList = + (convert: (item: unknown) => unknown): FieldConverter => + (item) => + mapArray(item, (entry) => convertRefOr(entry, convert)); + +const applyTypes = ( + types: string[], + schema: UnknownRecord, + out: UnknownRecord +): void => { + const nullable = types.includes("null"); + const rest = types.filter((item) => item !== "null"); + if (rest.length === 1) { + const [single] = rest; + out.type = single; + if (nullable) { + out.nullable = true; + } + return; + } + if (rest.length === 0) { + if (!nullable) { + // `type: []` allows nothing 3.0 can express; drop it. + return; + } + // `type: "null"` alone: 3.0's `nullable` needs a sibling `type`, so a + // single-value `enum` is the closest expressible form. Sibling `enum`/ + // `const` values intersect with the null type — only null can survive, + // and a sibling that excludes null leaves a schema matching nothing. + out.nullable = true; + if ("const" in schema) { + // convertConst emits the single-value enum; a non-null const + // contradicts the null type, so nothing may validate. + if (schema.const !== null) { + out.not = {}; + } + return; + } + if (Array.isArray(schema.enum)) { + if (schema.enum.includes(null)) { + out.enum = [null]; + } else { + out.not = {}; + } + return; + } + if (!("enum" in schema)) { + out.enum = [null]; + } + return; + } + // Multiple non-null types: 3.0 only allows a single `type`, so the type + // union moves into `anyOf` branches. + const variants = rest.map((item) => { + const variant: UnknownRecord = { type: item }; + if (item === "array") { + // 3.0 requires `items` whenever `type` is "array". + variant.items = out.items === undefined ? {} : deepClone(out.items); + } + if (nullable) { + variant.nullable = true; + } + return variant; + }); + if (out.anyOf === undefined) { + out.anyOf = variants; + } else if (out.allOf === undefined || Array.isArray(out.allOf)) { + out.allOf = [ + ...(Array.isArray(out.allOf) ? out.allOf : []), + { anyOf: variants }, + ]; + } + // With both anyOf occupied and a malformed allOf, the inexpressible type + // union is dropped rather than clobbering the passed-through allOf. +}; + +const convertType = (schema: UnknownRecord, out: UnknownRecord): void => { + const { type } = schema; + if (type === undefined) { + return; + } + if (typeof type === "string") { + applyTypes([type], schema, out); + return; + } + if (Array.isArray(type)) { + const types = [...new Set(type.filter((item) => typeof item === "string"))]; + if (types.length === 0 && type.length > 0) { + // Only malformed entries: pass the array through unchanged. + setKey(out, "type", deepClone(type)); + return; + } + applyTypes(types, schema, out); + return; + } + // Malformed: pass through. + setKey(out, "type", deepClone(type)); +}; + +const convertConst = (schema: UnknownRecord, out: UnknownRecord): void => { + if (!("const" in schema)) { + return; + } + // `const` is a single-value `enum`. + out.enum = [deepClone(schema.const)]; + if (schema.const === null) { + out.nullable = true; + } +}; + +const convertExamples = (schema: UnknownRecord, out: UnknownRecord): void => { + // 3.0 only has the singular `example`; the first entry wins, unless an + // explicit `example` already exists. The rest have no 3.0 home. + if ( + Array.isArray(schema.examples) && + schema.examples.length > 0 && + !("example" in schema) + ) { + out.example = deepClone(schema.examples[0]); + } +}; + +const convertExclusiveBounds = ( + schema: UnknownRecord, + out: UnknownRecord +): void => { + const { exclusiveMaximum, exclusiveMinimum, maximum, minimum } = schema; + // 3.1's numeric exclusive bounds become 3.0's bound + boolean pairs. When + // an inclusive bound is also present, the tighter constraint wins. + if ( + typeof exclusiveMinimum === "number" && + !(typeof minimum === "number" && minimum > exclusiveMinimum) + ) { + out.minimum = exclusiveMinimum; + out.exclusiveMinimum = true; + } + if ( + typeof exclusiveMaximum === "number" && + !(typeof maximum === "number" && maximum < exclusiveMaximum) + ) { + out.maximum = exclusiveMaximum; + out.exclusiveMaximum = true; + } +}; + +const convertContentKeywords = ( + schema: UnknownRecord, + out: UnknownRecord +): void => { + // 3.1 replaced 3.0's `format: "byte"` / `format: "binary"` with the JSON + // Schema content keywords; reconstruct the formats when unambiguous. + if (out.format !== undefined) { + return; + } + if (schema.contentEncoding === "base64") { + out.format = "byte"; + return; + } + if ( + schema.contentEncoding === undefined && + schema.contentMediaType === "application/octet-stream" + ) { + out.format = "binary"; + } +}; + +/** + * 3.1 documents produced from 3.2 ones may carry the 3.2 `nodeType` field + * in XML Objects (tolerated by the standard 3.1 document schema, though + * the OAS base-vocabulary meta-schema closes XML Objects to `x-` extras; + * 3.0 forbids it outright): it maps back to the `attribute`/`wrapped` + * flags where expressible and is removed. + */ +const convertXml = (value: unknown, schemaType: unknown): unknown => + convertRecord(value, { nodeType: DROP }, (out, xml) => { + if (xml.nodeType === "attribute") { + out.attribute = true; + } else if ( + xml.nodeType === "element" && + (schemaType === "array" || + (Array.isArray(schemaType) && schemaType.includes("array"))) + ) { + out.wrapped = true; + } + return out; + }); + +/** Converts the keywords whose 3.0 form depends on several 3.1 keywords at once. */ +const finishSchema = ( + out: UnknownRecord, + schema: UnknownRecord +): UnknownRecord => { + convertType(schema, out); + convertConst(schema, out); + convertExamples(schema, out); + convertExclusiveBounds(schema, out); + convertContentKeywords(schema, out); + if (out.type === "array" && out.items === undefined) { + // 3.0 requires `items` whenever `type` is "array". + out.items = {}; + } + const ref = schema.$ref; + if (typeof ref === "string") { + if (out.allOf === undefined || Array.isArray(out.allOf)) { + // 3.0 references must stand alone: keep the siblings and move the + // reference into an `allOf` member. + out.allOf = [ + { $ref: ref }, + ...(Array.isArray(out.allOf) ? out.allOf : []), + ]; + } else { + // A malformed allOf passes through, so the reference stays in place. + setKey(out, "$ref", ref); + } + } + return out; +}; + +const convertSchema = (schema: unknown): unknown => { + if (schema === true) { + return {}; + } + if (schema === false) { + return { not: {} }; + } + if ( + isRecord(schema) && + typeof schema.$ref === "string" && + Object.keys(schema).length === 1 + ) { + return { $ref: schema.$ref }; + } + // oxlint-disable-next-line no-use-before-define -- mutually recursive with the field table, as schemas are recursive structures + return convertRecord(schema, SCHEMA_FIELDS, finishSchema); +}; + +const convertSubschemas: FieldConverter = (item) => + mapArray(item, convertSchema); + +/** + * Every 3.1 Schema Object keyword 3.0 treats differently; the rest (including + * unknown keywords and `x-` extensions) is copied as it is. Keywords with no + * 3.0 equivalent are dropped: in positive schema positions this only loosens + * validation — the safe direction for a downgrade. Inside `not` (where + * loosening the operand tightens the whole) or between `oneOf` branches + * (where loosening one branch can break exclusivity) the semantics can + * shift; see the README's known limitations. Keywords `finishSchema` + * rewrites from their raw values are dropped here as well. + */ +const SCHEMA_FIELDS: FieldTable = { + $anchor: DROP, + $comment: DROP, + $defs: DROP, + $dynamicAnchor: DROP, + $dynamicRef: DROP, + $id: DROP, + // A string $ref is re-attached by finishSchema; malformed values pass + // through unchanged. + $ref: (item) => (typeof item === "string" ? DROP : deepClone(item)), + $schema: DROP, + $vocabulary: DROP, + // With `patternProperties` dropped, `additionalProperties` would also + // constrain the previously pattern-matched keys, so it is dropped alongside + // (removing a constraint is the safe direction). Booleans are valid in 3.0. + additionalProperties: (item, schema) => { + if ("patternProperties" in schema) { + return DROP; + } + return typeof item === "boolean" ? item : convertSchema(item); + }, + allOf: convertSubschemas, + anyOf: convertSubschemas, + const: DROP, + contains: DROP, + contentEncoding: DROP, + contentMediaType: DROP, + contentSchema: DROP, + dependentRequired: DROP, + dependentSchemas: DROP, + else: DROP, + // 3.0 requires at least one enum entry; an empty enum only constrains, so + // removing it is the safe direction. + enum: (item) => + Array.isArray(item) && item.length === 0 ? DROP : deepClone(item), + examples: DROP, + // Numeric bounds are rewritten by finishSchema; 3.0-style booleans (invalid + // in 3.1, but accepted gracefully) pass through. + exclusiveMaximum: (item) => + typeof item === "number" ? DROP : deepClone(item), + exclusiveMinimum: (item) => + typeof item === "number" ? DROP : deepClone(item), + if: DROP, + // With `prefixItems` dropped, a trailing `items` would wrongly constrain + // every item, so it is dropped alongside. + items: (item, schema) => + "prefixItems" in schema ? DROP : convertSchema(item), + maxContains: DROP, + minContains: DROP, + not: convertSchema, + oneOf: convertSubschemas, + patternProperties: DROP, + prefixItems: DROP, + properties: (item) => mapRecord(item, convertSchema), + propertyNames: DROP, + // 3.0 requires the array to be non-empty with unique entries. + required: (item) => { + if (!Array.isArray(item)) { + return deepClone(item); + } + const unique = [...new Set(item)]; + return unique.length === 0 ? DROP : deepClone(unique); + }, + // oxlint-disable-next-line unicorn/no-thenable -- the JSON Schema `then` keyword; the table is never awaited + then: DROP, + type: DROP, + unevaluatedItems: DROP, + unevaluatedProperties: DROP, + xml: (item, schema) => convertXml(item, schema.type), +}; + +/** + * Converts an OpenAPI 3.1 Schema Object to its OpenAPI 3.0 form. A schema + * consisting solely of `$ref` becomes a 3.0 Reference Object; a `$ref` with + * sibling keywords is wrapped in `allOf`. See the module documentation for + * the full keyword mapping. + */ +export const downgradeSchemaV31ToV30 = ( + schema: OpenAPIV3_1.SchemaObject +): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject => { + const converted: unknown = convertSchema(schema); + // SAFETY: convertSchema rewrites every 3.1-only keyword into its 3.0 form. + return converted as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject; +}; + +const SECURITY_SCHEMES_REF_PREFIX = "#/components/securitySchemes/"; + +interface SecuritySchemeIndex { + /** Names of `mutualTLS` schemes, which 3.0 cannot represent at all. */ + mutualTls: Set; + /** Scheme name to declared `type`, for every recognizable scheme. */ + types: Map; +} + +/** + * Resolves the declared `type` of a security scheme, following local + * reference aliases with cycle protection. + */ +const resolveSchemeType = ( + name: string, + schemes: UnknownRecord, + seen: Set +): string | undefined => { + if (seen.has(name) || !Object.hasOwn(schemes, name)) { + return undefined; + } + const scheme = schemes[name]; + if (!isRecord(scheme)) { + return undefined; + } + if (typeof scheme.type === "string") { + return scheme.type; + } + const ref = getRef(scheme); + if (ref !== undefined && ref.startsWith(SECURITY_SCHEMES_REF_PREFIX)) { + const target = ref.slice(SECURITY_SCHEMES_REF_PREFIX.length); + if (target !== "" && !target.includes("/")) { + seen.add(name); + return resolveSchemeType(target, schemes, seen); + } + } + return undefined; +}; + +const indexSecuritySchemes = (spec: unknown): SecuritySchemeIndex => { + const index: SecuritySchemeIndex = { mutualTls: new Set(), types: new Map() }; + const components = isRecord(spec) ? spec.components : undefined; + const schemes = isRecord(components) ? components.securitySchemes : undefined; + if (!isRecord(schemes)) { + return index; + } + for (const name of Object.keys(schemes)) { + const type = resolveSchemeType(name, schemes, new Set()); + if (type !== undefined) { + index.types.set(name, type); + if (type === "mutualTLS") { + // Reference aliases of mutualTLS schemes are removed as well, so no + // dangling references survive. + index.mutualTls.add(name); + } + } + } + return index; +}; + +const convertRequirement = ( + value: unknown, + index: SecuritySchemeIndex +): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const entries = Object.entries(value); + const kept = entries.filter(([name]) => !index.mutualTls.has(name)); + if (kept.length === 0 && entries.length > 0) { + // A requirement that only referenced mutualTLS schemes disappears; an + // originally empty `{}` (optional security) is kept. + return DROP; + } + return Object.fromEntries( + kept.map(([name, scopes]) => { + // 3.0 allows roles only on OAuth-family schemes; roles on unknown + // schemes are left alone. + const type = index.types.get(name); + const scoped = + type === undefined || type === "oauth2" || type === "openIdConnect"; + return [name, Array.isArray(scopes) && !scoped ? [] : deepClone(scopes)]; + }) + ); +}; + +/** + * Converts a `security` list. When mutualTLS removal empties a previously + * non-empty list, the whole field is dropped: an explicit empty `security` + * array means "no security required" and, on an operation, would override + * the root declaration and silently make the operation public. + */ +const convertSecurity = ( + value: unknown, + index: SecuritySchemeIndex +): unknown => { + if (!Array.isArray(value)) { + return deepClone(value); + } + const out = value + .map((item) => convertRequirement(item, index)) + .filter((item) => item !== DROP); + return value.length > 0 && out.length === 0 ? DROP : out; +}; + +const convertInfo = (value: unknown): unknown => + convertRecord(value, { + // No SPDX `identifier` and no `summary` in 3.0. + license: (item) => convertRecord(item, { identifier: DROP }), + summary: DROP, + }); + +/** Parameter Objects and Header Objects share every field this converter touches. */ +const convertParameterOrHeader = (value: unknown): unknown => + convertRecord( + value, + { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertMediaType via encoding headers + content: (item) => mapRecord(item, convertMediaType), + examples: refMap(deepClone), + schema: convertSchema, + }, + (out, parameter) => { + if (parameter.in === "path") { + // 3.0 requires `required: true` on every path parameter; 3.1 only + // structurally enforces it for schema-based ones. + out.required = true; + } + return out; + } + ); + +const convertEncoding = (value: unknown): unknown => + convertRecord(value, { headers: refMap(convertParameterOrHeader) }); + +const convertMediaType = (value: unknown): unknown => + convertRecord(value, { + encoding: (item) => mapRecord(item, convertEncoding), + examples: refMap(deepClone), + schema: convertSchema, + }); + +const convertContent: FieldConverter = (item) => + mapRecord(item, convertMediaType); + +const convertRequestBody = (value: unknown): unknown => + convertRecord(value, { content: convertContent }); + +const convertResponse = (value: unknown): unknown => + convertRecord(value, { + content: convertContent, + headers: refMap(convertParameterOrHeader), + links: refMap(deepClone), + }); + +const convertResponses: FieldConverter = (item) => + mapRecord(item, (entry, key) => + key.startsWith("x-") + ? deepClone(entry) + : convertRefOr(entry, convertResponse) + ); + +const convertOperation = ( + value: unknown, + index: SecuritySchemeIndex +): unknown => + convertRecord( + value, + { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertCallback, as callbacks contain path items + callbacks: refMap((item) => convertCallback(item, index)), + parameters: refList(convertParameterOrHeader), + requestBody: (item) => convertRefOr(item, convertRequestBody), + responses: convertResponses, + security: (item) => convertSecurity(item, index), + }, + (out) => { + if (out.responses === undefined) { + // Required and non-empty in 3.0, optional in 3.1: a minimal default + // response keeps the output valid against the official 3.0 schema. + out.responses = { default: { description: "" } }; + } + return out; + } + ); + +const convertCallback = (value: unknown, index: SecuritySchemeIndex): unknown => + mapRecord(value, (item, key) => + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertPathItem, as path items contain callbacks + key.startsWith("x-") ? deepClone(item) : convertPathItem(item, index) + ); + +const convertPathItem = (value: unknown, index: SecuritySchemeIndex): unknown => + convertRecord(value, { + ...operationFields((item) => convertOperation(item, index)), + parameters: refList(convertParameterOrHeader), + }); + +const convertPaths = (value: unknown, index: SecuritySchemeIndex): unknown => + mapRecord(value, (item, key) => + key.startsWith("/") ? convertPathItem(item, index) : deepClone(item) + ); + +const convertComponents = ( + value: unknown, + index: SecuritySchemeIndex +): unknown => + convertRecord(value, { + callbacks: refMap((item) => convertCallback(item, index)), + examples: refMap(deepClone), + headers: refMap(convertParameterOrHeader), + links: refMap(deepClone), + parameters: refMap(convertParameterOrHeader), + // 3.0 has no reusable path items. + pathItems: DROP, + requestBodies: refMap(convertRequestBody), + responses: refMap(convertResponse), + schemas: (item) => mapRecord(item, convertSchema), + securitySchemes: (item) => + mapRecord(item, (scheme, name) => + index.mutualTls.has(name) ? DROP : convertRefOr(scheme, deepClone) + ), + }); + +const convertSpec = (spec: unknown): unknown => { + const index = indexSecuritySchemes(spec); + return convertRecord( + spec, + { + components: (item) => convertComponents(item, index), + info: convertInfo, + // 3.0 has neither schema-dialect selection nor webhooks. + jsonSchemaDialect: DROP, + paths: (item) => convertPaths(item, index), + security: (item) => convertSecurity(item, index), + webhooks: DROP, + }, + (out) => { + out.openapi = "3.0.4"; + if (out.paths === undefined) { + // Required in 3.0; an empty Paths Object is valid. + out.paths = {}; + } + return out; + } + ); +}; + +/** + * Converts an OpenAPI 3.1 document to OpenAPI 3.0.4. The input is never + * mutated, unknown keys and specification extensions are preserved, and + * malformed parts are copied through unchanged instead of throwing. + */ +export const downgradeSpecV31ToV30 = ( + spec: OpenAPIV3_1.OpenAPIObject +): OpenAPIV3_0.OpenAPIObject => { + const converted: unknown = convertSpec(spec); + // SAFETY: convertSpec rewrites every 3.1-only construct into its 3.0 form. + return converted as OpenAPIV3_0.OpenAPIObject; +}; diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts new file mode 100644 index 0000000..a34745e --- /dev/null +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -0,0 +1,1327 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- the helpers deliberately accept and return `unknown` so tests can feed malformed input to the graceful-degradation branches and inspect loosely-shaped output */ + +import type { OpenAPIV3_2 } from "@oasty/types"; + +import type { UnknownRecord } from "./shared"; +import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from "./v3.2-to-v3.1"; + +const asSpec = (value: unknown): OpenAPIV3_2.OpenAPIObject => + // SAFETY: tests deliberately feed malformed documents to exercise graceful handling. + value as OpenAPIV3_2.OpenAPIObject; + +const asSchema = (value: unknown): OpenAPIV3_2.SchemaObject => + // SAFETY: tests deliberately feed malformed schemas to exercise graceful handling. + value as OpenAPIV3_2.SchemaObject; + +const dig = (value: unknown, ...path: string[]): unknown => { + let current: unknown = value; + for (const key of path) { + // SAFETY: tests walk converter output whose shape the surrounding assertions pin down. + current = (current as UnknownRecord)[key]; + } + return current; +}; + +const convertSpec = (fields: UnknownRecord) => + downgradeSpecV32ToV31(asSpec({ openapi: "3.2.0", ...fields })); + +const convertPathItem = ( + pathItem: unknown, + components: UnknownRecord = {} +): unknown => + dig(convertSpec({ components, paths: { "/a": pathItem } }), "paths", "/a"); + +const convertComponent = ( + kind: string, + value: unknown, + components: UnknownRecord = {} +): unknown => + dig( + convertSpec({ components: { ...components, [kind]: { X: value } } }), + "components", + kind, + "X" + ); + +/** Converts a request body content map, optionally beside `components` to resolve against. */ +const convertContent = ( + content: unknown, + components: UnknownRecord = {} +): unknown => + dig( + convertPathItem( + { post: { requestBody: { content }, responses: {} } }, + components + ), + "post", + "requestBody", + "content" + ); + +describe("downgradeSpecV32ToV31", () => { + describe("document", () => { + it("rewrites the openapi field to 3.1.2", () => { + expect( + downgradeSpecV32ToV31({ + info: { title: "t", version: "1.0.0" }, + openapi: "3.2.0", + }) + ).toEqual({ + info: { title: "t", version: "1.0.0" }, + openapi: "3.1.2", + }); + }); + + it("adds openapi: 3.1.2 when the input has no openapi field", () => { + expect(downgradeSpecV32ToV31(asSpec({}))).toEqual({ openapi: "3.1.2" }); + }); + + it("removes $self", () => { + expect(convertSpec({ $self: "https://example.com/api.json" })).toEqual({ + openapi: "3.1.2", + }); + }); + + it("returns non-object input unchanged", () => { + expect(downgradeSpecV32ToV31(asSpec(null))).toBeNull(); + expect(downgradeSpecV32ToV31(asSpec("junk"))).toBe("junk"); + expect(downgradeSpecV32ToV31(asSpec([1, 2]))).toEqual([1, 2]); + }); + + it("preserves x- keys and unknown keys at the document, path item, and operation levels", () => { + const fields = { + futureKey: { anything: [1] }, + info: { title: "t", version: "1" }, + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + paths: { + "/a": { + get: { + operationId: "getA", + responses: {}, + unknownOperationKey: 1, + "x-op": true, + }, + unknownPathItemKey: "kept", + "x-item": [1, 2], + }, + }, + security: [{ oauth: ["read"] }], + "x-root": { deep: { value: 1 } }, + }; + expect(convertSpec(fields)).toEqual({ ...fields, openapi: "3.1.2" }); + }); + }); + + describe("servers", () => { + it("removes server name at the root, path item, operation, and link levels", () => { + const result = convertSpec({ + components: { + links: { L: { operationId: "op", server: { name: "s", url: "/u" } } }, + }, + paths: { + "/a": { + get: { responses: {}, servers: [{ name: "s", url: "/u" }] }, + servers: [{ name: "s", url: "/u" }], + }, + }, + servers: [ + { description: "d", name: "prod", url: "https://example.com" }, + ], + }); + expect(result).toEqual({ + components: { + links: { L: { operationId: "op", server: { url: "/u" } } }, + }, + openapi: "3.1.2", + paths: { + "/a": { + get: { responses: {}, servers: [{ url: "/u" }] }, + servers: [{ url: "/u" }], + }, + }, + servers: [{ description: "d", url: "https://example.com" }], + }); + }); + + it("clones non-array servers and non-object server entries through", () => { + expect( + convertSpec({ + paths: { "/a": { servers: "junk" } }, + servers: [5, null], + }) + ).toEqual({ + openapi: "3.1.2", + paths: { "/a": { servers: "junk" } }, + servers: [5, null], + }); + }); + }); + + describe("tags", () => { + it("removes tag summary, parent, and kind and keeps other fields", () => { + expect( + convertSpec({ + tags: [ + { + description: "d", + externalDocs: { url: "https://example.com" }, + kind: "nav", + name: "pets", + parent: "animals", + summary: "Pets", + }, + "junk", + 1, + ], + }).tags + ).toEqual([ + { + description: "d", + externalDocs: { url: "https://example.com" }, + name: "pets", + }, + "junk", + 1, + ]); + }); + }); + + describe("paths and path items", () => { + it("removes the query operation and additionalOperations whatever their shape", () => { + expect( + convertSpec({ + paths: { + "/a": { + get: { responses: {} }, + query: { description: "q", responses: {} }, + }, + "/b": { additionalOperations: { NOTIFY: { description: "n" } } }, + "/c": { additionalOperations: "junk" }, + "/d": { additionalOperations: 42, query: "junk" }, + }, + }).paths + ).toEqual({ + "/a": { get: { responses: {} } }, + "/b": {}, + "/c": {}, + "/d": {}, + }); + }); + + it("converts only keys starting with a slash and clones the rest", () => { + expect( + convertSpec({ + paths: { + "/a": { query: { description: "dropped" } }, + "x-meta": { query: { description: "kept" } }, + }, + }).paths + ).toEqual({ "/a": {}, "x-meta": { query: { description: "kept" } } }); + }); + + it("clones malformed paths, path items, and nested objects through", () => { + expect(convertSpec({ paths: "junk" }).paths).toBe("junk"); + const paths = { + "/a": { + get: "junk", + post: { + requestBody: { + content: { + "application/json": 42, + "multipart/form-data": { + encoding: { field: "junk" }, + example: 5, + }, + }, + }, + }, + put: { requestBody: 42, responses: { "200": 42 } }, + }, + }; + expect(convertSpec({ paths }).paths).toEqual(paths); + }); + }); + + describe("parameters", () => { + it("removes querystring parameters from operation and path item lists, keeping neighbors and references", () => { + expect( + convertPathItem({ + get: { + parameters: [ + { + content: { "application/x-www-form-urlencoded": {} }, + in: "querystring", + name: "q", + }, + { in: "query", name: "keep" }, + { $ref: "#/components/parameters/P" }, + ], + responses: {}, + }, + parameters: [ + { in: "querystring", name: "q" }, + { in: "path", name: "id", required: true }, + ], + }) + ).toEqual({ + get: { + parameters: [ + { in: "query", name: "keep" }, + { $ref: "#/components/parameters/P" }, + ], + responses: {}, + }, + parameters: [{ in: "path", name: "id", required: true }], + }); + }); + + it("removes querystring entries from components.parameters, keeping neighbors and reference entries", () => { + expect( + convertSpec({ + components: { + parameters: { + N: { in: "header", name: "h" }, + Q: { in: "querystring", name: "q" }, + R: { $ref: "#/components/parameters/N" }, + }, + }, + }).components + ).toEqual({ + parameters: { + N: { in: "header", name: "h" }, + R: { $ref: "#/components/parameters/N" }, + }, + }); + }); + + it("removes references to removed querystring parameters, following alias chains", () => { + const result = convertSpec({ + components: { + parameters: { + Alias: { $ref: "#/components/parameters/Qs" }, + AliasOfAlias: { $ref: "#/components/parameters/Alias" }, + Keep: { in: "query", name: "k", schema: {} }, + Qs: { + content: { "application/x-www-form-urlencoded": { schema: {} } }, + in: "querystring", + name: "filter", + }, + }, + }, + paths: { + "/a": { + get: { + parameters: [ + { $ref: "#/components/parameters/AliasOfAlias" }, + { $ref: "#/components/parameters/Qs" }, + { $ref: "#/components/parameters/Keep" }, + ], + responses: {}, + }, + parameters: [{ $ref: "#/components/parameters/Qs" }], + }, + }, + }); + expect(result.components).toEqual({ + parameters: { Keep: { in: "query", name: "k", schema: {} } }, + }); + expect(result.paths).toEqual({ + "/a": { + get: { + parameters: [{ $ref: "#/components/parameters/Keep" }], + responses: {}, + }, + parameters: [], + }, + }); + }); + + it.each([ + [ + "removes style: cookie and keeps the other fields", + { in: "cookie", name: "c", style: "cookie" }, + { in: "cookie", name: "c" }, + ], + [ + "keeps other style values", + { in: "query", name: "q", style: "deepObject" }, + { in: "query", name: "q", style: "deepObject" }, + ], + [ + "keeps allowReserved on query parameters", + { allowReserved: true, in: "query", name: "q", schema: {} }, + { allowReserved: true, in: "query", name: "q", schema: {} }, + ], + [ + "removes allowReserved on path parameters", + { + allowReserved: true, + in: "path", + name: "id", + required: true, + schema: {}, + }, + { in: "path", name: "id", required: true, schema: {} }, + ], + [ + "removes allowReserved on cookie parameters", + { allowReserved: true, in: "cookie", name: "c", schema: {} }, + { in: "cookie", name: "c", schema: {} }, + ], + [ + "keeps allowReserved on objects without an in field", + { allowReserved: true, schema: {} }, + { allowReserved: true, schema: {} }, + ], + [ + "keeps parameter schemas verbatim and converts example maps", + { + examples: { + inline: { dataValue: 1 }, + referenced: { $ref: "#/components/examples/E" }, + }, + in: "query", + name: "q", + schema: { type: "string", xml: { nodeType: "attribute" } }, + }, + { + examples: { + inline: { value: 1 }, + referenced: { $ref: "#/components/examples/E" }, + }, + in: "query", + name: "q", + schema: { type: "string", xml: { nodeType: "attribute" } }, + }, + ], + ])("%s", (_name, input, expected) => { + expect(convertComponent("parameters", input)).toEqual(expected); + }); + + it("clones non-object parameter entries, non-array lists, and a malformed components map through", () => { + expect(convertPathItem({ parameters: [null, "junk"] })).toEqual({ + parameters: [null, "junk"], + }); + expect(convertPathItem({ parameters: "junk" })).toEqual({ + parameters: "junk", + }); + expect( + convertSpec({ components: { parameters: "junk" } }).components + ).toEqual({ + parameters: "junk", + }); + }); + }); + + describe("components.mediaTypes inlining", () => { + it("inlines a media type reference with the converted media type", () => { + expect( + convertContent( + { "application/jsonl": { $ref: "#/components/mediaTypes/Stream" } }, + { mediaTypes: { Stream: { itemSchema: { type: "object" } } } } + ) + ).toEqual({ + "application/jsonl": { + schema: { items: { type: "object" }, type: "array" }, + }, + }); + }); + + it("inlines media type references in response, parameter, and header content maps", () => { + const reference = { + "application/json": { $ref: "#/components/mediaTypes/Json" }, + }; + const inlined = { "application/json": { schema: { type: "string" } } }; + expect( + convertPathItem( + { + get: { + parameters: [{ content: reference, in: "query", name: "q" }], + responses: { + "200": { + content: reference, + description: "ok", + headers: { "X-H": { content: reference } }, + }, + }, + }, + }, + { mediaTypes: { Json: { schema: { type: "string" } } } } + ) + ).toEqual({ + get: { + parameters: [{ content: inlined, in: "query", name: "q" }], + responses: { + "200": { + content: inlined, + description: "ok", + headers: { "X-H": { content: inlined } }, + }, + }, + }, + }); + }); + + it("resolves chained media type references down to the final object", () => { + expect( + convertContent( + { "application/json": { $ref: "#/components/mediaTypes/A" } }, + { + mediaTypes: { + A: { $ref: "#/components/mediaTypes/B" }, + B: { schema: { type: "number" } }, + }, + } + ) + ).toEqual({ "application/json": { schema: { type: "number" } } }); + }); + + it("resolves long acyclic reference chains", () => { + const links = Array.from({ length: 40 }, (_unused, index) => [ + `m${index}`, + { $ref: `#/components/mediaTypes/m${index + 1}` }, + ]); + const mediaTypes = Object.fromEntries([ + ...links, + ["m40", { schema: { type: "string" } }], + ]); + expect( + convertContent( + { "application/json": { $ref: "#/components/mediaTypes/m0" } }, + { mediaTypes } + ) + ).toEqual({ "application/json": { schema: { type: "string" } } }); + }); + + it("removes content entries whose reference chain is cyclic", () => { + expect( + convertContent( + { + "application/json": { $ref: "#/components/mediaTypes/Loop" }, + "application/xml": { $ref: "#/components/mediaTypes/Ping" }, + }, + { + mediaTypes: { + Loop: { $ref: "#/components/mediaTypes/Loop" }, + Ping: { $ref: "#/components/mediaTypes/Pong" }, + Pong: { $ref: "#/components/mediaTypes/Ping" }, + }, + } + ) + ).toEqual({}); + }); + + it("removes content entries with external, unknown, and unparseable references", () => { + expect( + convertContent( + { + "a/1": { $ref: "#/components/schemas/Foo" }, + "a/2": { $ref: "#/components/mediaTypes/nested/name" }, + "a/3": { $ref: "#/components/mediaTypes/" }, + "a/4": { $ref: "https://example.com/other.json#/mediaTypes/A" }, + "a/5": { $ref: "#/components/mediaTypes/Unknown" }, + "a/6": { $ref: "#/components/mediaTypes/Known" }, + }, + { mediaTypes: { Known: { example: 1 } } } + ) + ).toEqual({ "a/6": { example: 1 } }); + }); + + it("does not resolve names through the prototype chain of the mediaTypes map", () => { + expect( + convertContent( + { + "application/json": { + $ref: "#/components/mediaTypes/hasOwnProperty", + }, + }, + { mediaTypes: {} } + ) + ).toEqual({}); + }); + + it("removes media type references when components.mediaTypes is missing or malformed", () => { + const content = { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }; + expect( + convertSpec({ + paths: { + "/a": { post: { requestBody: { content }, responses: {} } }, + }, + }) + ).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { post: { requestBody: { content: {} }, responses: {} } }, + }, + }); + expect(convertContent(content, { mediaTypes: "junk" })).toEqual({}); + }); + + it("removes the mediaTypes map from components", () => { + expect( + convertSpec({ + components: { + mediaTypes: { Json: { schema: {} } }, + schemas: { S: { type: "string" } }, + }, + }).components + ).toEqual({ schemas: { S: { type: "string" } } }); + }); + + it("clones a non-object content value through", () => { + expect(convertContent("junk")).toBe("junk"); + }); + + describe("parameters and headers losing their entire content", () => { + const missing = { + "application/json": { $ref: "#/components/mediaTypes/Missing" }, + }; + + it("removes a parameter whose only content entry could not be inlined", () => { + expect( + convertPathItem({ + get: { + parameters: [ + { content: missing, in: "query", name: "q" }, + { in: "query", name: "keep", schema: {} }, + ], + responses: {}, + }, + }) + ).toEqual({ + get: { + parameters: [{ in: "query", name: "keep", schema: {} }], + responses: {}, + }, + }); + }); + + it("keeps a parameter when part of its content could be inlined", () => { + expect( + convertPathItem( + { + get: { + parameters: [ + { + content: { + ...missing, + "application/xml": { + $ref: "#/components/mediaTypes/Known", + }, + }, + in: "query", + name: "q", + }, + ], + responses: {}, + }, + }, + { mediaTypes: { Known: { example: 1 } } } + ) + ).toEqual({ + get: { + parameters: [ + { + content: { "application/xml": { example: 1 } }, + in: "query", + name: "q", + }, + ], + responses: {}, + }, + }); + }); + + it("removes headers and component parameters whose entire content could not be inlined", () => { + const result = convertSpec({ + components: { + headers: { Broken: { content: missing }, Keep: { schema: {} } }, + parameters: { + Broken: { content: missing, in: "query", name: "q" }, + }, + }, + paths: { + "/a": { + get: { + responses: { + "200": { + description: "ok", + headers: { + "X-Broken": { content: missing }, + "X-Keep": { schema: {} }, + }, + }, + }, + }, + }, + }, + }); + expect(result.components).toEqual({ + headers: { Keep: { schema: {} } }, + parameters: {}, + }); + expect(result.paths).toEqual({ + "/a": { + get: { + responses: { + "200": { + description: "ok", + headers: { "X-Keep": { schema: {} } }, + }, + }, + }, + }, + }); + }); + + it("removes references to removed parameters and headers, following alias chains", () => { + const result = convertSpec({ + components: { + headers: { + Broken: { + content: { + "text/plain": { $ref: "#/components/mediaTypes/Loop" }, + }, + }, + BrokenAlias: { $ref: "#/components/headers/Broken" }, + }, + mediaTypes: { Loop: { $ref: "#/components/mediaTypes/Loop" } }, + parameters: { + Broken: { content: missing, in: "query", name: "q" }, + BrokenAlias: { $ref: "#/components/parameters/Broken" }, + }, + }, + paths: { + "/a": { + get: { + parameters: [ + { $ref: "#/components/parameters/Broken" }, + { $ref: "#/components/parameters/BrokenAlias" }, + ], + responses: { + "200": { + description: "ok", + headers: { + "X-Broken": { $ref: "#/components/headers/Broken" }, + "X-BrokenAlias": { + $ref: "#/components/headers/BrokenAlias", + }, + }, + }, + }, + }, + }, + }, + }); + expect(result.components).toEqual({ headers: {}, parameters: {} }); + expect(result.paths).toEqual({ + "/a": { + get: { + parameters: [], + responses: { "200": { description: "ok", headers: {} } }, + }, + }, + }); + }); + }); + }); + + describe("media types", () => { + it("turns itemSchema into a deep-cloned array schema when no schema exists", () => { + const itemSchema = { type: "object", xml: { nodeType: "text" } }; + const result = convertContent({ "application/jsonl": { itemSchema } }); + expect(result).toEqual({ + "application/jsonl": { + schema: { + items: { type: "object", xml: { nodeType: "text" } }, + type: "array", + }, + }, + }); + const promoted = dig(result, "application/jsonl", "schema", "items"); + expect(promoted).not.toBe(itemSchema); + expect(dig(promoted, "xml")).not.toBe(itemSchema.xml); + }); + + it.each([ + [ + "removes itemSchema when a schema already exists", + { itemSchema: { type: "string" }, schema: { type: "array" } }, + { schema: { type: "array" } }, + ], + [ + "removes prefixEncoding and itemEncoding", + { + example: 1, + itemEncoding: { contentType: "text/plain" }, + prefixEncoding: [{ contentType: "application/json" }], + }, + { example: 1 }, + ], + [ + "removes the 3.2-only description and keeps other fields", + { + description: "a JSON payload", + example: 5, + schema: { type: "integer" }, + }, + { example: 5, schema: { type: "integer" } }, + ], + [ + "converts example maps", + { + examples: { + inline: { serializedValue: "raw" }, + referenced: { $ref: "#/components/examples/E" }, + }, + }, + { + examples: { + inline: { value: "raw" }, + referenced: { $ref: "#/components/examples/E" }, + }, + }, + ], + ])("%s", (_name, mediaType, expected) => { + expect(convertContent({ "application/json": mediaType })).toEqual({ + "application/json": expected, + }); + }); + + it("removes nested and positional encoding inside encoding objects while still converting headers", () => { + expect( + convertContent({ + "multipart/form-data": { + encoding: { + part: { + contentType: "application/json", + encoding: { + inner: { headers: { "X-C": { style: "cookie" } } }, + }, + headers: { + Referenced: { $ref: "#/components/headers/H" }, + "X-H": { description: "h", style: "cookie" }, + }, + itemEncoding: { contentType: "text/plain" }, + prefixEncoding: [{ contentType: "text/csv" }], + }, + }, + }, + }) + ).toEqual({ + "multipart/form-data": { + encoding: { + part: { + contentType: "application/json", + headers: { + Referenced: { $ref: "#/components/headers/H" }, + "X-H": { description: "h" }, + }, + }, + }, + }, + }); + }); + }); + + describe("responses", () => { + it.each([ + [ + "uses summary as the description when none exists", + { summary: "ok" }, + { description: "ok" }, + ], + [ + "removes summary when a description exists", + { description: "d", summary: "s" }, + { description: "d" }, + ], + [ + "synthesizes an empty description when neither summary nor description exist", + {}, + { description: "" }, + ], + [ + "clones a non-object headers value through", + { description: "ok", headers: "junk" }, + { description: "ok", headers: "junk" }, + ], + [ + "leaves response reference objects untouched, including summary and description overrides", + { + $ref: "#/components/responses/R", + description: "override", + summary: "kept", + }, + { + $ref: "#/components/responses/R", + description: "override", + summary: "kept", + }, + ], + ])("%s", (_name, response, expected) => { + expect(convertComponent("responses", response)).toEqual(expected); + }); + + it("clones x- keys of the responses map without response conversion", () => { + expect( + convertPathItem({ + get: { + responses: { + "200": { summary: "ok" }, + "x-note": { summary: "not a response" }, + }, + }, + }) + ).toEqual({ + get: { + responses: { + "200": { description: "ok" }, + "x-note": { summary: "not a response" }, + }, + }, + }); + }); + + it("converts response headers, content, and links", () => { + expect( + convertComponent("responses", { + content: { "application/json": { itemSchema: { type: "string" } } }, + description: "ok", + headers: { "X-H": { style: "cookie" } }, + links: { + inline: { server: { name: "s", url: "/u" } }, + referenced: { $ref: "#/components/links/L" }, + }, + }) + ).toEqual({ + content: { + "application/json": { + schema: { items: { type: "string" }, type: "array" }, + }, + }, + description: "ok", + headers: { "X-H": {} }, + links: { + inline: { server: { url: "/u" } }, + referenced: { $ref: "#/components/links/L" }, + }, + }); + }); + }); + + describe("examples", () => { + it.each([ + [ + "moves dataValue into the free value slot", + { dataValue: { a: 1 } }, + { value: { a: 1 } }, + ], + [ + "moves serializedValue into the free value slot", + { serializedValue: "a=1" }, + { value: "a=1" }, + ], + [ + "removes dataValue when value already exists", + { dataValue: 1, value: 2 }, + { value: 2 }, + ], + [ + "removes serializedValue when value already exists", + { serializedValue: "s", value: 2 }, + { value: 2 }, + ], + [ + "removes dataValue and serializedValue when externalValue exists", + { + dataValue: 1, + externalValue: "https://example.com/e.json", + serializedValue: "s", + }, + { externalValue: "https://example.com/e.json" }, + ], + [ + "lets dataValue win the value slot over serializedValue", + { dataValue: 1, serializedValue: "s" }, + { value: 1 }, + ], + [ + "keeps other example fields untouched", + { dataValue: 1, description: "d", summary: "s" }, + { description: "d", summary: "s", value: 1 }, + ], + [ + "leaves an example without any value fields unchanged", + { summary: "s" }, + { summary: "s" }, + ], + ["clones a malformed example through", 42, 42], + ])("%s", (_name, example, expected) => { + expect(convertComponent("examples", example)).toEqual(expected); + }); + }); + + describe("security schemes", () => { + const flow = { + authorizationUrl: "https://example.com/auth", + scopes: {}, + tokenUrl: "https://example.com/token", + }; + + it.each([ + [ + "removes deprecated: true", + { deprecated: true, type: "http" }, + { type: "http" }, + ], + [ + "removes deprecated: false", + { deprecated: false, type: "http" }, + { type: "http" }, + ], + [ + "removes a malformed deprecated", + { deprecated: "yes", type: "http" }, + { type: "http" }, + ], + [ + "removes oauth2MetadataUrl", + { oauth2MetadataUrl: "https://example.com/meta", type: "oauth2" }, + { type: "oauth2" }, + ], + [ + "removes a malformed oauth2MetadataUrl", + { oauth2MetadataUrl: 42, type: "oauth2" }, + { type: "oauth2" }, + ], + [ + "removes the deviceAuthorization flow and keeps other flows", + { + flows: { + authorizationCode: flow, + deviceAuthorization: { + deviceAuthorizationUrl: "https://example.com/device", + scopes: {}, + tokenUrl: flow.tokenUrl, + }, + }, + type: "oauth2", + }, + { flows: { authorizationCode: flow }, type: "oauth2" }, + ], + [ + "removes a malformed deviceAuthorization", + { flows: { deviceAuthorization: "junk" }, type: "oauth2" }, + { flows: {}, type: "oauth2" }, + ], + [ + "clones malformed flows through", + { flows: "junk", type: "oauth2" }, + { flows: "junk", type: "oauth2" }, + ], + [ + "clones references through", + { $ref: "#/components/securitySchemes/Other" }, + { $ref: "#/components/securitySchemes/Other" }, + ], + ["passes a non-object security scheme through", "junk", "junk"], + ])("%s", (_name, scheme, expected) => { + expect(convertComponent("securitySchemes", scheme)).toEqual(expected); + }); + }); + + describe("webhooks and components.pathItems", () => { + it("converts webhook path items and removes their query operation", () => { + expect( + convertSpec({ + webhooks: { + newPet: { + post: { responses: { "200": { summary: "ok" } } }, + query: { description: "q" }, + }, + }, + }).webhooks + ).toEqual({ + newPet: { post: { responses: { "200": { description: "ok" } } } }, + }); + }); + + it("converts components.pathItems path items and removes their query operation", () => { + expect( + convertComponent("pathItems", { + get: { responses: {} }, + query: { description: "q" }, + }) + ).toEqual({ get: { responses: {} } }); + }); + }); + + describe("callbacks", () => { + it("converts path items in operation-level callbacks and clones x- keys", () => { + expect( + convertPathItem({ + post: { + callbacks: { + onEvent: { + "x-note": { query: { description: "kept" } }, + "{$request.body#/url}": { + post: { responses: { "200": { summary: "ok" } } }, + query: { description: "q" }, + }, + }, + referenced: { $ref: "#/components/callbacks/C" }, + }, + responses: {}, + }, + }) + ).toEqual({ + post: { + callbacks: { + onEvent: { + "x-note": { query: { description: "kept" } }, + "{$request.body#/url}": { + post: { responses: { "200": { description: "ok" } } }, + }, + }, + referenced: { $ref: "#/components/callbacks/C" }, + }, + responses: {}, + }, + }); + }); + + it("converts components.callbacks, handling both references and inline callbacks", () => { + expect( + convertSpec({ + components: { + callbacks: { + inline: { + "https://example.com/cb": { + post: { responses: { "200": { summary: "ok" } } }, + query: { description: "q" }, + }, + }, + referenced: { $ref: "#/components/callbacks/inline" }, + }, + }, + }).components + ).toEqual({ + callbacks: { + inline: { + "https://example.com/cb": { + post: { responses: { "200": { description: "ok" } } }, + }, + }, + referenced: { $ref: "#/components/callbacks/inline" }, + }, + }); + }); + }); + + describe("components", () => { + it("handles references and inline objects across component maps", () => { + expect( + convertSpec({ + components: { + examples: { + E: { dataValue: 1 }, + ERef: { $ref: "#/components/examples/E" }, + }, + headers: { + H: { style: "cookie" }, + HRef: { $ref: "#/components/headers/H" }, + }, + links: { junkLink: 42 }, + parameters: { + P: { in: "querystring", name: "q" }, + PRef: { $ref: "#/components/parameters/P" }, + }, + requestBodies: { + B: { + content: { + "application/json": { itemSchema: { type: "string" } }, + }, + }, + BRef: { $ref: "#/components/requestBodies/B" }, + }, + responses: { + R: { summary: "ok" }, + RRef: { $ref: "#/components/responses/R" }, + }, + }, + }).components + ).toEqual({ + examples: { + E: { value: 1 }, + ERef: { $ref: "#/components/examples/E" }, + }, + headers: { H: {}, HRef: { $ref: "#/components/headers/H" } }, + links: { junkLink: 42 }, + parameters: {}, + requestBodies: { + B: { + content: { + "application/json": { + schema: { items: { type: "string" }, type: "array" }, + }, + }, + }, + BRef: { $ref: "#/components/requestBodies/B" }, + }, + responses: { + R: { description: "ok" }, + RRef: { $ref: "#/components/responses/R" }, + }, + }); + }); + + it("clones components.schemas entries unchanged, keeping 3.2 OAS vocabulary fields", () => { + const schema = { + discriminator: { defaultMapping: "Dog", propertyName: "kind" }, + xml: { nodeType: "attribute" }, + }; + expect(convertComponent("schemas", schema)).toEqual(schema); + }); + + it("clones unknown component keys and passes non-object components through", () => { + expect( + convertSpec({ components: { custom: { anything: true } } }).components + ).toEqual({ + custom: { anything: true }, + }); + expect(convertSpec({ components: "junk" }).components).toBe("junk"); + }); + }); + + describe("robustness", () => { + it("never mutates the input document", () => { + const spec = asSpec({ + $self: "https://example.com/api.json", + components: { + examples: { E: { dataValue: 1, serializedValue: "s" } }, + mediaTypes: { + A: { $ref: "#/components/mediaTypes/B" }, + B: { itemSchema: { xml: { nodeType: "text" } } }, + }, + pathItems: { P: { query: { description: "q" } } }, + schemas: { S: { discriminator: { defaultMapping: "Dog" } } }, + securitySchemes: { O: { deprecated: true, type: "oauth2" } }, + }, + openapi: "3.2.0", + paths: { + "/a": { + additionalOperations: { NOTIFY: { description: "n" } }, + get: { + parameters: [{ in: "querystring", name: "q" }], + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, + }, + responses: { "200": { summary: "ok" } }, + }, + query: { description: "q" }, + servers: [{ name: "s", url: "/u" }], + }, + }, + servers: [{ name: "root", url: "https://example.com" }], + tags: [{ kind: "nav", name: "t", parent: "p", summary: "s" }], + webhooks: { hook: { query: { description: "wq" } } }, + }); + const before = structuredClone(spec); + downgradeSpecV32ToV31(spec); + expect(spec).toEqual(before); + }); + + it("converts a path item that cycles through its callbacks without throwing", () => { + const callback: UnknownRecord = {}; + const pathItem: UnknownRecord = { + get: { callbacks: { cb: callback }, responses: {} }, + }; + callback.expr = pathItem; + expect(() => convertPathItem(pathItem)).not.toThrow(); + }); + }); +}); + +describe("downgradeSchemaV32ToV31", () => { + it("deep-clones schemas, preserving discriminator defaultMapping and xml nodeType verbatim", () => { + const source = { + discriminator: { + defaultMapping: "Dog", + mapping: { dog: "#/components/schemas/Dog" }, + propertyName: "kind", + }, + properties: { a: { xml: { nodeType: "text" } } }, + type: "object", + xml: { nodeType: "attribute" }, + }; + const result = downgradeSchemaV32ToV31(asSchema(source)); + expect(result).toEqual(source); + expect(result).not.toBe(source); + expect(dig(result, "discriminator")).not.toBe(source.discriminator); + expect(dig(result, "properties")).not.toBe(source.properties); + expect(dig(result, "properties", "a")).not.toBe(source.properties.a); + expect(dig(result, "properties", "a", "xml")).not.toBe( + source.properties.a.xml + ); + expect(dig(result, "xml")).not.toBe(source.xml); + }); + + it("clones subschema containers at every level", () => { + const source = { + allOf: [{ discriminator: { defaultMapping: "Dog" } }, true], + items: { xml: { nodeType: "cdata" } }, + }; + const result = downgradeSchemaV32ToV31(asSchema(source)); + expect(result).toEqual(source); + 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("keeps unknown schema keywords, validation keywords, and extensions unchanged", () => { + const source = { + customKeyword: { nested: true }, + maximum: 5, + type: "number", + "x-note": "kept", + }; + expect(downgradeSchemaV32ToV31(asSchema(source))).toEqual(source); + }); + + it("passes boolean and junk schema input through", () => { + expect(downgradeSchemaV32ToV31(true)).toBe(true); + expect(downgradeSchemaV32ToV31(false)).toBe(false); + expect(downgradeSchemaV32ToV31(asSchema("junk"))).toBe("junk"); + expect(downgradeSchemaV32ToV31(asSchema(null))).toBeNull(); + expect( + downgradeSchemaV32ToV31(asSchema({ allOf: "junk", properties: 5 })) + ).toEqual({ + allOf: "junk", + properties: 5, + }); + }); + + it("never mutates the input schema", () => { + const schema: OpenAPIV3_2.SchemaObject = { + discriminator: { defaultMapping: "Dog", propertyName: "kind" }, + properties: { a: { xml: { nodeType: "attribute" } } }, + type: "object", + }; + const before = structuredClone(schema); + downgradeSchemaV32ToV31(schema); + expect(schema).toEqual(before); + }); + + it("converts deeply nested schemas without throwing", () => { + let deep = asSchema({ type: "string" }); + for (let index = 0; index < 1000; index += 1) { + deep = asSchema({ items: deep, type: "array" }); + } + expect(() => downgradeSchemaV32ToV31(deep)).not.toThrow(); + }); +}); diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts new file mode 100644 index 0000000..ba73b5b --- /dev/null +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -0,0 +1,477 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-known-value-widening, anti-slop/no-runtime-typeof -- this converter is the I/O boundary for untrusted OpenAPI documents: it walks arbitrary input defensively and passes malformed parts through unchanged, so `unknown` values and runtime type checks are the domain contract here */ + +/** + * Converts OpenAPI 3.2 documents and schemas to OpenAPI 3.1 (targeting the + * latest patch release, 3.1.2). + * + * The conversion never throws: parts that do not match the expected shape + * are deep-copied through unchanged, a subtree that cycles back into an + * ancestor object is deep-copied with its cycle preserved instead of + * converted, and existing specification extensions (`x-` keys) as well as + * unknown keys are always preserved. Constructs 3.1 cannot express are + * converted where an equivalent exists and removed otherwise — the converter + * never invents `x-` keys of its own: + * + * - Removed: `$self`, server `name`, tag `summary`/`parent`/`kind`, the + * `query` operation and `additionalOperations` of Path Items, + * `in: "querystring"` parameters (from parameter lists and + * `components.parameters`, following chains of reference aliases), + * `style: "cookie"` (the 3.1 default `form` applies), media type and + * encoding `prefixEncoding`/`itemEncoding` and nested `encoding`, OAuth + * `deviceAuthorization` flows, and security scheme `oauth2MetadataUrl` + * and `deprecated`. + * - Converted: reusable `components.mediaTypes` are inlined at their `$ref` + * use sites (3.1 content maps allow no references) and the map itself is + * removed; content entries whose reference cannot be inlined are removed, + * and a parameter or header losing its entire `content` that way is + * removed with it (3.1 requires exactly one entry there); media type + * `itemSchema` becomes `schema: { type: "array", items }` when no + * `schema` exists (the 3.2 sequential media type data model) and is + * removed otherwise; example `dataValue`/`serializedValue` fill a free + * `value` slot (in that order); response `summary` becomes the + * `description` when none exists (3.1 requires one, so `""` is + * synthesized as a last resort). + * - Schema Objects pass through unchanged: the 3.2 Schema Object keyword + * set is identical to 3.1's (3.2 defines its own dialect URI, but only + * the OAS base vocabulary gained fields), so the 3.2-only fields + * (discriminator `defaultMapping`, XML `nodeType`) are deliberately + * retained. The standard 3.1 document schema tolerates them, but the + * strict OAS base-vocabulary meta-schema closes the XML and + * Discriminator Objects to `x-` extras, and 3.1 tooling will not act on + * them; `nodeType` is recovered on the 3.1-to-3.0 hop. + * + * @see {@link https://spec.openapis.org/oas/v3.2.0.html} + * @see {@link https://spec.openapis.org/oas/v3.1.2.html} + */ + +import type { OpenAPIV3_1, OpenAPIV3_2 } from "@oasty/types"; + +import type { FieldConverter, UnknownRecord } from "./shared"; +import { + convertRecord, + deepClone, + DROP, + getRef, + isRecord, + mapArray, + mapRecord, + operationFields, +} from "./shared"; + +const HEADERS_REF_PREFIX = "#/components/headers/"; +const MEDIA_TYPES_REF_PREFIX = "#/components/mediaTypes/"; +const PARAMETERS_REF_PREFIX = "#/components/parameters/"; + +interface Context { + /** The raw `components.mediaTypes` map, used to inline references. */ + mediaTypes: UnknownRecord | undefined; + /** `$ref` strings of `components.headers` entries conversion removes. */ + removedHeaderRefs: ReadonlySet; + /** `$ref` strings of `components.parameters` entries conversion removes. */ + removedParameterRefs: ReadonlySet; +} + +/** + * Converts an OpenAPI 3.2 Schema Object to its OpenAPI 3.1 form: a deep + * clone. The 3.2 Schema Object keyword set is identical to 3.1's (3.2 + * defines its own dialect URI, but only the OAS base vocabulary gained + * fields), so the 3.2-only fields (discriminator `defaultMapping`, XML + * `nodeType`) are deliberately retained. The standard 3.1 document schema + * tolerates them; the strict OAS base-vocabulary meta-schema closes the + * XML and Discriminator Objects to `x-` extras, and 3.1 tooling will not + * act on them. + */ +export const downgradeSchemaV32ToV31 = ( + schema: OpenAPIV3_2.SchemaObject +): OpenAPIV3_1.SchemaObject => { + const converted: unknown = deepClone(schema); + // SAFETY: every 3.2 Schema Object is already a structurally valid 3.1 one. + return converted as OpenAPIV3_1.SchemaObject; +}; + +/** + * References stay references in 3.1 (including their `summary`/`description` + * overrides); everything else is converted. + */ +const convertRefOr = ( + value: unknown, + context: Context, + convert: (item: unknown, innerContext: Context) => unknown +): unknown => + getRef(value) === undefined ? convert(value, context) : deepClone(value); + +/** Field converter for a map of reference-or-object entries. */ +const refMap = + ( + context: Context, + convert: (item: unknown, innerContext: Context) => unknown + ): FieldConverter => + (item) => + mapRecord(item, (entry) => convertRefOr(entry, context, convert)); + +const convertServer = (value: unknown): unknown => + convertRecord(value, { name: DROP }); + +const convertTag = (value: unknown): unknown => + convertRecord(value, { kind: DROP, parent: DROP, summary: DROP }); + +const convertLink = (value: unknown): unknown => + convertRecord(value, { server: convertServer }); + +const convertSecurityScheme = (value: unknown): unknown => + convertRecord(value, { + // `deprecated`, `oauth2MetadataUrl`, and the device authorization flow + // are new in 3.2. + deprecated: DROP, + flows: (item) => convertRecord(item, { deviceAuthorization: DROP }), + oauth2MetadataUrl: DROP, + }); + +const convertExample = (value: unknown): unknown => + convertRecord( + value, + { dataValue: DROP, serializedValue: DROP }, + (out, example) => { + // 3.2's dataValue/serializedValue fill 3.1's `value` slot when it is + // free (and no externalValue competes); whatever cannot be promoted is + // removed. + if ("value" in example || "externalValue" in example) { + return out; + } + if ("dataValue" in example) { + out.value = deepClone(example.dataValue); + } else if ("serializedValue" in example) { + out.value = deepClone(example.serializedValue); + } + return out; + } + ); + +/** Whether a parameter uses the 3.2-only `querystring` location. */ +const isQuerystringParameter = (value: unknown): boolean => + isRecord(value) && value.in === "querystring"; + +/** Whether the value references a component entry conversion removes. */ +const isRemovedRef = ( + value: unknown, + removed: ReadonlySet +): boolean => { + const ref = getRef(value); + return ref !== undefined && removed.has(ref); +}; + +/** Parameter Objects and Header Objects share every field this converter touches. */ +const convertParameterOrHeader = (value: unknown, context: Context): unknown => + convertRecord( + value, + { + // 3.2 broadened allowReserved beyond query parameters; 3.1 only + // defines it there. + allowReserved: (item, parameter) => + !("in" in parameter) || parameter.in === "query" + ? deepClone(item) + : DROP, + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertContentMap via media type encodings + content: (item) => convertContentMap(item, context), + examples: refMap(context, convertExample), + // "cookie" is not a 3.1 style; removing it lets the 3.1 default + // (`form`) take over. Other styles pass through. + style: (item) => (item === "cookie" ? DROP : deepClone(item)), + }, + (out, parameter) => { + const lostContent = + isRecord(parameter.content) && + Object.keys(parameter.content).length > 0 && + isRecord(out.content) && + Object.keys(out.content).length === 0; + // 3.1 requires exactly one content entry on parameters and headers, so + // one whose entire content could not be inlined is removed. + return lostContent ? DROP : out; + } + ); + +/** + * Converts a parameter list entry, removing 3.2-only `querystring` + * parameters and references to removed component parameters. + */ +const convertParameterEntry = (value: unknown, context: Context): unknown => + isQuerystringParameter(value) || + isRemovedRef(value, context.removedParameterRefs) + ? DROP + : convertRefOr(value, context, convertParameterOrHeader); + +const convertParameterList = (value: unknown, context: Context): unknown => + mapArray(value, (item) => convertParameterEntry(item, context)); + +const convertHeaderMap = (value: unknown, context: Context): unknown => + mapRecord(value, (item) => + isRemovedRef(item, context.removedHeaderRefs) + ? DROP + : convertRefOr(item, context, convertParameterOrHeader) + ); + +const convertEncoding = (value: unknown, context: Context): unknown => + convertRecord(value, { + // Nested and positional encoding are new in 3.2. + encoding: DROP, + headers: (item) => convertHeaderMap(item, context), + itemEncoding: DROP, + prefixEncoding: DROP, + }); + +const convertMediaType = (value: unknown, context: Context): unknown => + convertRecord( + value, + { + // `description`, positional encoding, and nested encoding are + // 3.2-only; `itemSchema` is recovered below. + description: DROP, + encoding: (item) => + mapRecord(item, (entry) => convertEncoding(entry, context)), + examples: refMap(context, convertExample), + itemEncoding: DROP, + itemSchema: DROP, + prefixEncoding: DROP, + }, + (out, mediaType) => { + if ("itemSchema" in mediaType && out.schema === undefined) { + // The 3.2 sequential media type data model maps streams to arrays. + out.schema = { items: deepClone(mediaType.itemSchema), type: "array" }; + } + return out; + } + ); + +/** + * Follows a content-map entry's `components.mediaTypes` reference chain to + * the Media Type Object it names (non-reference entries stand for + * themselves), or to `DROP` when it cannot be inlined: an external, unknown, + * or cyclic target. + */ +const resolveMediaType = ( + value: unknown, + mediaTypes: UnknownRecord | undefined, + seen: Set +): unknown => { + const ref = getRef(value); + if (ref === undefined) { + return value; + } + if (!ref.startsWith(MEDIA_TYPES_REF_PREFIX)) { + return DROP; + } + const name = ref.slice(MEDIA_TYPES_REF_PREFIX.length); + if ( + name === "" || + name.includes("/") || + mediaTypes === undefined || + !Object.hasOwn(mediaTypes, name) || + seen.has(name) + ) { + return DROP; + } + seen.add(name); + return resolveMediaType(mediaTypes[name], mediaTypes, seen); +}; + +/** + * Converts a content map, inlining `components.mediaTypes` references (3.1 + * content maps hold Media Type Objects only, never references) and removing + * entries whose reference cannot be inlined. + */ +const convertContentMap = (value: unknown, context: Context): unknown => + mapRecord(value, (item) => { + const target = resolveMediaType(item, context.mediaTypes, new Set()); + return target === DROP ? DROP : convertMediaType(target, context); + }); + +const convertRequestBody = (value: unknown, context: Context): unknown => + convertRecord(value, { content: (item) => convertContentMap(item, context) }); + +const convertResponse = (value: unknown, context: Context): unknown => + convertRecord( + value, + { + content: (item) => convertContentMap(item, context), + headers: (item) => convertHeaderMap(item, context), + links: refMap(context, convertLink), + summary: DROP, + }, + (out, response) => { + if (out.description === undefined) { + // Required in 3.1, optional in 3.2: the 3.2 summary stands in, and + // `""` is synthesized as a last resort. + out.description = + "summary" in response ? deepClone(response.summary) : ""; + } + return out; + } + ); + +const convertResponses = (value: unknown, context: Context): unknown => + mapRecord(value, (item, key) => + key.startsWith("x-") + ? deepClone(item) + : convertRefOr(item, context, convertResponse) + ); + +const convertOperation = (value: unknown, context: Context): unknown => + convertRecord(value, { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertCallback, as callbacks contain path items + callbacks: refMap(context, convertCallback), + parameters: (item) => convertParameterList(item, context), + requestBody: (item) => convertRefOr(item, context, convertRequestBody), + responses: (item) => convertResponses(item, context), + servers: (item) => mapArray(item, convertServer), + }); + +const convertCallback = (value: unknown, context: Context): unknown => + mapRecord(value, (item, key) => + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertPathItem, as path items contain callbacks + key.startsWith("x-") ? deepClone(item) : convertPathItem(item, context) + ); + +const convertPathItem = (value: unknown, context: Context): unknown => + convertRecord(value, { + ...operationFields((item) => convertOperation(item, context)), + // The QUERY method and arbitrary additional operations are 3.2-only. + additionalOperations: DROP, + parameters: (item) => convertParameterList(item, context), + query: DROP, + servers: (item) => mapArray(item, convertServer), + }); + +const convertPaths = (value: unknown, context: Context): unknown => + mapRecord(value, (item, key) => + key.startsWith("/") ? convertPathItem(item, context) : deepClone(item) + ); + +const convertComponents = (value: unknown, context: Context): unknown => + convertRecord(value, { + callbacks: refMap(context, convertCallback), + examples: refMap(context, convertExample), + headers: (item) => convertHeaderMap(item, context), + links: refMap(context, convertLink), + // Inlined at use sites; 3.1 has no reusable media types. + 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), + securitySchemes: refMap(context, convertSecurityScheme), + }); + +/** Whether conversion would remove every entry of the value's `content`. */ +const losesEntireContent = ( + value: unknown, + mediaTypes: UnknownRecord | undefined +): boolean => { + if (!(isRecord(value) && isRecord(value.content))) { + return false; + } + const entries = Object.values(value.content); + return ( + entries.length > 0 && + entries.every( + (item) => resolveMediaType(item, mediaTypes, new Set()) === DROP + ) + ); +}; + +/** + * Collects the `$ref` strings of component entries conversion removes + * (querystring parameters, and parameters or headers losing their entire + * `content`), iterated to a fixpoint so chains of reference aliases are + * removed with their targets. + */ +const indexRemovedComponentRefs = ( + map: unknown, + prefix: string, + mediaTypes: UnknownRecord | undefined, + isDirectlyRemoved: (item: unknown) => boolean +): Set => { + const removed = new Set(); + if (!isRecord(map)) { + return removed; + } + const entries = Object.entries(map); + let changed = true; + while (changed) { + changed = false; + for (const [name, item] of entries) { + const selfRef = prefix + name; + if (removed.has(selfRef)) { + continue; + } + const target = getRef(item); + if ( + isDirectlyRemoved(item) || + losesEntireContent(item, mediaTypes) || + (target !== undefined && removed.has(target)) + ) { + removed.add(selfRef); + changed = true; + } + } + } + return removed; +}; + +const createContext = (spec: unknown): Context => { + const components = isRecord(spec) ? spec.components : undefined; + const mediaTypes = + isRecord(components) && isRecord(components.mediaTypes) + ? components.mediaTypes + : undefined; + return { + mediaTypes, + removedHeaderRefs: indexRemovedComponentRefs( + isRecord(components) ? components.headers : undefined, + HEADERS_REF_PREFIX, + mediaTypes, + () => false + ), + removedParameterRefs: indexRemovedComponentRefs( + isRecord(components) ? components.parameters : undefined, + PARAMETERS_REF_PREFIX, + mediaTypes, + isQuerystringParameter + ), + }; +}; + +const convertSpec = (spec: unknown): unknown => { + const context = createContext(spec); + return convertRecord( + spec, + { + // 3.1 has no self-assigned document URI. + $self: DROP, + components: (item) => convertComponents(item, context), + 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; + } + ); +}; + +/** + * Converts an OpenAPI 3.2 document to OpenAPI 3.1.2. The input is never + * mutated, unknown keys and existing specification extensions are preserved, + * and malformed parts are copied through unchanged instead of throwing. + */ +export const downgradeSpecV32ToV31 = ( + spec: OpenAPIV3_2.OpenAPIObject +): OpenAPIV3_1.OpenAPIObject => { + const converted: unknown = convertSpec(spec); + // SAFETY: convertSpec rewrites every 3.2-only construct into its 3.1 form. + return converted as OpenAPIV3_1.OpenAPIObject; +}; diff --git a/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap b/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap new file mode 100644 index 0000000..6051a63 --- /dev/null +++ b/packages/downgrader/tests/__snapshots__/e2e.test.ts.snap @@ -0,0 +1,954 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`3.1 example documents downgraded to 3.0 > converts the 3.1 mega document, removing 3.1-only constructs and the mutualTLS scheme 1`] = ` +{ + "components": { + "schemas": { + "Foo": { + "properties": { + "type": { + "enum": [ + "foo", + ], + }, + }, + "type": "object", + }, + }, + "securitySchemes": {}, + }, + "info": { + "license": { + "name": "Apache 2.0", + }, + "title": "My API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/": { + "get": { + "parameters": [], + "responses": { + "default": { + "description": "", + }, + }, + }, + }, + "/{pathTest}": {}, + }, +} +`; + +exports[`3.1 example documents downgraded to 3.0 > converts the non-OAuth-scopes example, emptying roles on the non-OAuth scheme 1`] = ` +{ + "components": { + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "jwt", + "description": "note: non-oauth scopes are not defined at the securityScheme level", + "scheme": "bearer", + "type": "http", + }, + }, + }, + "info": { + "title": "Non-oAuth Scopes example", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/users": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + }, + }, + }, +} +`; + +exports[`3.1 example documents downgraded to 3.0 > converts the tictactoe example to a valid 3.0.4 document without mutating the input 1`] = ` +{ + "components": { + "parameters": { + "columnParam": { + "description": "Board column (horizontal coordinate)", + "in": "path", + "name": "column", + "required": true, + "schema": { + "$ref": "#/components/schemas/coordinate", + }, + }, + "rowParam": { + "description": "Board row (vertical coordinate)", + "in": "path", + "name": "row", + "required": true, + "schema": { + "$ref": "#/components/schemas/coordinate", + }, + }, + }, + "schemas": { + "board": { + "items": { + "items": { + "$ref": "#/components/schemas/mark", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "coordinate": { + "example": 1, + "maximum": 3, + "minimum": 1, + "type": "integer", + }, + "errorMessage": { + "description": "A text message describing an error", + "maxLength": 256, + "type": "string", + }, + "mark": { + "description": "Possible values for a board square. \`.\` means empty square.", + "enum": [ + ".", + "X", + "O", + ], + "example": ".", + "type": "string", + }, + "status": { + "properties": { + "board": { + "$ref": "#/components/schemas/board", + }, + "winner": { + "$ref": "#/components/schemas/winner", + }, + }, + "type": "object", + }, + "winner": { + "description": "Winner of the game. \`.\` means nobody has won yet.", + "enum": [ + ".", + "X", + "O", + ], + "example": ".", + "type": "string", + }, + }, + "securitySchemes": { + "app2AppOauth": { + "flows": { + "clientCredentials": { + "scopes": { + "board:read": "Read the board", + }, + "tokenUrl": "https://learn.openapis.org/oauth/2.0/token", + }, + }, + "type": "oauth2", + }, + "basicHttpAuthentication": { + "description": "Basic HTTP Authentication", + "scheme": "Basic", + "type": "http", + }, + "bearerHttpAuthentication": { + "bearerFormat": "JWT", + "description": "Bearer token using a JWT", + "scheme": "Bearer", + "type": "http", + }, + "defaultApiKey": { + "description": "API key provided in console", + "in": "header", + "name": "api-key", + "type": "apiKey", + }, + "user2AppOauth": { + "flows": { + "authorizationCode": { + "authorizationUrl": "https://learn.openapis.org/oauth/2.0/auth", + "scopes": { + "board:read": "Read the board", + "board:write": "Write to the board", + }, + "tokenUrl": "https://learn.openapis.org/oauth/2.0/token", + }, + }, + "type": "oauth2", + }, + }, + }, + "info": { + "description": "This API allows writing down marks on a Tic Tac Toe board +and requesting the state of the board or of individual squares. +", + "title": "Tic Tac Toe", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/board": { + "get": { + "description": "Retrieves the current state of the board and the winner.", + "operationId": "get-board", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status", + }, + }, + }, + "description": "OK", + }, + }, + "security": [ + { + "defaultApiKey": [], + }, + { + "app2AppOauth": [ + "board:read", + ], + }, + ], + "summary": "Get the whole board", + "tags": [ + "Gameplay", + ], + }, + }, + "/board/{row}/{column}": { + "get": { + "description": "Retrieves the requested square.", + "operationId": "get-square", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/mark", + }, + }, + }, + "description": "OK", + }, + "400": { + "content": { + "text/html": { + "example": "Illegal coordinates", + "schema": { + "$ref": "#/components/schemas/errorMessage", + }, + }, + }, + "description": "The provided parameters are incorrect", + }, + }, + "security": [ + { + "bearerHttpAuthentication": [], + }, + { + "user2AppOauth": [ + "board:read", + ], + }, + ], + "summary": "Get a single board square", + "tags": [ + "Gameplay", + ], + }, + "parameters": [ + { + "$ref": "#/components/parameters/rowParam", + }, + { + "$ref": "#/components/parameters/columnParam", + }, + ], + "put": { + "description": "Places a mark on the board and retrieves the whole board and the winner (if any).", + "operationId": "put-square", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/mark", + }, + }, + }, + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status", + }, + }, + }, + "description": "OK", + }, + "400": { + "content": { + "text/html": { + "examples": { + "illegalCoordinates": { + "value": "Illegal coordinates.", + }, + "invalidMark": { + "value": "Invalid Mark (X or O).", + }, + "notEmpty": { + "value": "Square is not empty.", + }, + }, + "schema": { + "$ref": "#/components/schemas/errorMessage", + }, + }, + }, + "description": "The provided parameters are incorrect", + }, + }, + "security": [ + { + "bearerHttpAuthentication": [], + }, + { + "user2AppOauth": [ + "board:write", + ], + }, + ], + "summary": "Set a single board square", + "tags": [ + "Gameplay", + ], + }, + }, + }, + "tags": [ + { + "name": "Gameplay", + }, + ], +} +`; + +exports[`3.1 example documents downgraded to 3.0 > converts the webhook example, removing webhooks and synthesizing empty paths 1`] = ` +{ + "components": { + "schemas": { + "Pet": { + "properties": { + "id": { + "format": "int64", + "type": "integer", + }, + "name": { + "type": "string", + }, + "tag": { + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "type": "object", + }, + }, + }, + "info": { + "title": "Webhook Example", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": {}, +} +`; + +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`] = ` +{ + "components": { + "schemas": { + "Foo": { + "properties": { + "type": { + "enum": [ + "foo", + ], + }, + }, + "type": "object", + }, + }, + "securitySchemes": {}, + }, + "info": { + "license": { + "name": "Apache 2.0", + }, + "title": "My API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/": { + "get": { + "parameters": [], + "responses": { + "default": { + "description": "", + }, + }, + }, + }, + "/{pathTest}": {}, + }, +} +`; + +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`] = ` +{ + "components": { + "pathItems": { + "myPathItem": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Foo", + }, + ], + "discriminator": { + "defaultMapping": "Bar", + "mapping": { + "foo": "Foo", + }, + "propertyName": "type", + "x-extension": true, + }, + "externalDocs": { + "description": "More docs!", + "url": "https://example.com/elsewhere.html", + }, + "myArbitraryKeyword": true, + "properties": { + "arr": { + "$comment": "Array without items keyword", + "type": "array", + }, + "either": { + "type": [ + "string", + "null", + ], + }, + "int": { + "exclusiveMaximum": 100, + "exclusiveMinimum": 0, + "type": "integer", + }, + "none": { + "type": "null", + }, + "type": { + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "required": true, + }, + }, + }, + }, + "schemas": { + "Foo": { + "properties": { + "type": { + "const": "foo", + }, + }, + "type": "object", + }, + }, + "securitySchemes": { + "mtls": { + "type": "mutualTLS", + }, + }, + }, + "info": { + "license": { + "identifier": "Apache-2.0", + "name": "Apache 2.0", + }, + "summary": "My API's summary", + "title": "My API", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/": { + "get": { + "parameters": [], + }, + }, + "/{pathTest}": {}, + }, + "webhooks": { + "myWebhook": { + "$ref": "#/components/pathItems/myPathItem", + "description": "Overriding description", + }, + }, +} +`; + +exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes tag summary, parent, and kind of the tags example > v3.0 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/flights": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "List all flights", + "tags": [ + "flights", + ], + }, + }, + "/flights/delayed": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "Get delayed flights", + "tags": [ + "delays", + ], + }, + }, + "/flights/domestic": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "List domestic flights", + "tags": [ + "domestic", + ], + }, + }, + "/flights/international": { + "get": { + "responses": { + "default": { + "description": "", + }, + }, + "summary": "List international flights", + "tags": [ + "international", + ], + }, + }, + }, + "tags": [ + { + "description": "Core flight operations", + "name": "flights", + }, + { + "description": "Flights that cross country borders", + "name": "international", + }, + { + "description": "Flights within a single country", + "name": "domestic", + }, + { + "description": "Information about flight delays", + "externalDocs": { + "description": "Delay compensation policies", + "url": "https://docs.example.com/delay-policies", + }, + "name": "delays", + }, + ], +} +`; + +exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes tag summary, parent, and kind of the tags example > v3.1 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/flights": { + "get": { + "summary": "List all flights", + "tags": [ + "flights", + ], + }, + }, + "/flights/delayed": { + "get": { + "summary": "Get delayed flights", + "tags": [ + "delays", + ], + }, + }, + "/flights/domestic": { + "get": { + "summary": "List domestic flights", + "tags": [ + "domestic", + ], + }, + }, + "/flights/international": { + "get": { + "summary": "List international flights", + "tags": [ + "international", + ], + }, + }, + }, + "tags": [ + { + "description": "Core flight operations", + "name": "flights", + }, + { + "description": "Flights that cross country borders", + "name": "international", + }, + { + "description": "Flights within a single country", + "name": "domestic", + }, + { + "description": "Information about flight delays", + "externalDocs": { + "description": "Delay compensation policies", + "url": "https://docs.example.com/delay-policies", + }, + "name": "delays", + }, + ], +} +`; + +exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes the query operation of the query example, leaving an empty path item > v3.0 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/flights/search": {}, + }, +} +`; + +exports[`3.2 example documents downgraded to 3.1 and chained to 3.0 > removes the query operation of the query example, leaving an empty path item > v3.1 1`] = ` +{ + "info": { + "title": "Flight API", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/flights/search": {}, + }, +} +`; + +exports[`kitchen-sink 3.2 document chained down to 3.0 > converts every 3.2-only construct and stays valid through both hops > v3.0 1`] = ` +{ + "components": { + "parameters": { + "page": { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + }, + }, + }, + "securitySchemes": { + "deviceAuth": { + "flows": {}, + "type": "oauth2", + }, + }, + }, + "info": { + "title": "Kitchen Sink", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/events": { + "get": { + "operationId": "streamEvents", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "object", + }, + "type": "array", + }, + }, + "application/jsonl": { + "schema": { + "items": { + "properties": { + "kind": { + "type": "string", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + }, + "description": "Event stream", + }, + "204": { + "description": "", + }, + }, + }, + }, + "/search": { + "get": { + "operationId": "searchEvents", + "parameters": [ + { + "examples": { + "kept": { + "value": "sid=1", + }, + "linked": { + "externalValue": "https://example.com/session.json", + }, + "promoted": { + "value": "sid=3", + }, + }, + "in": "cookie", + "name": "session", + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + }, + "description": "Search results", + }, + }, + }, + }, + }, + "security": [ + { + "deviceAuth": [ + "events:read", + ], + }, + ], + "servers": [ + { + "url": "https://api.example.com", + }, + ], +} +`; + +exports[`kitchen-sink 3.2 document chained down to 3.0 > converts every 3.2-only construct and stays valid through both hops > v3.1 1`] = ` +{ + "components": { + "parameters": { + "page": { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + }, + }, + }, + "securitySchemes": { + "deviceAuth": { + "flows": {}, + "type": "oauth2", + }, + }, + }, + "info": { + "title": "Kitchen Sink", + "version": "1.0.0", + }, + "openapi": "3.1.2", + "paths": { + "/events": { + "get": { + "operationId": "streamEvents", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "object", + }, + "type": "array", + }, + }, + "application/jsonl": { + "schema": { + "items": { + "properties": { + "kind": { + "type": "string", + }, + }, + "type": "object", + }, + "type": "array", + }, + }, + }, + "description": "Event stream", + }, + "204": { + "description": "", + }, + }, + }, + }, + "/search": { + "get": { + "operationId": "searchEvents", + "parameters": [ + { + "examples": { + "kept": { + "value": "sid=1", + }, + "linked": { + "externalValue": "https://example.com/session.json", + }, + "promoted": { + "value": "sid=3", + }, + }, + "in": "cookie", + "name": "session", + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + }, + "description": "Search results", + }, + }, + }, + }, + }, + "security": [ + { + "deviceAuth": [ + "events:read", + ], + }, + ], + "servers": [ + { + "url": "https://api.example.com", + }, + ], +} +`; diff --git a/packages/downgrader/tests/corpus.test.ts b/packages/downgrader/tests/corpus.test.ts new file mode 100644 index 0000000..6711e8b --- /dev/null +++ b/packages/downgrader/tests/corpus.test.ts @@ -0,0 +1,263 @@ +import type { OpenAPIV3_1, OpenAPIV3_2 } from "@oasty/types"; +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-object-parameters, anti-slop/no-unsafe-dictionary-type -- these corpus tests bulk-feed whole OpenAPI documents into the converters and a generic JSON-schema validator, so version-agnostic document shapes are the domain contract */ +import { Validator } from "@seriousme/openapi-schema-validator"; + +import { doc as exampleQueryV32 } from "../../types/tests/examples/3-2-query-example"; +import { doc as exampleTagsV32 } from "../../types/tests/examples/3-2-tags-example"; +import { doc as exampleNonOauthScopesV31 } from "../../types/tests/examples/non-oauth-scopes-3-1"; +import { doc as exampleTictactoeV31 } from "../../types/tests/examples/tictactoe-3-1"; +import { doc as exampleWebhookV31 } from "../../types/tests/examples/webhook-example-3-1"; +import { doc as callbackObjectExamplesV31 } from "../../types/tests/schema-tests-3.1/callback-object-examples"; +import { doc as compPathitemsV31 } from "../../types/tests/schema-tests-3.1/comp-pathitems"; +import { doc as componentsObjectExampleV31 } from "../../types/tests/schema-tests-3.1/components-object-example"; +import { doc as exampleObjectExamplesV31 } from "../../types/tests/schema-tests-3.1/example-object-examples"; +import { doc as headerObjectExamplesV31 } from "../../types/tests/schema-tests-3.1/header-object-examples"; +import { doc as infoObjectExampleV31 } from "../../types/tests/schema-tests-3.1/info-object-example"; +import { doc as infoSummaryV31 } from "../../types/tests/schema-tests-3.1/info-summary"; +import { doc as jsonSchemaDialectV31 } from "../../types/tests/schema-tests-3.1/json-schema-dialect"; +import { doc as licenseIdentifierV31 } from "../../types/tests/schema-tests-3.1/license-identifier"; +import { doc as linkObjectExamplesV31 } from "../../types/tests/schema-tests-3.1/link-object-examples"; +import { doc as mediaTypeExamplesV31 } from "../../types/tests/schema-tests-3.1/media-type-examples"; +import { doc as megaV31 } from "../../types/tests/schema-tests-3.1/mega"; +import { doc as minimalCompV31 } from "../../types/tests/schema-tests-3.1/minimal-comp"; +import { doc as minimalHooksV31 } from "../../types/tests/schema-tests-3.1/minimal-hooks"; +import { doc as minimalPathsV31 } from "../../types/tests/schema-tests-3.1/minimal-paths"; +import { doc as nonOauthScopesV31 } from "../../types/tests/schema-tests-3.1/non-oauth-scopes"; +import { doc as operationObjectExampleV31 } from "../../types/tests/schema-tests-3.1/operation-object-example"; +import { doc as parameterObjectExamplesV31 } from "../../types/tests/schema-tests-3.1/parameter-object-examples"; +import { doc as parameterObjectQueryAllowReservedV31 } from "../../types/tests/schema-tests-3.1/parameter-object-query-allow-reserved"; +import { doc as pathItemObjectExampleV31 } from "../../types/tests/schema-tests-3.1/path-item-object-example"; +import { doc as pathItemServersParametersV31 } from "../../types/tests/schema-tests-3.1/path-item-servers-parameters"; +import { doc as pathNoResponseV31 } from "../../types/tests/schema-tests-3.1/path-no-response"; +import { doc as pathVarEmptyPathitemV31 } from "../../types/tests/schema-tests-3.1/path-var-empty-pathitem"; +import { doc as pathsObjectExampleV31 } from "../../types/tests/schema-tests-3.1/paths-object-example"; +import { doc as requestBodyExamplesV31 } from "../../types/tests/schema-tests-3.1/request-body-examples"; +import { doc as responseObjectExamplesV31 } from "../../types/tests/schema-tests-3.1/response-object-examples"; +import { doc as schemaV31 } from "../../types/tests/schema-tests-3.1/schema"; +import { doc as schemaObjectDeprecatedExampleKeywordV31 } from "../../types/tests/schema-tests-3.1/schema-object-deprecated-example-keyword"; +import { doc as serversV31 } from "../../types/tests/schema-tests-3.1/servers"; +import { doc as specificationExtensionsV31 } from "../../types/tests/schema-tests-3.1/specification-extensions"; +import { doc as tagObjectExampleV31 } from "../../types/tests/schema-tests-3.1/tag-object-example"; +import { doc as validSchemaTypesV31 } from "../../types/tests/schema-tests-3.1/valid-schema-types"; +import { doc as webhookExampleV31 } from "../../types/tests/schema-tests-3.1/webhook-example"; +import { doc as callbackObjectExamplesV32 } from "../../types/tests/schema-tests-3.2/callback-object-examples"; +import { doc as compPathitemsV32 } from "../../types/tests/schema-tests-3.2/comp-pathitems"; +import { doc as componentsObjectExampleV32 } from "../../types/tests/schema-tests-3.2/components-object-example"; +import { doc as exampleObjectExamplesV32 } from "../../types/tests/schema-tests-3.2/example-object-examples"; +import { doc as headerObjectExamplesV32 } from "../../types/tests/schema-tests-3.2/header-object-examples"; +import { doc as infoObjectExampleV32 } from "../../types/tests/schema-tests-3.2/info-object-example"; +import { doc as infoSummaryV32 } from "../../types/tests/schema-tests-3.2/info-summary"; +import { doc as jsonSchemaDialectV32 } from "../../types/tests/schema-tests-3.2/json-schema-dialect"; +import { doc as licenseIdentifierV32 } from "../../types/tests/schema-tests-3.2/license-identifier"; +import { doc as linkObjectExamplesV32 } from "../../types/tests/schema-tests-3.2/link-object-examples"; +import { doc as mediaTypeExamplesV32 } from "../../types/tests/schema-tests-3.2/media-type-examples"; +import { doc as megaV32 } from "../../types/tests/schema-tests-3.2/mega"; +import { doc as minimalCompV32 } from "../../types/tests/schema-tests-3.2/minimal-comp"; +import { doc as minimalHooksV32 } from "../../types/tests/schema-tests-3.2/minimal-hooks"; +import { doc as minimalPathsV32 } from "../../types/tests/schema-tests-3.2/minimal-paths"; +import { doc as nonOauthScopesV32 } from "../../types/tests/schema-tests-3.2/non-oauth-scopes"; +import { doc as operationObjectExampleV32 } from "../../types/tests/schema-tests-3.2/operation-object-example"; +import { doc as parameterObjectCookieFormAllowReservedV32 } from "../../types/tests/schema-tests-3.2/parameter-object-cookie-form-allow-reserved"; +import { doc as parameterObjectExamplesV32 } from "../../types/tests/schema-tests-3.2/parameter-object-examples"; +import { doc as parameterObjectPathAllowReservedV32 } from "../../types/tests/schema-tests-3.2/parameter-object-path-allow-reserved"; +import { doc as parameterObjectQueryAllowReservedV32 } from "../../types/tests/schema-tests-3.2/parameter-object-query-allow-reserved"; +import { doc as pathItemObjectExampleV32 } from "../../types/tests/schema-tests-3.2/path-item-object-example"; +import { doc as pathItemServersParametersV32 } from "../../types/tests/schema-tests-3.2/path-item-servers-parameters"; +import { doc as pathNoResponseV32 } from "../../types/tests/schema-tests-3.2/path-no-response"; +import { doc as pathVarEmptyPathitemV32 } from "../../types/tests/schema-tests-3.2/path-var-empty-pathitem"; +import { doc as pathsObjectExampleV32 } from "../../types/tests/schema-tests-3.2/paths-object-example"; +import { doc as requestBodyExamplesV32 } from "../../types/tests/schema-tests-3.2/request-body-examples"; +import { doc as responseObjectExamplesV32 } from "../../types/tests/schema-tests-3.2/response-object-examples"; +import { doc as schemaV32 } from "../../types/tests/schema-tests-3.2/schema"; +import { doc as schemaObjectDeprecatedExampleKeywordV32 } from "../../types/tests/schema-tests-3.2/schema-object-deprecated-example-keyword"; +import { doc as serversV32 } from "../../types/tests/schema-tests-3.2/servers"; +import { doc as specificationExtensionsV32 } from "../../types/tests/schema-tests-3.2/specification-extensions"; +import { doc as styleDefaultsV32 } from "../../types/tests/schema-tests-3.2/style-defaults"; +import { doc as tagObjectExampleV32 } from "../../types/tests/schema-tests-3.2/tag-object-example"; +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"; + +const asSpec31 = (value: unknown): OpenAPIV3_1.OpenAPIObject => + // SAFETY: fixture literals infer unions that do not always narrow to the declared document type; the converters accept arbitrary documents at runtime. + value as OpenAPIV3_1.OpenAPIObject; + +const asSpec32 = (value: unknown): OpenAPIV3_2.OpenAPIObject => + // SAFETY: fixture literals infer unions that do not always narrow to the declared document type; the converters accept arbitrary documents at runtime. + value as OpenAPIV3_2.OpenAPIObject; + +const asValidatorInput = (value: unknown): Record => + // SAFETY: the validator accepts arbitrary JSON documents at runtime. + value as Record; + +const validate = async (spec: object) => { + const validator = new Validator(); + const result = await validator.validate( + asValidatorInput(structuredClone(spec)) + ); + return { result, version: validator.version }; +}; + +const expectValidAs = async ( + spec: object, + expectedVersion: string +): Promise => { + const { result, version } = await validate(spec); + expect(result.errors ?? []).toEqual([]); + expect(result.valid).toBe(true); + expect(version).toBe(expectedVersion); +}; + +/** + * Excluded 3.1 fixtures: + * + * - `security-scheme-object-examples`: contains a `$ref` to an external URL, + * which the validator cannot resolve ("only internal refs are supported") — + * a validator limitation, not a conversion defect. + * - `style-defaults`: it carries `x-comment` inside an Encoding Object, + * which the converter rightly preserves but the official 3.0 schema + * rejects — its Encoding definition is `additionalProperties: false` + * with no `^x-` carve-out, an upstream schema strictness (the 3.0 prose + * declares the Encoding Object extensible). + */ +const corpus31: readonly (readonly [ + name: string, + doc: OpenAPIV3_1.OpenAPIObject, +])[] = [ + ["examples/non-oauth-scopes-3-1", asSpec31(exampleNonOauthScopesV31)], + ["examples/tictactoe-3-1", asSpec31(exampleTictactoeV31)], + ["examples/webhook-example-3-1", asSpec31(exampleWebhookV31)], + ["callback-object-examples", asSpec31(callbackObjectExamplesV31)], + ["comp-pathitems", asSpec31(compPathitemsV31)], + ["components-object-example", asSpec31(componentsObjectExampleV31)], + ["example-object-examples", asSpec31(exampleObjectExamplesV31)], + ["header-object-examples", asSpec31(headerObjectExamplesV31)], + ["info-object-example", asSpec31(infoObjectExampleV31)], + ["info-summary", asSpec31(infoSummaryV31)], + ["json-schema-dialect", asSpec31(jsonSchemaDialectV31)], + ["license-identifier", asSpec31(licenseIdentifierV31)], + ["link-object-examples", asSpec31(linkObjectExamplesV31)], + ["media-type-examples", asSpec31(mediaTypeExamplesV31)], + ["mega", asSpec31(megaV31)], + ["minimal-comp", asSpec31(minimalCompV31)], + ["minimal-hooks", asSpec31(minimalHooksV31)], + ["minimal-paths", asSpec31(minimalPathsV31)], + ["non-oauth-scopes", asSpec31(nonOauthScopesV31)], + ["operation-object-example", asSpec31(operationObjectExampleV31)], + ["parameter-object-examples", asSpec31(parameterObjectExamplesV31)], + [ + "parameter-object-query-allow-reserved", + asSpec31(parameterObjectQueryAllowReservedV31), + ], + ["path-item-object-example", asSpec31(pathItemObjectExampleV31)], + ["path-item-servers-parameters", asSpec31(pathItemServersParametersV31)], + ["path-no-response", asSpec31(pathNoResponseV31)], + ["path-var-empty-pathitem", asSpec31(pathVarEmptyPathitemV31)], + ["paths-object-example", asSpec31(pathsObjectExampleV31)], + ["request-body-examples", asSpec31(requestBodyExamplesV31)], + ["response-object-examples", asSpec31(responseObjectExamplesV31)], + ["schema", asSpec31(schemaV31)], + [ + "schema-object-deprecated-example-keyword", + asSpec31(schemaObjectDeprecatedExampleKeywordV31), + ], + ["servers", asSpec31(serversV31)], + ["specification-extensions", asSpec31(specificationExtensionsV31)], + ["tag-object-example", asSpec31(tagObjectExampleV31)], + ["valid-schema-types", asSpec31(validSchemaTypesV31)], + ["webhook-example", asSpec31(webhookExampleV31)], +]; + +/** + * Excluded 3.2 fixtures: + * + * - `security-scheme-object-examples`: contains a `$ref` to an external URL, + * which the validator cannot resolve ("only internal refs are supported") — + * a validator limitation, not a conversion defect. + */ +const corpus32: readonly (readonly [ + name: string, + doc: OpenAPIV3_2.OpenAPIObject, +])[] = [ + ["examples/3-2-query-example", asSpec32(exampleQueryV32)], + ["examples/3-2-tags-example", asSpec32(exampleTagsV32)], + ["callback-object-examples", asSpec32(callbackObjectExamplesV32)], + ["comp-pathitems", asSpec32(compPathitemsV32)], + ["components-object-example", asSpec32(componentsObjectExampleV32)], + ["example-object-examples", asSpec32(exampleObjectExamplesV32)], + ["header-object-examples", asSpec32(headerObjectExamplesV32)], + ["info-object-example", asSpec32(infoObjectExampleV32)], + ["info-summary", asSpec32(infoSummaryV32)], + ["json-schema-dialect", asSpec32(jsonSchemaDialectV32)], + ["license-identifier", asSpec32(licenseIdentifierV32)], + ["link-object-examples", asSpec32(linkObjectExamplesV32)], + ["media-type-examples", asSpec32(mediaTypeExamplesV32)], + ["mega", asSpec32(megaV32)], + ["minimal-comp", asSpec32(minimalCompV32)], + ["minimal-hooks", asSpec32(minimalHooksV32)], + ["minimal-paths", asSpec32(minimalPathsV32)], + ["non-oauth-scopes", asSpec32(nonOauthScopesV32)], + ["operation-object-example", asSpec32(operationObjectExampleV32)], + [ + "parameter-object-cookie-form-allow-reserved", + asSpec32(parameterObjectCookieFormAllowReservedV32), + ], + ["parameter-object-examples", asSpec32(parameterObjectExamplesV32)], + [ + "parameter-object-path-allow-reserved", + asSpec32(parameterObjectPathAllowReservedV32), + ], + [ + "parameter-object-query-allow-reserved", + asSpec32(parameterObjectQueryAllowReservedV32), + ], + ["path-item-object-example", asSpec32(pathItemObjectExampleV32)], + ["path-item-servers-parameters", asSpec32(pathItemServersParametersV32)], + ["path-no-response", asSpec32(pathNoResponseV32)], + ["path-var-empty-pathitem", asSpec32(pathVarEmptyPathitemV32)], + ["paths-object-example", asSpec32(pathsObjectExampleV32)], + ["request-body-examples", asSpec32(requestBodyExamplesV32)], + ["response-object-examples", asSpec32(responseObjectExamplesV32)], + ["schema", asSpec32(schemaV32)], + [ + "schema-object-deprecated-example-keyword", + asSpec32(schemaObjectDeprecatedExampleKeywordV32), + ], + ["servers", asSpec32(serversV32)], + ["specification-extensions", asSpec32(specificationExtensionsV32)], + ["style-defaults", asSpec32(styleDefaultsV32)], + ["tag-object-example", asSpec32(tagObjectExampleV32)], + ["valid-schema-types", asSpec32(validSchemaTypesV32)], + ["webhook-example", asSpec32(webhookExampleV32)], +]; + +describe("3.1 corpus downgraded to 3.0", () => { + it.each(corpus31)( + "converts %s to a valid 3.0 document without 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"); + expect(doc).toEqual(before); + } + ); +}); + +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", + 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"); + const v30 = downgradeSpecV31ToV30(v31); + expect(v30.openapi).toBe("3.0.4"); + await expectValidAs(v30, "3.0"); + expect(doc).toEqual(before); + } + ); +}); diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts new file mode 100644 index 0000000..4009e0c --- /dev/null +++ b/packages/downgrader/tests/e2e.test.ts @@ -0,0 +1,460 @@ +import type { OpenAPIV3_1, OpenAPIV3_2 } from "@oasty/types"; +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-object-parameters, anti-slop/no-unsafe-dictionary-type -- these e2e tests shuttle whole OpenAPI documents across three spec versions into the converters and a generic JSON-schema validator, so version-agnostic document shapes are the domain contract */ +import { Validator } from "@seriousme/openapi-schema-validator"; + +import { doc as queryExample } from "../../types/tests/examples/3-2-query-example"; +import { doc as tagsExample } from "../../types/tests/examples/3-2-tags-example"; +import { doc as nonOauthScopes } from "../../types/tests/examples/non-oauth-scopes-3-1"; +import { doc as petstore } from "../../types/tests/examples/petstore-3-0"; +import { doc as tictactoe } from "../../types/tests/examples/tictactoe-3-1"; +import { doc as webhookExample } from "../../types/tests/examples/webhook-example-3-1"; +import { doc as mega31 } from "../../types/tests/schema-tests-3.1/mega"; +import { doc as mega32 } from "../../types/tests/schema-tests-3.2/mega"; +import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from "../src/index"; + +const asSpec31 = (value: unknown): OpenAPIV3_1.OpenAPIObject => + // SAFETY: tests deliberately feed documents of other versions (and fixture literals whose inferred unions do not narrow) to exercise real converter input. + value as OpenAPIV3_1.OpenAPIObject; + +const asValidatorInput = (value: unknown): Record => + // SAFETY: the validator accepts arbitrary JSON documents at runtime. + value as Record; + +const validate = async (spec: object) => { + const validator = new Validator(); + const result = await validator.validate( + asValidatorInput(structuredClone(spec)) + ); + return { result, version: validator.version }; +}; + +const expectValidAs = async ( + spec: object, + expectedVersion: string +): Promise => { + const { result, version } = await validate(spec); + expect(result.errors ?? []).toEqual([]); + expect(result.valid).toBe(true); + expect(version).toBe(expectedVersion); +}; + +describe("3.1 example documents downgraded to 3.0", () => { + it("converts the tictactoe example to a valid 3.0.4 document without mutating the input", async () => { + const before = structuredClone(tictactoe); + const converted = downgradeSpecV31ToV30(asSpec31(tictactoe)); + expect(converted.openapi).toBe("3.0.4"); + await expectValidAs(converted, "3.0"); + expect(converted).toMatchSnapshot(); + expect(tictactoe).toEqual(before); + }); + + it("converts the webhook example, removing webhooks and synthesizing empty paths", async () => { + const before = structuredClone(webhookExample); + const converted = downgradeSpecV31ToV30(webhookExample); + expect(converted.openapi).toBe("3.0.4"); + expect(converted).not.toHaveProperty("webhooks"); + expect(converted).not.toHaveProperty("x-webhooks"); + expect(converted.paths).toEqual({}); + expect(converted.components).toHaveProperty(["schemas", "Pet"]); + await expectValidAs(converted, "3.0"); + expect(converted).toMatchSnapshot(); + expect(webhookExample).toEqual(before); + }); + + it("converts the non-OAuth-scopes example, emptying roles on the non-OAuth scheme", async () => { + const before = structuredClone(nonOauthScopes); + const converted = downgradeSpecV31ToV30(nonOauthScopes); + expect(converted.openapi).toBe("3.0.4"); + expect(converted.paths).toMatchObject({ + "/users": { get: { security: [{ bearerAuth: [] }] } }, + }); + // The source operation has no responses; the synthesized minimal default + // response keeps the document valid. + expect(converted.paths?.["/users"]?.get?.responses).toEqual({ + default: { description: "" }, + }); + await expectValidAs(converted, "3.0"); + expect(converted).toMatchSnapshot(); + expect(nonOauthScopes).toEqual(before); + }); + + it("converts the 3.1 mega document, removing 3.1-only constructs and the mutualTLS scheme", async () => { + const before = structuredClone(mega31); + const converted = downgradeSpecV31ToV30(mega31); + expect(converted.openapi).toBe("3.0.4"); + expect(converted).not.toHaveProperty("webhooks"); + expect(converted).not.toHaveProperty("x-webhooks"); + expect(converted.info).toEqual({ + license: { name: "Apache 2.0" }, + title: "My API", + version: "1.0.0", + }); + expect(converted.components).not.toHaveProperty("pathItems"); + expect(converted.components).not.toHaveProperty("x-pathItems"); + expect(converted.components?.securitySchemes).toEqual({}); + // The only reference into components.pathItems lived in the removed + // webhooks, so no trace of the reusable path items remains. + expect(JSON.stringify(converted)).not.toContain("#/components/pathItems/"); + await expectValidAs(converted, "3.0"); + expect(converted).toMatchSnapshot(); + expect(mega31).toEqual(before); + }); + + it("leaves $refs into the removed components.pathItems untouched, letting them dangle", () => { + const doc = asSpec31({ + components: { + pathItems: { + shared: { + get: { responses: { "200": { description: "ok" } } }, + }, + }, + }, + info: { title: "Dangling", version: "1.0.0" }, + openapi: "3.1.0", + paths: { + "/shared": { $ref: "#/components/pathItems/shared" }, + }, + }); + const before = structuredClone(doc); + const converted = downgradeSpecV31ToV30(doc); + expect(converted.components).not.toHaveProperty("pathItems"); + // Documented limitation: the reference is passed through untouched and + // now dangles, so the (reference-resolving) validator is not consulted. + expect(converted.paths?.["/shared"]).toEqual({ + $ref: "#/components/pathItems/shared", + }); + expect(doc).toEqual(before); + }); + + it("clones a discriminator with defaultMapping as-is into the 3.0 document", async () => { + const doc = asSpec31({ + components: { + schemas: { + Cat: { + properties: { kind: { type: "string" } }, + required: ["kind"], + type: "object", + }, + Pet: { + discriminator: { + defaultMapping: "Cat", + mapping: { cat: "#/components/schemas/Cat" }, + propertyName: "kind", + }, + oneOf: [{ $ref: "#/components/schemas/Cat" }], + }, + }, + }, + info: { title: "Discriminated", version: "1.0.0" }, + openapi: "3.1.0", + paths: {}, + }); + const before = structuredClone(doc); + const converted = downgradeSpecV31ToV30(doc); + // defaultMapping is not a schema keyword the 3.0 converter touches, and + // the official 3.0 schema allows extra discriminator fields. + expect(converted).toHaveProperty( + ["components", "schemas", "Pet", "discriminator"], + { + defaultMapping: "Cat", + mapping: { cat: "#/components/schemas/Cat" }, + propertyName: "kind", + } + ); + await expectValidAs(converted, "3.0"); + expect(doc).toEqual(before); + }); +}); + +describe("3.2 example documents downgraded to 3.1 and chained to 3.0", () => { + it("removes the query operation of the query example, leaving an empty path item", async () => { + const before = structuredClone(queryExample); + const v31 = downgradeSpecV32ToV31(queryExample); + expect(v31.openapi).toBe("3.1.2"); + // The QUERY operation has no 3.1 equivalent; an empty Path Item Object + // is legal in both 3.1 and 3.0. + expect(v31.paths?.["/flights/search"]).toEqual({}); + expect(JSON.stringify(v31)).not.toContain("x-additionalOperations"); + await expectValidAs(v31, "3.1"); + expect(v31).toMatchSnapshot("v3.1"); + + const v30 = downgradeSpecV31ToV30(v31); + expect(v30.openapi).toBe("3.0.4"); + await expectValidAs(v30, "3.0"); + expect(v30).toMatchSnapshot("v3.0"); + expect(queryExample).toEqual(before); + }); + + it("removes tag summary, parent, and kind of the tags example", async () => { + const before = structuredClone(tagsExample); + const v31 = downgradeSpecV32ToV31(tagsExample); + expect(v31.openapi).toBe("3.1.2"); + expect(v31.tags).toEqual([ + { description: "Core flight operations", name: "flights" }, + { + description: "Flights that cross country borders", + name: "international", + }, + { description: "Flights within a single country", name: "domestic" }, + { + description: "Information about flight delays", + externalDocs: { + description: "Delay compensation policies", + url: "https://docs.example.com/delay-policies", + }, + name: "delays", + }, + ]); + await expectValidAs(v31, "3.1"); + expect(v31).toMatchSnapshot("v3.1"); + + const v30 = downgradeSpecV31ToV30(v31); + expect(v30.openapi).toBe("3.0.4"); + await expectValidAs(v30, "3.0"); + expect(v30).toMatchSnapshot("v3.0"); + expect(tagsExample).toEqual(before); + }); + + it("converts the 3.2 mega document, preserving the discriminator defaultMapping in the schema", async () => { + const before = structuredClone(mega32); + const v31 = downgradeSpecV32ToV31(mega32); + expect(v31.openapi).toBe("3.1.2"); + const megaDiscriminatorPath = [ + "components", + "pathItems", + "myPathItem", + "post", + "requestBody", + "content", + "application/json", + "schema", + "discriminator", + ]; + // Schema Objects pass through unchanged in 3.2 -> 3.1, so the 3.2-only + // discriminator defaultMapping survives as an extra JSON Schema keyword. + expect(v31).toHaveProperty( + [...megaDiscriminatorPath, "defaultMapping"], + "Bar" + ); + expect(v31).toHaveProperty( + [...megaDiscriminatorPath, "propertyName"], + "type" + ); + expect(v31).not.toHaveProperty([ + ...megaDiscriminatorPath, + "x-defaultMapping", + ]); + await expectValidAs(v31, "3.1"); + expect(v31).toMatchSnapshot("v3.1"); + + const v30 = downgradeSpecV31ToV30(v31); + expect(v30.openapi).toBe("3.0.4"); + // The discriminator lives in components.pathItems, which 3.0 cannot + // express, so it disappears together with its host in this hop. + expect(v30.components).not.toHaveProperty("pathItems"); + expect(v30).not.toHaveProperty("webhooks"); + await expectValidAs(v30, "3.0"); + expect(v30).toMatchSnapshot("v3.0"); + expect(mega32).toEqual(before); + }); +}); + +describe("already-3.0-shaped documents", () => { + it("passes the petstore example through untouched apart from the version stamp", () => { + const before = structuredClone(petstore); + const converted = downgradeSpecV31ToV30(asSpec31(petstore)); + expect(converted).toEqual({ + ...structuredClone(petstore), + openapi: "3.0.4", + }); + expect(petstore).toEqual(before); + }); +}); + +describe("kitchen-sink 3.2 document chained down to 3.0", () => { + const kitchenSink = { + $self: "https://api.example.com/openapi.json", + components: { + mediaTypes: { + JsonPayload: { + schema: { items: { type: "string" }, type: "array" }, + }, + }, + parameters: { + filter: { + content: { + "application/json": { + schema: { + properties: { term: { type: "string" } }, + type: "object", + }, + }, + }, + in: "querystring", + name: "filter", + }, + page: { in: "query", name: "page", schema: { type: "integer" } }, + }, + securitySchemes: { + deviceAuth: { + deprecated: true, + flows: { + deviceAuthorization: { + deviceAuthorizationUrl: "https://auth.example.com/device", + scopes: { "events:read": "Read events" }, + tokenUrl: "https://auth.example.com/token", + }, + }, + oauth2MetadataUrl: "https://auth.example.com/.well-known/oauth", + type: "oauth2", + }, + }, + }, + info: { title: "Kitchen Sink", version: "1.0.0" }, + openapi: "3.2.0", + paths: { + "/events": { + get: { + operationId: "streamEvents", + responses: { + "200": { + content: { + "application/json": { + itemSchema: { type: "object" }, + schema: { items: { type: "object" }, type: "array" }, + }, + "application/jsonl": { + itemSchema: { + properties: { kind: { type: "string" } }, + type: "object", + }, + }, + }, + summary: "Event stream", + }, + "204": {}, + }, + }, + }, + "/search": { + get: { + operationId: "searchEvents", + parameters: [ + { + content: { + "application/json": { + schema: { + properties: { term: { type: "string" } }, + type: "object", + }, + }, + }, + in: "querystring", + name: "filter", + }, + { + examples: { + kept: { serializedValue: "sid=1", value: "sid=1" }, + linked: { + dataValue: { sid: 2 }, + externalValue: "https://example.com/session.json", + }, + promoted: { dataValue: "sid=3" }, + }, + in: "cookie", + name: "session", + schema: { type: "string" }, + style: "cookie", + }, + ], + responses: { + "200": { + content: { + "application/json": { + $ref: "#/components/mediaTypes/JsonPayload", + }, + }, + description: "Search results", + summary: "Results", + }, + }, + }, + }, + }, + security: [{ deviceAuth: ["events:read"] }], + servers: [{ name: "production", url: "https://api.example.com" }], + } satisfies OpenAPIV3_2.OpenAPIObject; + + it("converts every 3.2-only construct and stays valid through both hops", async () => { + const before = structuredClone(kitchenSink); + + const v31 = downgradeSpecV32ToV31(kitchenSink); + expect(v31.openapi).toBe("3.1.2"); + expect(v31).not.toHaveProperty("$self"); + expect(v31).not.toHaveProperty("x-self"); + expect(v31.servers).toEqual([{ url: "https://api.example.com" }]); + expect(v31.components).not.toHaveProperty("mediaTypes"); + expect(JSON.stringify(v31)).not.toContain("#/components/mediaTypes/"); + // The querystring component is removed outright; the deviceAuthorization + // flow, oauth2MetadataUrl, and deprecated have no 3.1 equivalent either. + expect(v31.components?.parameters).toEqual({ + page: { in: "query", name: "page", schema: { type: "integer" } }, + }); + expect(v31.components?.securitySchemes).toEqual({ + deviceAuth: { flows: {}, type: "oauth2" }, + }); + expect(v31.paths?.["/events"]?.get?.responses).toEqual({ + "200": { + content: { + "application/json": { + schema: { items: { type: "object" }, type: "array" }, + }, + "application/jsonl": { + schema: { + items: { + properties: { kind: { type: "string" } }, + type: "object", + }, + type: "array", + }, + }, + }, + description: "Event stream", + }, + "204": { description: "" }, + }); + // The querystring parameter is removed from the list; the cookie style + // is removed from the remaining parameter, and its examples promote + // dataValue/serializedValue into free value slots only. + expect(v31.paths?.["/search"]?.get?.parameters).toEqual([ + { + examples: { + kept: { value: "sid=1" }, + linked: { externalValue: "https://example.com/session.json" }, + promoted: { value: "sid=3" }, + }, + in: "cookie", + name: "session", + schema: { type: "string" }, + }, + ]); + expect(v31.paths?.["/search"]?.get?.responses?.["200"]).toEqual({ + content: { + "application/json": { + schema: { items: { type: "string" }, type: "array" }, + }, + }, + description: "Search results", + }); + expect(v31.security).toEqual([{ deviceAuth: ["events:read"] }]); + await expectValidAs(v31, "3.1"); + expect(v31).toMatchSnapshot("v3.1"); + + const v30 = downgradeSpecV31ToV30(v31); + expect(v30.openapi).toBe("3.0.4"); + await expectValidAs(v30, "3.0"); + expect(v30).toMatchSnapshot("v3.0"); + + expect(kitchenSink).toEqual(before); + }); +}); diff --git a/packages/downgrader/tsconfig.json b/packages/downgrader/tsconfig.json new file mode 100644 index 0000000..4a7f724 --- /dev/null +++ b/packages/downgrader/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [{ "path": "../types" }], + "compilerOptions": { + "outDir": "out", + "tsBuildInfoFile": "out/.tsbuildinfo" + }, + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd50227..b25ec45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: .: devDependencies: + '@seriousme/openapi-schema-validator': + specifier: ^2.9.1 + version: 2.9.1 '@types/node': specifier: ^26.4.0 version: 26.4.0 @@ -143,6 +146,12 @@ importers: specifier: ^4.1.11 version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)(yaml@2.9.0)) + packages/downgrader: + dependencies: + '@oasty/types': + specifier: workspace:^ + version: link:../types + packages/types: {} packages: @@ -919,6 +928,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@seriousme/openapi-schema-validator@2.9.1': + resolution: {integrity: sha512-EgGqVIP8xiKHmNTHWbrxec+RhD/WPUay7D/erEc7vWoZKxTT9f2aCEu1egKVpxcEbT1z0wdAbWFR9o2Is5FJEw==} + hasBin: true + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -984,6 +997,25 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1195,6 +1227,9 @@ packages: exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1205,6 +1240,9 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -1336,6 +1374,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -1763,6 +1804,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -2548,6 +2593,13 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@seriousme/openapi-schema-validator@2.9.1': + dependencies: + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-formats: 3.0.1(ajv@8.20.0) + yaml: 2.9.0 + '@sindresorhus/merge-streams@4.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -2624,6 +2676,21 @@ snapshots: acorn@8.18.0: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -2871,6 +2938,8 @@ snapshots: exsolve@1.1.1: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2885,6 +2954,8 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.6: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -2994,6 +3065,8 @@ snapshots: js-tokens@10.0.0: {} + json-schema-traverse@1.0.0: {} + jsonc-parser@3.3.1: {} knitwork@1.3.0: {} @@ -3396,6 +3469,8 @@ snapshots: queue-microtask@1.2.3: {} + require-from-string@2.0.2: {} + resolve.exports@2.0.3: {} resolve@1.22.12: