From c5fe13c6ecc13dfb65448015806a7ab6aa3013c3 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 29 Aug 2026 15:41:09 +0700 Subject: [PATCH 1/7] feat(downgrader): add @oasty/downgrader with 3.2-to-3.1 and 3.1-to-3.0 converters --- package.json | 1 + packages/downgrader/README.md | 83 + packages/downgrader/package.json | 67 + packages/downgrader/src/index.ts | 2 + packages/downgrader/src/shared.test.ts | 220 ++ packages/downgrader/src/shared.ts | 119 ++ packages/downgrader/src/v3.1-to-v3.0.test.ts | 1471 +++++++++++++ packages/downgrader/src/v3.1-to-v3.0.ts | 881 ++++++++ packages/downgrader/src/v3.2-to-v3.1.test.ts | 1833 +++++++++++++++++ packages/downgrader/src/v3.2-to-v3.1.ts | 696 +++++++ .../tests/__snapshots__/e2e.test.ts.snap | 954 +++++++++ packages/downgrader/tests/corpus.test.ts | 260 +++ packages/downgrader/tests/e2e.test.ts | 460 +++++ packages/downgrader/tsconfig.json | 12 + pnpm-lock.yaml | 75 + 15 files changed, 7134 insertions(+) create mode 100644 packages/downgrader/README.md create mode 100644 packages/downgrader/package.json create mode 100644 packages/downgrader/src/index.ts create mode 100644 packages/downgrader/src/shared.test.ts create mode 100644 packages/downgrader/src/shared.ts create mode 100644 packages/downgrader/src/v3.1-to-v3.0.test.ts create mode 100644 packages/downgrader/src/v3.1-to-v3.0.ts create mode 100644 packages/downgrader/src/v3.2-to-v3.1.test.ts create mode 100644 packages/downgrader/src/v3.2-to-v3.1.ts create mode 100644 packages/downgrader/tests/__snapshots__/e2e.test.ts.snap create mode 100644 packages/downgrader/tests/corpus.test.ts create mode 100644 packages/downgrader/tests/e2e.test.ts create mode 100644 packages/downgrader/tsconfig.json diff --git a/package.json b/package.json index a7aedc4..e79861a 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" }, "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..3bd6821 --- /dev/null +++ b/packages/downgrader/README.md @@ -0,0 +1,83 @@ +# @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. +- **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: 3.2 keeps the exact 3.1 schema dialect, and JSON Schema allows arbitrary extra keywords without an `x-` prefix, so even the 3.2-only OAS vocabulary fields (discriminator `defaultMapping`, XML `nodeType`) are legal to keep as-is. + +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 | +| 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`), `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 together with the security requirements referencing them. + +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` | +| `type: "null"` | `nullable: true` plus `enum: [null]` | +| `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`, `propertyNames`, `unevaluatedItems`/`unevaluatedProperties`, `contentSchema` | removed — dropping these only loosens validation, which is the safe direction for a downgrade | + +## 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..7238436 --- /dev/null +++ b/packages/downgrader/package.json @@ -0,0 +1,67 @@ +{ + "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", + "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..5470a34 --- /dev/null +++ b/packages/downgrader/src/shared.test.ts @@ -0,0 +1,220 @@ +import { + deepClone, + getRef, + isRecord, + mapArray, + mapRecord, + setKey, +} from "./shared"; +import type { UnknownRecord } from "./shared"; + +const identity = (value: T): T => value; + +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 by reference", () => { + const input = { fn: identity }; + const clone = deepClone(input); + expect(clone.fn).toBe(identity); + }); + + it("keeps class instances by reference", () => { + const date = new Date(); + const map = new Map(); + const input = { date, map }; + const clone = deepClone(input); + 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 = deepClone(input); + // SAFETY: JSON.parse produces a plain object; the cast lets the test inspect its own keys. + const cloneRecord = clone as UnknownRecord; + expect(Object.getOwnPropertyNames(cloneRecord)).toContain("__proto__"); + const descriptor = Object.getOwnPropertyDescriptor( + cloneRecord, + "__proto__" + ); + expect(descriptor?.value).toEqual({ polluted: true }); + expect(Object.getPrototypeOf(cloneRecord)).toBe(Object.prototype); + // SAFETY: probing an arbitrary key on a fresh object to prove Object.prototype was not polluted. + expect(({} as UnknownRecord).polluted).toBeUndefined(); + }); + + it("preserves key order", () => { + const input: UnknownRecord = {}; + input.zebra = 1; + input.apple = 2; + input.mango = 3; + const clone = deepClone(input); + expect(Object.keys(clone)).toEqual(["zebra", "apple", "mango"]); + }); +}); + +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("preserves key order", () => { + const input: UnknownRecord = {}; + input.zebra = 1; + input.apple = 2; + const result = mapRecord(input, (item) => item); + // SAFETY: mapRecord returns a plain object for plain-object input. + expect(Object.keys(result as UnknownRecord)).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(convert).not.toHaveBeenCalled(); + 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("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(convert).not.toHaveBeenCalled(); + 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", () => { + expect(getRef({})).toBeUndefined(); + expect(getRef({ ref: "#/x" })).toBeUndefined(); + }); + + it("returns undefined when $ref is not a string", () => { + 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"); + const descriptor = Object.getOwnPropertyDescriptor(target, "name"); + expect(descriptor).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); + // SAFETY: probing an arbitrary key on a fresh object to prove Object.prototype was not polluted. + expect(({} as UnknownRecord).polluted).toBeUndefined(); + }); +}); diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts new file mode 100644 index 0000000..a8972fe --- /dev/null +++ b/packages/downgrader/src/shared.ts @@ -0,0 +1,119 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, 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; + +/** + * 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; +}; + +/** + * A JSON-oriented deep clone that never throws: non-plain values (class + * instances, functions, ...) are kept by reference, and hostile keys like + * `__proto__` are copied as own data properties instead of being assigned. + */ +export const deepClone = (value: T): T => { + if (Array.isArray(value)) { + // SAFETY: mapping an array element-wise preserves its runtime shape. + return value.map((item) => deepClone(item)) as T; + } + if (isRecord(value)) { + // SAFETY: rebuilding a plain object entry-wise preserves its runtime shape. + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, deepClone(item)]) + ) as T; + } + return value; +}; + +/** + * Applies `convert` to every value of a plain object, preserving key order. + * 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); + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, convert(item, key)]) + ); +}; + +/** + * Applies `convert` to every element of an array. 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)); +}; + +/** + * Deep-clones a plain object without the given keys, the building block for + * removing fields the target version cannot express. Non-object input is + * deep-cloned unchanged. + */ +export const omitKeys = ( + value: unknown, + keys: ReadonlySet +): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !keys.has(key)) + .map(([key, item]) => [key, deepClone(item)]) + ); +}; + +/** + * 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; +}; + +/** + * 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, + }); +}; 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..6b611e3 --- /dev/null +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -0,0 +1,1471 @@ +/* oxlint-disable anti-slop/no-unknown-parameters -- the cast helpers deliberately accept `unknown` so tests can feed malformed input to the graceful-degradation branches */ + +import type { OpenAPIV3_1 } from "@oasty/types"; + +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 info = { title: "t", version: "1" }; + +describe("downgradeSpecV31ToV30", () => { + describe("version and top-level keys", () => { + it("rewrites the openapi version to 3.0.4", () => { + expect( + downgradeSpecV31ToV30({ info, openapi: "3.1.1", paths: {} }) + ).toEqual({ info, openapi: "3.0.4", paths: {} }); + }); + + it("adds openapi 3.0.4 when the openapi key is missing", () => { + expect(downgradeSpecV31ToV30(asSpec({ info, paths: {} }))).toEqual({ + info, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("clones non-object spec input unchanged", () => { + expect(downgradeSpecV31ToV30(asSpec(null))).toBeNull(); + expect(downgradeSpecV31ToV30(asSpec(42))).toBe(42); + expect(downgradeSpecV31ToV30(asSpec("spec"))).toBe("spec"); + }); + + it("clones array spec input into a new array", () => { + const input = [1, { a: 1 }]; + const result = downgradeSpecV31ToV30(asSpec(input)); + expect(result).toEqual(input); + expect(result).not.toBe(input); + }); + + it("drops jsonSchemaDialect", () => { + expect( + downgradeSpecV31ToV30({ + info, + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ info, openapi: "3.0.4", paths: {} }); + }); + + it("preserves unknown top-level keys and extensions", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + future: { a: 1 }, + info, + openapi: "3.1.0", + paths: {}, + "x-root": true, + }) + ) + ).toEqual({ + future: { a: 1 }, + info, + openapi: "3.0.4", + paths: {}, + "x-root": true, + }); + }); + }); + + describe("info", () => { + it("removes info.summary", () => { + expect( + downgradeSpecV31ToV30({ + info: { summary: "short", title: "t", version: "1" }, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + info: { title: "t", version: "1" }, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("removes license.identifier and keeps the other license fields", () => { + expect( + downgradeSpecV31ToV30({ + info: { + license: { + identifier: "MIT", + name: "MIT", + url: "https://opensource.org/license/mit", + }, + title: "t", + version: "1", + }, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + info: { + license: { name: "MIT", url: "https://opensource.org/license/mit" }, + title: "t", + version: "1", + }, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("clones malformed info unchanged", () => { + expect( + downgradeSpecV31ToV30(asSpec({ info: 42, openapi: "3.1.0", paths: {} })) + ).toEqual({ info: 42, openapi: "3.0.4", paths: {} }); + }); + + it("clones a malformed license unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + info: { license: "MIT", title: "t", version: "1" }, + openapi: "3.1.0", + paths: {}, + }) + ) + ).toEqual({ + info: { license: "MIT", title: "t", version: "1" }, + openapi: "3.0.4", + paths: {}, + }); + }); + }); + + describe("paths", () => { + it("adds an empty paths object when missing", () => { + expect(downgradeSpecV31ToV30({ info, openapi: "3.1.0" })).toEqual({ + info, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("converts path items and clones non-path keys in paths", () => { + expect( + downgradeSpecV31ToV30({ + info, + openapi: "3.1.0", + paths: { + "/a": { get: { summary: "s" } }, + "x-note": { keep: true }, + }, + }) + ).toEqual({ + info, + openapi: "3.0.4", + paths: { + "/a": { + get: { + responses: { default: { description: "" } }, + summary: "s", + }, + }, + "x-note": { keep: true }, + }, + }); + }); + + it("clones a malformed paths value unchanged", () => { + expect( + downgradeSpecV31ToV30(asSpec({ info, openapi: "3.1.0", paths: "junk" })) + ).toEqual({ info, openapi: "3.0.4", paths: "junk" }); + }); + }); + + describe("webhooks", () => { + it("removes webhooks entirely", () => { + const result = downgradeSpecV31ToV30({ + info, + openapi: "3.1.0", + webhooks: { newPet: { post: { summary: "s" } } }, + }); + expect(result).toEqual({ info, openapi: "3.0.4", paths: {} }); + expect(result).not.toHaveProperty("webhooks"); + expect(result).not.toHaveProperty("x-webhooks"); + }); + }); + + describe("components.pathItems", () => { + it("removes components.pathItems and keeps the other component maps", () => { + const result = downgradeSpecV31ToV30({ + components: { + pathItems: { Reusable: { get: { summary: "s" } } }, + schemas: { S: { type: "string" } }, + }, + info, + openapi: "3.1.0", + paths: {}, + }); + expect(result).toEqual({ + components: { schemas: { S: { type: "string" } } }, + info, + openapi: "3.0.4", + paths: {}, + }); + expect(result.components).not.toHaveProperty("pathItems"); + expect(result.components).not.toHaveProperty("x-pathItems"); + }); + + it("leaves a path item $ref field pointing at components.pathItems untouched", () => { + expect( + downgradeSpecV31ToV30({ + info, + openapi: "3.1.0", + paths: { + "/a": { $ref: "#/components/pathItems/Reusable", summary: "s" }, + }, + }) + ).toEqual({ + info, + openapi: "3.0.4", + paths: { + "/a": { $ref: "#/components/pathItems/Reusable", summary: "s" }, + }, + }); + }); + + it("clones a non-string path item $ref unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ info, openapi: "3.1.0", paths: { "/a": { $ref: 42 } } }) + ) + ).toEqual({ info, openapi: "3.0.4", paths: { "/a": { $ref: 42 } } }); + }); + + it("leaves reference objects pointing at components.pathItems untouched apart from override stripping", () => { + expect( + downgradeSpecV31ToV30({ + components: { + callbacks: { + cb: { $ref: "#/components/pathItems/Reusable", summary: "s" }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + components: { + callbacks: { cb: { $ref: "#/components/pathItems/Reusable" } }, + }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + }); + + describe("reference objects", () => { + it("strips reference summary and description across components maps", () => { + expect( + downgradeSpecV31ToV30({ + 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" } }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + components: { + 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" } }, + }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("strips reference overrides inside operations and path items", () => { + expect( + downgradeSpecV31ToV30({ + info, + openapi: "3.1.0", + paths: { + "/a": { + 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({ + info, + openapi: "3.0.4", + paths: { + "/a": { + 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( + downgradeSpecV31ToV30({ + components: { + responses: { + R: { + 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" } }, + }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + components: { + responses: { + R: { + 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" } }, + }, + }, + }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("keeps x- entries in a responses map unconverted", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + info, + openapi: "3.1.0", + paths: { + "/a": { + get: { + responses: { + "200": { description: "ok" }, + "x-note": { $ref: "#/c/r", summary: "s" }, + }, + }, + }, + }, + }) + ) + ).toEqual({ + info, + openapi: "3.0.4", + paths: { + "/a": { + get: { + responses: { + "200": { description: "ok" }, + "x-note": { $ref: "#/c/r", summary: "s" }, + }, + }, + }, + }, + }); + }); + }); + + describe("operations", () => { + it("synthesizes a minimal default responses object when an operation lacks one", () => { + expect( + downgradeSpecV31ToV30({ + info, + openapi: "3.1.0", + paths: { "/a": { get: { operationId: "getA" } } }, + }) + ).toEqual({ + info, + openapi: "3.0.4", + paths: { + "/a": { + get: { + operationId: "getA", + responses: { default: { description: "" } }, + }, + }, + }, + }); + }); + + it("clones a malformed operation unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + info, + openapi: "3.1.0", + paths: { "/a": { get: "junk" } }, + }) + ) + ).toEqual({ + info, + openapi: "3.0.4", + paths: { "/a": { get: "junk" } }, + }); + }); + + it("converts parameter schemas, content, and examples", () => { + expect( + downgradeSpecV31ToV30({ + components: { + parameters: { + P: { + examples: { e: { $ref: "#/c/e", summary: "s" } }, + in: "query", + name: "p", + schema: { type: ["string", "null"] }, + }, + Q: { + content: { + "text/plain": { schema: { type: ["integer", "null"] } }, + }, + in: "query", + name: "q", + }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + components: { + parameters: { + P: { + examples: { e: { $ref: "#/c/e" } }, + in: "query", + name: "p", + schema: { nullable: true, type: "string" }, + }, + Q: { + content: { + "text/plain": { + schema: { nullable: true, type: "integer" }, + }, + }, + in: "query", + name: "q", + }, + }, + }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("converts request body content, media type encoding, and encoding headers", () => { + expect( + downgradeSpecV31ToV30({ + components: { + requestBodies: { + B: { + 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, + }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + components: { + requestBodies: { + B: { + 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, + }, + }, + }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + + it("converts schemas under components.schemas including boolean schemas", () => { + expect( + downgradeSpecV31ToV30({ + components: { + schemas: { S: { type: ["string", "null"] }, T: true }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ).toEqual({ + components: { + schemas: { S: { nullable: true, type: "string" }, T: {} }, + }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + }); + + describe("security", () => { + it("removes mutualTLS schemes and drops requirements that become empty", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + mtls: { type: "mutualTLS" }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + security: [{ mtls: [] }, { api: [], mtls: [] }, {}], + }) + ) + ).toEqual({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + }, + }, + info, + openapi: "3.0.4", + paths: {}, + security: [{ api: [] }, {}], + }); + }); + + it("empties roles on non-OAuth schemes", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + basic: { scheme: "basic", type: "http" }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + security: [{ api: ["read"], basic: ["admin"] }], + }) + ) + ).toEqual({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + basic: { scheme: "basic", type: "http" }, + }, + }, + info, + openapi: "3.0.4", + paths: {}, + security: [{ api: [], basic: [] }], + }); + }); + + it("keeps scopes for oauth2, openIdConnect, and unknown schemes", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + components: { + securitySchemes: { + oauth: { flows: {}, type: "oauth2" }, + oidc: { openIdConnectUrl: "https://x", type: "openIdConnect" }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + security: [{ oauth: ["read"], oidc: ["a"], unknownScheme: ["s"] }], + }) + ) + ).toEqual({ + components: { + securitySchemes: { + oauth: { flows: {}, type: "oauth2" }, + oidc: { openIdConnectUrl: "https://x", type: "openIdConnect" }, + }, + }, + info, + openapi: "3.0.4", + paths: {}, + security: [{ oauth: ["read"], oidc: ["a"], unknownScheme: ["s"] }], + }); + }); + + it("converts operation-level security lists", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + mtls: { type: "mutualTLS" }, + }, + }, + info, + openapi: "3.1.0", + paths: { + "/a": { + get: { + responses: {}, + security: [{ mtls: [] }, { api: ["read"] }], + }, + }, + }, + }) + ) + ).toEqual({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + }, + }, + info, + openapi: "3.0.4", + paths: { + "/a": { + get: { responses: {}, security: [{ api: [] }] }, + }, + }, + }); + }); + + it("clones non-record security requirements unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + info, + openapi: "3.1.0", + paths: {}, + security: [{ api: [] }, "junk", 42], + }) + ) + ).toEqual({ + info, + openapi: "3.0.4", + paths: {}, + security: [{ api: [] }, "junk", 42], + }); + }); + + it("clones a non-array security value unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ info, openapi: "3.1.0", paths: {}, security: { api: [] } }) + ) + ).toEqual({ + info, + openapi: "3.0.4", + paths: {}, + security: { api: [] }, + }); + }); + + it("clones a malformed securitySchemes value unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ + components: { securitySchemes: "junk" }, + info, + openapi: "3.1.0", + paths: {}, + }) + ) + ).toEqual({ + components: { securitySchemes: "junk" }, + info, + openapi: "3.0.4", + paths: {}, + }); + }); + }); + + describe("input non-mutation", () => { + 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); + }); + }); +}); + +describe("downgradeSchemaV31ToV30", () => { + describe("boolean and junk schemas", () => { + it("converts the true schema to an empty object", () => { + expect(downgradeSchemaV31ToV30(true)).toEqual({}); + }); + + it("converts the false schema to a match-nothing object", () => { + expect(downgradeSchemaV31ToV30(false)).toEqual({ not: {} }); + }); + + it("clones junk primitive inputs unchanged", () => { + expect(downgradeSchemaV31ToV30(asSchema(null))).toBeNull(); + expect(downgradeSchemaV31ToV30(asSchema(42))).toBe(42); + expect(downgradeSchemaV31ToV30(asSchema("x"))).toBe("x"); + }); + + it("clones array inputs into a new array", () => { + const input = [{ type: "string" }]; + const result = downgradeSchemaV31ToV30(asSchema(input)); + expect(result).toEqual(input); + expect(result).not.toBe(input); + }); + }); + + describe("$ref handling", () => { + it("keeps a pure $ref as a bare reference object", () => { + const input = { $ref: "#/components/schemas/Pet" }; + const result = downgradeSchemaV31ToV30(input); + expect(result).toEqual({ $ref: "#/components/schemas/Pet" }); + expect(result).not.toBe(input); + }); + + it("leaves a schema $ref pointing at components.pathItems untouched", () => { + expect( + downgradeSchemaV31ToV30({ $ref: "#/components/pathItems/Foo" }) + ).toEqual({ $ref: "#/components/pathItems/Foo" }); + }); + + it("wraps a $ref with sibling keywords into allOf", () => { + expect(downgradeSchemaV31ToV30({ $ref: "#/c/s", minLength: 1 })).toEqual({ + allOf: [{ $ref: "#/c/s" }], + minLength: 1, + }); + }); + + it("merges a $ref into an existing allOf", () => { + expect( + downgradeSchemaV31ToV30({ $ref: "#/c/s", allOf: [{ type: "string" }] }) + ).toEqual({ allOf: [{ $ref: "#/c/s" }, { type: "string" }] }); + }); + }); + + describe("type", () => { + it("keeps a single string type", () => { + expect(downgradeSchemaV31ToV30({ type: "string" })).toEqual({ + type: "string", + }); + }); + + it("converts a type array with null into type plus nullable", () => { + expect(downgradeSchemaV31ToV30({ type: ["string", "null"] })).toEqual({ + nullable: true, + type: "string", + }); + }); + + it("converts a null-only type into nullable plus a null enum", () => { + expect(downgradeSchemaV31ToV30({ type: ["null"] })).toEqual({ + enum: [null], + nullable: true, + }); + expect(downgradeSchemaV31ToV30({ type: "null" })).toEqual({ + enum: [null], + nullable: true, + }); + }); + + it("does not overwrite an existing enum for a null-only type", () => { + expect( + downgradeSchemaV31ToV30({ enum: ["a", null], type: ["null"] }) + ).toEqual({ enum: ["a", null], nullable: true }); + }); + + it("lets const provide the enum for a null-only type", () => { + expect(downgradeSchemaV31ToV30({ const: 7, type: ["null"] })).toEqual({ + enum: [7], + nullable: true, + }); + }); + + it("converts multiple non-null types into anyOf variants", () => { + expect(downgradeSchemaV31ToV30({ type: ["string", "integer"] })).toEqual({ + anyOf: [{ type: "string" }, { type: "integer" }], + }); + }); + + it("converts multiple types with null into nullable anyOf variants", () => { + expect( + downgradeSchemaV31ToV30({ type: ["string", "integer", "null"] }) + ).toEqual({ + anyOf: [ + { nullable: true, type: "string" }, + { nullable: true, type: "integer" }, + ], + }); + }); + + it("wraps the type union into allOf when anyOf already exists", () => { + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ minLength: 1 }], + type: ["string", "integer"], + }) + ).toEqual({ + allOf: [{ anyOf: [{ type: "string" }, { type: "integer" }] }], + anyOf: [{ minLength: 1 }], + }); + }); + + it("appends the type union to an existing allOf when anyOf also exists", () => { + expect( + downgradeSchemaV31ToV30({ + allOf: [{ title: "t" }], + anyOf: [{ minLength: 1 }], + type: ["string", "integer"], + }) + ).toEqual({ + allOf: [ + { title: "t" }, + { anyOf: [{ type: "string" }, { type: "integer" }] }, + ], + anyOf: [{ minLength: 1 }], + }); + }); + + it("passes junk type values through unchanged", () => { + expect(downgradeSchemaV31ToV30(asSchema({ type: 42 }))).toEqual({ + type: 42, + }); + expect(downgradeSchemaV31ToV30(asSchema({ type: { a: 1 } }))).toEqual({ + type: { a: 1 }, + }); + }); + + it("deduplicates and filters non-string entries in type arrays", () => { + expect( + downgradeSchemaV31ToV30(asSchema({ type: ["string", "string"] })) + ).toEqual({ type: "string" }); + expect( + downgradeSchemaV31ToV30(asSchema({ type: ["string", 42] })) + ).toEqual({ type: "string" }); + expect(downgradeSchemaV31ToV30(asSchema({ type: [42] }))).toEqual({}); + }); + + it("drops an empty type array", () => { + expect(downgradeSchemaV31ToV30({ type: [] })).toEqual({}); + }); + }); + + describe("const", () => { + it("converts const into a single-value enum", () => { + expect(downgradeSchemaV31ToV30({ const: "a" })).toEqual({ enum: ["a"] }); + }); + + it("converts falsy const values", () => { + expect(downgradeSchemaV31ToV30({ const: 0 })).toEqual({ enum: [0] }); + expect(downgradeSchemaV31ToV30({ const: false })).toEqual({ + enum: [false], + }); + expect(downgradeSchemaV31ToV30({ const: "" })).toEqual({ enum: [""] }); + }); + + it("converts a null const and marks the schema nullable", () => { + expect(downgradeSchemaV31ToV30({ const: null })).toEqual({ + enum: [null], + nullable: true, + }); + }); + + it("replaces an existing enum with the const value", () => { + expect(downgradeSchemaV31ToV30({ const: 5, enum: [1, 2] })).toEqual({ + enum: [5], + }); + }); + }); + + describe("exclusive bounds", () => { + it("converts a numeric exclusiveMinimum into minimum plus flag", () => { + expect(downgradeSchemaV31ToV30({ exclusiveMinimum: 3 })).toEqual({ + exclusiveMinimum: true, + minimum: 3, + }); + }); + + it("keeps a tighter inclusive minimum and drops the exclusive one", () => { + expect( + downgradeSchemaV31ToV30({ exclusiveMinimum: 3, minimum: 5 }) + ).toEqual({ minimum: 5 }); + }); + + it("overrides a looser inclusive minimum with the exclusive bound", () => { + expect( + downgradeSchemaV31ToV30({ exclusiveMinimum: 5, minimum: 3 }) + ).toEqual({ exclusiveMinimum: true, minimum: 5 }); + }); + + it("prefers the exclusive form for equal minimum bounds", () => { + expect( + downgradeSchemaV31ToV30({ exclusiveMinimum: 3, minimum: 3 }) + ).toEqual({ exclusiveMinimum: true, minimum: 3 }); + }); + + it("converts a numeric exclusiveMaximum into maximum plus flag", () => { + expect(downgradeSchemaV31ToV30({ exclusiveMaximum: 10 })).toEqual({ + exclusiveMaximum: true, + maximum: 10, + }); + }); + + it("keeps a tighter inclusive maximum and drops the exclusive one", () => { + expect( + downgradeSchemaV31ToV30({ exclusiveMaximum: 10, maximum: 5 }) + ).toEqual({ maximum: 5 }); + }); + + it("overrides a looser inclusive maximum with the exclusive bound", () => { + expect( + downgradeSchemaV31ToV30({ exclusiveMaximum: 5, maximum: 10 }) + ).toEqual({ exclusiveMaximum: true, maximum: 5 }); + }); + + it("prefers the exclusive form for equal maximum bounds", () => { + expect( + downgradeSchemaV31ToV30({ exclusiveMaximum: 5, maximum: 5 }) + ).toEqual({ exclusiveMaximum: true, maximum: 5 }); + }); + + it("passes 3.0-style boolean exclusive bounds through unchanged", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ exclusiveMinimum: true, minimum: 3 }) + ) + ).toEqual({ exclusiveMinimum: true, minimum: 3 }); + expect( + downgradeSchemaV31ToV30( + asSchema({ exclusiveMaximum: false, maximum: 3 }) + ) + ).toEqual({ exclusiveMaximum: false, maximum: 3 }); + }); + }); + + describe("examples", () => { + it("promotes the first examples entry to example", () => { + expect(downgradeSchemaV31ToV30({ examples: ["a", "b"] })).toEqual({ + example: "a", + }); + }); + + it("keeps an explicit example over the examples entries", () => { + expect( + downgradeSchemaV31ToV30({ example: "e", examples: ["a"] }) + ).toEqual({ example: "e" }); + }); + + it("drops empty examples arrays", () => { + expect(downgradeSchemaV31ToV30({ examples: [] })).toEqual({}); + }); + + it("drops non-array examples values", () => { + expect(downgradeSchemaV31ToV30(asSchema({ examples: "junk" }))).toEqual( + {} + ); + }); + }); + + describe("content keywords", () => { + it("converts contentEncoding base64 into format byte", () => { + expect(downgradeSchemaV31ToV30({ contentEncoding: "base64" })).toEqual({ + format: "byte", + }); + }); + + it("keeps an existing format over contentEncoding", () => { + expect( + downgradeSchemaV31ToV30({ + contentEncoding: "base64", + format: "custom", + }) + ).toEqual({ format: "custom" }); + }); + + it("drops other content encodings", () => { + expect(downgradeSchemaV31ToV30({ contentEncoding: "gzip" })).toEqual({}); + }); + + it("converts contentMediaType application/octet-stream into format binary", () => { + expect( + downgradeSchemaV31ToV30({ + contentMediaType: "application/octet-stream", + }) + ).toEqual({ format: "binary" }); + }); + + it("does not emit format binary when a contentEncoding is present", () => { + expect( + downgradeSchemaV31ToV30({ + contentEncoding: "gzip", + contentMediaType: "application/octet-stream", + }) + ).toEqual({}); + }); + + it("drops other content media types", () => { + expect( + downgradeSchemaV31ToV30({ contentMediaType: "image/png" }) + ).toEqual({}); + }); + + it("drops contentSchema", () => { + expect( + downgradeSchemaV31ToV30({ contentSchema: { type: "string" } }) + ).toEqual({}); + }); + }); + + describe("dropped keywords", () => { + it("removes every keyword with no 3.0 equivalent", () => { + expect( + downgradeSchemaV31ToV30({ + $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" }); + }); + }); + + describe("items and prefixItems", () => { + it("drops prefixItems together with its trailing items", () => { + expect( + downgradeSchemaV31ToV30({ + items: { type: "integer" }, + prefixItems: [{ type: "string" }], + }) + ).toEqual({}); + }); + + it("keeps and converts items when there are no prefixItems", () => { + expect( + downgradeSchemaV31ToV30({ items: { type: ["string", "null"] } }) + ).toEqual({ items: { nullable: true, type: "string" } }); + }); + + it("converts boolean items", () => { + expect(downgradeSchemaV31ToV30({ items: true })).toEqual({ items: {} }); + expect(downgradeSchemaV31ToV30({ items: false })).toEqual({ + items: { not: {} }, + }); + }); + + it("adds empty items when type array has none", () => { + expect(downgradeSchemaV31ToV30({ type: "array" })).toEqual({ + items: {}, + type: "array", + }); + expect(downgradeSchemaV31ToV30({ type: ["array", "null"] })).toEqual({ + items: {}, + nullable: true, + type: "array", + }); + }); + }); + + describe("required", () => { + it("drops an empty required array", () => { + expect(downgradeSchemaV31ToV30({ required: [] })).toEqual({}); + }); + + it("keeps a non-empty required array", () => { + expect(downgradeSchemaV31ToV30({ required: ["a"] })).toEqual({ + required: ["a"], + }); + }); + + it("clones a non-array required value unchanged", () => { + expect(downgradeSchemaV31ToV30(asSchema({ required: "junk" }))).toEqual({ + required: "junk", + }); + }); + }); + + describe("recursion", () => { + it("converts nested property schemas", () => { + expect( + downgradeSchemaV31ToV30({ + properties: { a: { type: ["string", "null"] }, b: true }, + type: "object", + }) + ).toEqual({ + properties: { a: { nullable: true, type: "string" }, b: {} }, + type: "object", + }); + }); + + it("keeps boolean additionalProperties and converts schema ones", () => { + expect(downgradeSchemaV31ToV30({ additionalProperties: true })).toEqual({ + additionalProperties: true, + }); + expect(downgradeSchemaV31ToV30({ additionalProperties: false })).toEqual({ + additionalProperties: false, + }); + expect( + downgradeSchemaV31ToV30({ + additionalProperties: { type: ["string", "null"] }, + }) + ).toEqual({ + additionalProperties: { nullable: true, type: "string" }, + }); + }); + + it("converts allOf, anyOf, oneOf, and not members", () => { + expect( + downgradeSchemaV31ToV30({ + allOf: [true], + anyOf: [{ const: 1 }], + not: false, + oneOf: [{ type: ["integer", "null"] }], + }) + ).toEqual({ + allOf: [{}], + anyOf: [{ enum: [1] }], + not: { not: {} }, + oneOf: [{ nullable: true, type: "integer" }], + }); + }); + + it("clones a non-array allOf value unchanged", () => { + expect(downgradeSchemaV31ToV30(asSchema({ allOf: "junk" }))).toEqual({ + allOf: "junk", + }); + }); + }); + + describe("extensions", () => { + it("preserves x- keys and unknown keywords", () => { + expect( + downgradeSchemaV31ToV30({ + customKeyword: "v", + title: "t", + "x-foo": { a: 1 }, + }) + ).toEqual({ customKeyword: "v", title: "t", "x-foo": { a: 1 } }); + }); + }); + + describe("input non-mutation", () => { + 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); + }); + }); +}); + +describe("graceful handling of malformed nested objects", () => { + it("clones malformed path items, parameters, request bodies, responses, media types, and encodings", () => { + const spec = asSpec({ + info, + openapi: "3.1.0", + paths: { + "/a": { + get: { requestBody: 42, responses: { "200": "junk" } }, + parameters: [42], + }, + "/b": { + post: { + requestBody: { + content: { + "application/json": "junk", + "multipart/form-data": { encoding: { field: "junk" } }, + }, + }, + responses: {}, + }, + }, + "/junk": "junk", + }, + }); + expect(downgradeSpecV31ToV30(spec)).toEqual({ + info, + openapi: "3.0.4", + paths: { + "/a": { + get: { requestBody: 42, responses: { "200": "junk" } }, + parameters: [42], + }, + "/b": { + post: { + requestBody: { + content: { + "application/json": "junk", + "multipart/form-data": { encoding: { field: "junk" } }, + }, + }, + responses: {}, + }, + }, + "/junk": "junk", + }, + }); + }); + + it("converts inline callback objects, cloning x- keys and junk entries", () => { + const spec = asSpec({ + components: { + callbacks: { + junkCallback: 42, + realCallback: { + "x-note": 1, + "{$request.body#/url}": { post: { summary: "s" } }, + }, + }, + "x-extra": { keep: true }, + }, + info, + openapi: "3.1.0", + paths: { + "/a": { + get: { + callbacks: { inline: { expr: { get: {} }, "x-k": 2 }, junk: 7 }, + responses: {}, + }, + }, + }, + }); + expect(downgradeSpecV31ToV30(spec)).toEqual({ + components: { + callbacks: { + junkCallback: 42, + realCallback: { + "x-note": 1, + "{$request.body#/url}": { + post: { + responses: { default: { description: "" } }, + summary: "s", + }, + }, + }, + }, + "x-extra": { keep: true }, + }, + info, + openapi: "3.0.4", + paths: { + "/a": { + get: { + callbacks: { + inline: { + expr: { get: { responses: { default: { description: "" } } } }, + "x-k": 2, + }, + junk: 7, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("clones a malformed components value unchanged", () => { + expect( + downgradeSpecV31ToV30( + asSpec({ components: "junk", info, openapi: "3.1.0", paths: {} }) + ) + ).toEqual({ components: "junk", info, openapi: "3.0.4", paths: {} }); + }); +}); + +describe("xml nodeType from chained 3.2 documents", () => { + it("converts nodeType attribute to attribute: true", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ type: "string", xml: { name: "n", nodeType: "attribute" } }) + ) + ).toEqual({ type: "string", xml: { attribute: true, name: "n" } }); + }); + + it("converts nodeType element on an array schema to wrapped: true", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ items: {}, type: "array", xml: { nodeType: "element" } }) + ) + ).toEqual({ items: {}, type: "array", xml: { wrapped: true } }); + }); + + it("converts nodeType element on a nullable array schema to wrapped: true", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ type: ["array", "null"], xml: { nodeType: "element" } }) + ) + ).toEqual({ + items: {}, + nullable: true, + type: "array", + xml: { wrapped: true }, + }); + }); + + it("removes nodeType element on non-array schemas", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ type: "string", xml: { nodeType: "element" } }) + ) + ).toEqual({ type: "string", xml: {} }); + }); + + it("removes inexpressible nodeType values", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ type: "string", xml: { name: "n", nodeType: "text" } }) + ) + ).toEqual({ type: "string", xml: { name: "n" } }); + }); + + it("clones xml objects without nodeType and malformed xml values unchanged", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ type: "string", xml: { attribute: true, name: "n" } }) + ) + ).toEqual({ type: "string", xml: { attribute: true, name: "n" } }); + expect( + downgradeSchemaV31ToV30(asSchema({ type: "string", xml: "junk" })) + ).toEqual({ type: "string", xml: "junk" }); + }); +}); 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..917c46f --- /dev/null +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -0,0 +1,881 @@ +/* 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, 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 { UnknownRecord } from "./shared"; +import { + deepClone, + getRef, + isRecord, + mapArray, + mapRecord, + omitKeys, + setKey, +} from "./shared"; + +const HTTP_METHODS = new Set([ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +]); + +/** + * JSON Schema keywords with no OpenAPI 3.0 equivalent. Dropping them only + * loosens validation, which is the safe direction for a downgrade. + */ +const DROPPED_SCHEMA_KEYWORDS = new Set([ + "$anchor", + "$comment", + "$defs", + "$dynamicAnchor", + "$dynamicRef", + "$id", + "$schema", + "$vocabulary", + "contains", + "contentSchema", + "dependentRequired", + "dependentSchemas", + "else", + "if", + "maxContains", + "minContains", + "patternProperties", + "prefixItems", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", +]); + +/** Keywords converted after the main loop, from their raw values. */ +const POSTPROCESSED_SCHEMA_KEYWORDS = new Set([ + "const", + "contentEncoding", + "contentMediaType", + "examples", + "type", +]); + +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; +} + +/** + * Strips a 3.1 Reference Object down to its bare `$ref` (3.0 references + * allow no `summary`/`description` overrides), or converts the value. + */ +const convertRefOr = ( + value: unknown, + convert: (item: unknown) => unknown +): unknown => { + const ref = getRef(value); + if (ref !== undefined) { + return { $ref: ref }; + } + return convert(value); +}; + +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. + out.nullable = true; + if (!("enum" in schema) && !("const" 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) => + nullable ? { nullable: true, type: item } : { type: item } + ); + if (out.anyOf === undefined) { + out.anyOf = variants; + } else { + out.allOf = [ + ...(Array.isArray(out.allOf) ? out.allOf : []), + { anyOf: variants }, + ]; + } +}; + +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"))]; + 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 => { + if (!("examples" in schema)) { + return; + } + // 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 (a legal extra key in 3.1, but 3.0 forbids unknown XML Object + * fields): it maps back to the `attribute`/`wrapped` flags where expressible + * and is removed. + */ +const convertXml = (value: unknown, schemaType: unknown): unknown => { + if (!isRecord(value) || !("nodeType" in value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== "nodeType") { + setKey(out, key, deepClone(item)); + } + } + if (value.nodeType === "attribute") { + out.attribute = true; + } else if ( + value.nodeType === "element" && + (schemaType === "array" || + (Array.isArray(schemaType) && schemaType.includes("array"))) + ) { + out.wrapped = true; + } + return out; +}; + +const convertSchema = (schema: unknown): unknown => { + if (schema === true) { + return {}; + } + if (schema === false) { + return { not: {} }; + } + if (!isRecord(schema)) { + return deepClone(schema); + } + const ref = schema.$ref; + if (typeof ref === "string" && Object.keys(schema).length === 1) { + return { $ref: ref }; + } + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertSchemaFields, as schemas are recursive structures + const out = convertSchemaFields(schema); + if (typeof ref === "string") { + // 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 : [])]; + } + return out; +}; + +/** Converts the keywords holding nested schemas; returns whether `key` was one. */ +const convertSubschemaKeyword = ( + out: UnknownRecord, + key: string, + value: unknown, + hasPrefixItems: boolean +): boolean => { + switch (key) { + case "allOf": + case "anyOf": + case "oneOf": { + setKey(out, key, mapArray(value, convertSchema)); + return true; + } + case "items": { + // With `prefixItems` dropped, a trailing `items` would wrongly + // constrain every item, so it is dropped alongside. + if (!hasPrefixItems) { + setKey(out, key, convertSchema(value)); + } + return true; + } + case "not": { + setKey(out, key, convertSchema(value)); + return true; + } + case "properties": { + setKey(out, key, mapRecord(value, convertSchema)); + return true; + } + case "additionalProperties": { + setKey( + out, + key, + typeof value === "boolean" ? value : convertSchema(value) + ); + return true; + } + default: { + return false; + } + } +}; + +const convertSchemaFields = (schema: UnknownRecord): UnknownRecord => { + const out: UnknownRecord = {}; + const hasPrefixItems = "prefixItems" in schema; + + for (const [key, value] of Object.entries(schema)) { + if ( + key === "$ref" || + DROPPED_SCHEMA_KEYWORDS.has(key) || + POSTPROCESSED_SCHEMA_KEYWORDS.has(key) + ) { + continue; + } + if (convertSubschemaKeyword(out, key, value, hasPrefixItems)) { + continue; + } + if ( + (key === "exclusiveMaximum" || key === "exclusiveMinimum") && + typeof value === "number" + ) { + // Converted after the loop; 3.0-style booleans (invalid in 3.1, but + // accepted gracefully) pass through the default clone below. + continue; + } + if (key === "required" && Array.isArray(value) && value.length === 0) { + // 3.0 requires the array to be non-empty. + continue; + } + if (key === "xml") { + setKey(out, key, convertXml(value, schema.type)); + continue; + } + setKey(out, key, deepClone(value)); + } + + 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 = {}; + } + return out; +}; + +/** + * 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 indexSecuritySchemes = (spec: UnknownRecord): SecuritySchemeIndex => { + const types = new Map(); + const mutualTls = new Set(); + const schemes = isRecord(spec.components) + ? spec.components.securitySchemes + : undefined; + if (isRecord(schemes)) { + for (const [name, scheme] of Object.entries(schemes)) { + if (isRecord(scheme) && typeof scheme.type === "string") { + types.set(name, scheme.type); + if (scheme.type === "mutualTLS") { + mutualTls.add(name); + } + } + } + } + return { mutualTls, types }; +}; + +const convertSecurityList = ( + value: unknown, + index: SecuritySchemeIndex +): unknown => { + if (!Array.isArray(value)) { + return deepClone(value); + } + const out: unknown[] = []; + for (const requirement of value) { + if (!isRecord(requirement)) { + out.push(deepClone(requirement)); + continue; + } + const converted: UnknownRecord = {}; + for (const [name, scopes] of Object.entries(requirement)) { + if (index.mutualTls.has(name)) { + continue; + } + const type = index.types.get(name); + const supportsScopes = + type === undefined || type === "oauth2" || type === "openIdConnect"; + if (!supportsScopes && Array.isArray(scopes) && scopes.length > 0) { + // 3.0 allows roles only on OAuth-family schemes. + setKey(converted, name, []); + continue; + } + setKey(converted, name, deepClone(scopes)); + } + // A requirement that only referenced mutualTLS schemes disappears; an + // originally empty `{}` (optional security) is kept. + if ( + Object.keys(requirement).length > 0 && + Object.keys(converted).length === 0 + ) { + continue; + } + out.push(converted); + } + return out; +}; + +/** The SPDX `identifier` has no 3.0 equivalent. */ +const DROPPED_LICENSE_KEYS = new Set(["identifier"]); + +const convertInfo = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "summary": { + // No `summary` in 3.0. + continue; + } + case "license": { + setKey(out, "license", omitKeys(item, DROPPED_LICENSE_KEYS)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +/** Parameter Objects and Header Objects share every field this converter touches. */ +const convertParameterOrHeader = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "schema": { + setKey(out, key, convertSchema(item)); + continue; + } + case "content": { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertMediaType via encoding headers + setKey(out, key, mapRecord(item, convertMediaType)); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, deepClone)) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertEncoding = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "headers") { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, convertParameterOrHeader) + ) + ); + } else { + setKey(out, key, deepClone(item)); + } + } + return out; +}; + +const convertMediaType = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "schema": { + setKey(out, key, convertSchema(item)); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, deepClone)) + ); + continue; + } + case "encoding": { + setKey(out, key, mapRecord(item, convertEncoding)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertRequestBody = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "content") { + setKey(out, key, mapRecord(item, convertMediaType)); + } else { + setKey(out, key, deepClone(item)); + } + } + return out; +}; + +const convertResponse = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "headers": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, convertParameterOrHeader) + ) + ); + continue; + } + case "content": { + setKey(out, key, mapRecord(item, convertMediaType)); + continue; + } + case "links": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, deepClone)) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertResponses = (value: unknown): unknown => + mapRecord(value, (item, key) => + key.startsWith("x-") ? deepClone(item) : convertRefOr(item, convertResponse) + ); + +const convertOperation = ( + value: unknown, + index: SecuritySchemeIndex +): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "parameters": { + setKey( + out, + key, + mapArray(item, (entry) => + convertRefOr(entry, convertParameterOrHeader) + ) + ); + continue; + } + case "requestBody": { + setKey(out, key, convertRefOr(item, convertRequestBody)); + continue; + } + case "responses": { + setKey(out, key, convertResponses(item)); + continue; + } + case "callbacks": { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertCallback, as callbacks contain path items + const convert = convertCallback; + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, (inner) => convert(inner, index)) + ) + ); + continue; + } + case "security": { + setKey(out, key, convertSecurityList(item, index)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + 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. + setKey(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 => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (HTTP_METHODS.has(key)) { + setKey(out, key, convertOperation(item, index)); + continue; + } + switch (key) { + case "parameters": { + setKey( + out, + key, + mapArray(item, (entry) => + convertRefOr(entry, convertParameterOrHeader) + ) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +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 => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "schemas": { + setKey(out, key, mapRecord(item, convertSchema)); + continue; + } + case "responses": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, convertResponse)) + ); + continue; + } + case "parameters": + case "headers": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, convertParameterOrHeader) + ) + ); + continue; + } + case "examples": + case "links": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, deepClone)) + ); + continue; + } + case "requestBodies": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, convertRequestBody)) + ); + continue; + } + case "securitySchemes": { + const schemes: UnknownRecord = {}; + if (isRecord(item)) { + for (const [name, scheme] of Object.entries(item)) { + if (!index.mutualTls.has(name)) { + setKey(schemes, name, convertRefOr(scheme, deepClone)); + } + } + setKey(out, key, schemes); + } else { + setKey(out, key, deepClone(item)); + } + continue; + } + case "callbacks": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, (inner) => convertCallback(inner, index)) + ) + ); + continue; + } + case "pathItems": { + // 3.0 has no reusable path items. + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertSpec = (spec: unknown): unknown => { + if (!isRecord(spec)) { + return deepClone(spec); + } + const index = indexSecuritySchemes(spec); + const out: UnknownRecord = {}; + for (const [key, value] of Object.entries(spec)) { + switch (key) { + case "openapi": { + setKey(out, "openapi", "3.0.4"); + continue; + } + case "jsonSchemaDialect": { + // 3.0 has no schema-dialect selection. + continue; + } + case "info": { + setKey(out, "info", convertInfo(value)); + continue; + } + case "paths": { + setKey(out, "paths", convertPaths(value, index)); + continue; + } + case "webhooks": { + // No webhooks in 3.0. + continue; + } + case "components": { + setKey(out, "components", convertComponents(value, index)); + continue; + } + case "security": { + setKey(out, "security", convertSecurityList(value, index)); + continue; + } + default: { + setKey(out, key, deepClone(value)); + } + } + } + if (out.openapi === undefined) { + setKey(out, "openapi", "3.0.4"); + } + if (out.paths === undefined) { + // Required in 3.0; an empty Paths Object is valid. + setKey(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..b4bb181 --- /dev/null +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -0,0 +1,1833 @@ +/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-unsafe-dictionary-type -- these tests feed deliberately malformed OpenAPI documents through the converters and inspect loosely-typed output, mirroring the converters' own defensive contract */ + +import type { OpenAPIV3_2 } from "@oasty/types"; +import { describe, expect, it } from "vitest"; + +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 Record)[key]; + } + return current; +}; + +describe("document version", () => { + it("rewrites the openapi field to 3.1.2", () => { + const result = downgradeSpecV32ToV31({ + info: { title: "t", version: "1.0.0" }, + openapi: "3.2.0", + }); + expect(result).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", () => { + const result = downgradeSpecV32ToV31(asSpec({})); + expect(result).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]); + }); +}); + +describe("$self", () => { + it("removes $self", () => { + const result = downgradeSpecV32ToV31( + asSpec({ $self: "https://example.com/api.json", openapi: "3.2.0" }) + ); + expect(result).toEqual({ openapi: "3.1.2" }); + }); +}); + +describe("servers", () => { + it("removes server name in the root servers list", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + servers: [ + { description: "d", name: "prod", url: "https://example.com" }, + ], + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + servers: [{ description: "d", url: "https://example.com" }], + }); + }); + + it("removes server name in path item servers", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { "/a": { servers: [{ name: "s", url: "/u" }] } }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { "/a": { servers: [{ url: "/u" }] } }, + }); + }); + + it("removes server name in operation servers", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { responses: {}, servers: [{ name: "s", url: "/u" }] }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { get: { responses: {}, servers: [{ url: "/u" }] } }, + }, + }); + }); + + it("removes server name inside a link server", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + links: { + L: { operationId: "op", server: { name: "s", url: "/u" } }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + links: { L: { operationId: "op", server: { url: "/u" } } }, + }, + openapi: "3.1.2", + }); + }); + + it("clones non-array servers and non-object server entries through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { "/a": { servers: "junk" } }, + servers: [5, null], + }) + ); + expect(result).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", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + tags: [ + { + description: "d", + externalDocs: { url: "https://example.com" }, + kind: "nav", + name: "pets", + parent: "animals", + summary: "Pets", + }, + ], + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + tags: [ + { + description: "d", + externalDocs: { url: "https://example.com" }, + name: "pets", + }, + ], + }); + }); + + it("clones non-object tag entries through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ openapi: "3.2.0", tags: ["junk", 1] }) + ); + expect(result).toEqual({ openapi: "3.1.2", tags: ["junk", 1] }); + }); +}); + +describe("path item query and additionalOperations", () => { + it("removes the query operation and keeps regular operations", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { responses: {} }, + query: { description: "q", responses: {} }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { "/a": { get: { responses: {} } } }, + }); + }); + + it("removes additionalOperations whatever its shape", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { additionalOperations: { NOTIFY: { description: "n" } } }, + "/b": { additionalOperations: "junk" }, + "/c": { additionalOperations: 42, query: "junk" }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { "/a": {}, "/b": {}, "/c": {} }, + }); + }); +}); + +describe("paths", () => { + it("converts only keys starting with a slash and clones the rest", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { query: { description: "dropped" } }, + "x-meta": { query: { description: "kept" } }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": {}, + "x-meta": { query: { description: "kept" } }, + }, + }); + }); + + it("clones non-object paths and path items through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ openapi: "3.2.0", paths: { "/a": "junk" } }) + ); + expect(result).toEqual({ openapi: "3.1.2", paths: { "/a": "junk" } }); + }); +}); + +describe("parameters", () => { + it("removes querystring parameters from operation parameter lists, keeping neighbors and references", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { + parameters: [ + { + content: { "application/x-www-form-urlencoded": {} }, + in: "querystring", + name: "q", + }, + { in: "query", name: "keep" }, + { $ref: "#/components/parameters/P" }, + ], + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + get: { + parameters: [ + { in: "query", name: "keep" }, + { $ref: "#/components/parameters/P" }, + ], + responses: {}, + }, + }, + }, + }); + }); + + it("removes querystring parameters from path item parameter lists", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + parameters: [ + { in: "querystring", name: "q" }, + { in: "path", name: "id", required: true }, + ], + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { parameters: [{ in: "path", name: "id", required: true }] }, + }, + }); + }); + + it("removes querystring entries from components.parameters, keeping neighbors and reference entries", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + parameters: { + N: { in: "header", name: "h" }, + Q: { in: "querystring", name: "q" }, + R: { $ref: "#/components/parameters/N" }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + parameters: { + N: { in: "header", name: "h" }, + R: { $ref: "#/components/parameters/N" }, + }, + }, + openapi: "3.1.2", + }); + }); + + it("clones a malformed components.parameters map through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ components: { parameters: "junk" }, openapi: "3.2.0" }) + ); + expect(result).toEqual({ + components: { parameters: "junk" }, + openapi: "3.1.2", + }); + }); + + it("removes style: cookie and keeps the other fields", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + parameters: { P: { in: "cookie", name: "c", style: "cookie" } }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { parameters: { P: { in: "cookie", name: "c" } } }, + openapi: "3.1.2", + }); + }); + + it("keeps other style values", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + parameters: { + P: { in: "query", name: "q", style: "deepObject" }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + parameters: { P: { in: "query", name: "q", style: "deepObject" } }, + }, + openapi: "3.1.2", + }); + }); + + it("keeps parameter schemas verbatim and converts example maps", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + parameters: { + P: { + examples: { + inline: { dataValue: 1 }, + referenced: { $ref: "#/components/examples/E" }, + }, + in: "query", + name: "q", + schema: { type: "string", xml: { nodeType: "attribute" } }, + }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + parameters: { + P: { + examples: { + inline: { value: 1 }, + referenced: { $ref: "#/components/examples/E" }, + }, + in: "query", + name: "q", + schema: { type: "string", xml: { nodeType: "attribute" } }, + }, + }, + }, + openapi: "3.1.2", + }); + }); + + it("clones non-object parameter entries and non-array parameter lists through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { parameters: [null, "junk"] }, + "/b": { parameters: "junk" }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { parameters: [null, "junk"] }, + "/b": { parameters: "junk" }, + }, + }); + }); +}); + +describe("components.mediaTypes inlining", () => { + it("inlines a media type reference in a request body content map with the converted media type", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + mediaTypes: { Stream: { itemSchema: { type: "object" } } }, + }, + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/jsonl": { + $ref: "#/components/mediaTypes/Stream", + }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/jsonl": { + schema: { items: { type: "object" }, type: "array" }, + }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("inlines media type references in response, parameter, and header content maps", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + mediaTypes: { Json: { schema: { type: "string" } } }, + }, + openapi: "3.2.0", + paths: { + "/a": { + get: { + parameters: [ + { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Json", + }, + }, + in: "query", + name: "q", + }, + ], + responses: { + "200": { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Json", + }, + }, + description: "ok", + headers: { + "X-H": { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Json", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + ); + const inlined = { schema: { type: "string" } }; + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + get: { + parameters: [ + { + content: { "application/json": inlined }, + in: "query", + name: "q", + }, + ], + responses: { + "200": { + content: { "application/json": inlined }, + description: "ok", + headers: { + "X-H": { content: { "application/json": inlined } }, + }, + }, + }, + }, + }, + }, + }); + }); + + it("resolves chained media type references down to the final object", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + mediaTypes: { + A: { $ref: "#/components/mediaTypes/B" }, + B: { schema: { type: "number" } }, + }, + }, + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { schema: { type: "number" } }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("leaves self-referential media type reference chains as the original reference", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + mediaTypes: { Loop: { $ref: "#/components/mediaTypes/Loop" } }, + }, + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Loop", + }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Loop", + }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("leaves external and unparseable references as-is", () => { + const content = { + "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" }, + }; + const result = downgradeSpecV32ToV31( + asSpec({ + components: { mediaTypes: { Known: {} } }, + openapi: "3.2.0", + paths: { + "/a": { + post: { requestBody: { content }, responses: {} }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + post: { requestBody: { content }, responses: {} }, + }, + }, + }); + }); + + it("leaves media type references as-is when components.mediaTypes is missing", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("leaves media type references as-is when components.mediaTypes is not an object", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { mediaTypes: "junk" }, + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("removes the mediaTypes map from components", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + mediaTypes: { Json: { schema: {} } }, + schemas: { S: { type: "string" } }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { schemas: { S: { type: "string" } } }, + openapi: "3.1.2", + }); + }); +}); + +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 = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { "application/jsonl": { itemSchema } }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/jsonl": { + schema: { + items: { type: "object", xml: { nodeType: "text" } }, + type: "array", + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }); + const promoted = dig( + result, + "paths", + "/a", + "post", + "requestBody", + "content", + "application/jsonl", + "schema", + "items" + ); + expect(promoted).not.toBe(itemSchema); + expect(dig(promoted, "xml")).not.toBe(itemSchema.xml); + }); + + it("removes itemSchema when a schema already exists", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/jsonl": { + itemSchema: { type: "string" }, + schema: { type: "array" }, + }, + }, + required: true, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { "application/jsonl": { schema: { type: "array" } } }, + required: true, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("removes media type prefixEncoding and itemEncoding", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "multipart/mixed": { + example: 1, + itemEncoding: { contentType: "text/plain" }, + prefixEncoding: [{ contentType: "application/json" }], + }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { "multipart/mixed": { example: 1 } }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("removes nested encoding keys inside encoding objects while still converting headers", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "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" }], + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "multipart/form-data": { + encoding: { + part: { + contentType: "application/json", + headers: { + Referenced: { $ref: "#/components/headers/H" }, + "X-H": { description: "h" }, + }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); + + it("converts example maps inside media types", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { + examples: { + inline: { serializedValue: "raw" }, + referenced: { $ref: "#/components/examples/E" }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { + examples: { + inline: { value: "raw" }, + referenced: { $ref: "#/components/examples/E" }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }); + }); +}); + +describe("responses", () => { + it("uses summary as the description when none exists", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { responses: { R: { summary: "ok" } } }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { responses: { R: { description: "ok" } } }, + openapi: "3.1.2", + }); + }); + + it("removes summary when a description exists", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + responses: { R: { description: "d", summary: "s" } }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { responses: { R: { description: "d" } } }, + openapi: "3.1.2", + }); + }); + + it("synthesizes an empty description when neither summary nor description exist", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { responses: { R: {} } }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { responses: { R: { description: "" } } }, + openapi: "3.1.2", + }); + }); + + it("leaves response reference objects untouched, including summary and description overrides", () => { + const reference = { + $ref: "#/components/responses/R", + description: "override", + summary: "kept", + }; + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { "/a": { get: { responses: { "200": reference } } } }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { "/a": { get: { responses: { "200": reference } } } }, + }); + }); + + it("clones x- keys of the responses map without response conversion", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { + responses: { + "200": { summary: "ok" }, + "x-note": { summary: "not a response" }, + }, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + get: { + responses: { + "200": { description: "ok" }, + "x-note": { summary: "not a response" }, + }, + }, + }, + }, + }); + }); + + it("converts response headers, content, and links", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + responses: { + R: { + 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" }, + }, + }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + responses: { + R: { + content: { + "application/json": { + schema: { items: { type: "string" }, type: "array" }, + }, + }, + description: "ok", + headers: { "X-H": {} }, + links: { + inline: { server: { url: "/u" } }, + referenced: { $ref: "#/components/links/L" }, + }, + }, + }, + }, + openapi: "3.1.2", + }); + }); +}); + +describe("examples", () => { + const convertExampleComponent = (example: unknown): unknown => + dig( + downgradeSpecV32ToV31( + asSpec({ + components: { examples: { E: example } }, + openapi: "3.2.0", + }) + ), + "components", + "examples", + "E" + ); + + it("moves dataValue into the free value slot", () => { + expect(convertExampleComponent({ dataValue: { a: 1 } })).toEqual({ + value: { a: 1 }, + }); + }); + + it("moves serializedValue into the free value slot", () => { + expect(convertExampleComponent({ serializedValue: "a=1" })).toEqual({ + value: "a=1", + }); + }); + + it("removes dataValue when value already exists", () => { + expect(convertExampleComponent({ dataValue: 1, value: 2 })).toEqual({ + value: 2, + }); + }); + + it("removes serializedValue when value already exists", () => { + expect(convertExampleComponent({ serializedValue: "s", value: 2 })).toEqual( + { value: 2 } + ); + }); + + it("removes dataValue and serializedValue when externalValue exists", () => { + expect( + convertExampleComponent({ + dataValue: 1, + externalValue: "https://example.com/e.json", + serializedValue: "s", + }) + ).toEqual({ externalValue: "https://example.com/e.json" }); + }); + + it("lets dataValue win the value slot over serializedValue", () => { + expect( + convertExampleComponent({ dataValue: 1, serializedValue: "s" }) + ).toEqual({ value: 1 }); + }); + + it("keeps other example fields untouched", () => { + expect( + convertExampleComponent({ dataValue: 1, description: "d", summary: "s" }) + ).toEqual({ description: "d", summary: "s", value: 1 }); + }); + + it("leaves an example without any value fields unchanged", () => { + expect(convertExampleComponent({ summary: "s" })).toEqual({ + summary: "s", + }); + }); +}); + +describe("schema passthrough", () => { + 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", () => { + expect( + downgradeSchemaV32ToV31( + asSchema({ + customKeyword: { nested: true }, + maximum: 5, + type: "number", + "x-note": "kept", + }) + ) + ).toEqual({ + customKeyword: { nested: true }, + maximum: 5, + type: "number", + "x-note": "kept", + }); + }); + + it("passes boolean schemas through", () => { + expect(downgradeSchemaV32ToV31(true)).toBe(true); + expect(downgradeSchemaV32ToV31(false)).toBe(false); + }); + + it("passes junk schema input through", () => { + expect(downgradeSchemaV32ToV31(asSchema("junk"))).toBe("junk"); + expect(downgradeSchemaV32ToV31(asSchema(null))).toBeNull(); + expect( + downgradeSchemaV32ToV31(asSchema({ allOf: "junk", properties: 5 })) + ).toEqual({ allOf: "junk", properties: 5 }); + }); +}); + +describe("security schemes", () => { + const convertSecuritySchemeComponent = (scheme: unknown): unknown => + dig( + downgradeSpecV32ToV31( + asSpec({ + components: { securitySchemes: { S: scheme } }, + openapi: "3.2.0", + }) + ), + "components", + "securitySchemes", + "S" + ); + + it("removes deprecated whatever its value", () => { + expect( + convertSecuritySchemeComponent({ deprecated: true, type: "http" }) + ).toEqual({ type: "http" }); + expect( + convertSecuritySchemeComponent({ deprecated: false, type: "http" }) + ).toEqual({ type: "http" }); + expect( + convertSecuritySchemeComponent({ deprecated: "yes", type: "http" }) + ).toEqual({ type: "http" }); + }); + + it("removes oauth2MetadataUrl whatever its value", () => { + expect( + convertSecuritySchemeComponent({ + oauth2MetadataUrl: "https://example.com/meta", + type: "oauth2", + }) + ).toEqual({ type: "oauth2" }); + expect( + convertSecuritySchemeComponent({ oauth2MetadataUrl: 42, type: "oauth2" }) + ).toEqual({ type: "oauth2" }); + }); + + it("removes the deviceAuthorization flow and keeps other flows", () => { + expect( + convertSecuritySchemeComponent({ + flows: { + authorizationCode: { + authorizationUrl: "https://example.com/auth", + scopes: {}, + tokenUrl: "https://example.com/token", + }, + deviceAuthorization: { + deviceAuthorizationUrl: "https://example.com/device", + scopes: {}, + tokenUrl: "https://example.com/token", + }, + }, + type: "oauth2", + }) + ).toEqual({ + flows: { + authorizationCode: { + authorizationUrl: "https://example.com/auth", + scopes: {}, + tokenUrl: "https://example.com/token", + }, + }, + type: "oauth2", + }); + }); + + it("removes deviceAuthorization whatever its value", () => { + expect( + convertSecuritySchemeComponent({ + flows: { deviceAuthorization: "junk" }, + type: "oauth2", + }) + ).toEqual({ flows: {}, type: "oauth2" }); + }); + + it("clones malformed flows through", () => { + expect( + convertSecuritySchemeComponent({ flows: "junk", type: "oauth2" }) + ).toEqual({ flows: "junk", type: "oauth2" }); + }); + + it("clones references in securitySchemes", () => { + expect( + convertSecuritySchemeComponent({ + $ref: "#/components/securitySchemes/Other", + }) + ).toEqual({ $ref: "#/components/securitySchemes/Other" }); + }); + + it("passes a non-object security scheme through", () => { + expect(convertSecuritySchemeComponent("junk")).toBe("junk"); + }); +}); + +describe("webhooks and components.pathItems", () => { + it("converts webhook path items and removes their query operation", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + webhooks: { + newPet: { + post: { responses: { "200": { summary: "ok" } } }, + query: { description: "q" }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + webhooks: { + newPet: { + post: { responses: { "200": { description: "ok" } } }, + }, + }, + }); + }); + + it("converts components.pathItems path items and removes their query operation", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + pathItems: { + P: { + get: { responses: {} }, + query: { description: "q" }, + }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { pathItems: { P: { get: { responses: {} } } } }, + openapi: "3.1.2", + }); + }); +}); + +describe("callbacks", () => { + it("converts path items in operation-level callbacks and clones x- keys", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + 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: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + 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", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + callbacks: { + inline: { + "https://example.com/cb": { + post: { responses: { "200": { summary: "ok" } } }, + query: { description: "q" }, + }, + }, + referenced: { $ref: "#/components/callbacks/inline" }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + callbacks: { + inline: { + "https://example.com/cb": { + post: { responses: { "200": { description: "ok" } } }, + }, + }, + referenced: { $ref: "#/components/callbacks/inline" }, + }, + }, + openapi: "3.1.2", + }); + }); +}); + +describe("components maps", () => { + it("handles references and inline objects across component maps", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + examples: { + E: { dataValue: 1 }, + ERef: { $ref: "#/components/examples/E" }, + }, + headers: { + H: { style: "cookie" }, + HRef: { $ref: "#/components/headers/H" }, + }, + 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" }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + examples: { + E: { value: 1 }, + ERef: { $ref: "#/components/examples/E" }, + }, + headers: { + H: {}, + HRef: { $ref: "#/components/headers/H" }, + }, + parameters: { + PRef: { $ref: "#/components/parameters/P" }, + }, + 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" }, + }, + }, + openapi: "3.1.2", + }); + }); + + it("clones components.schemas entries unchanged, keeping 3.2 OAS vocabulary fields", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + schemas: { + S: { + discriminator: { defaultMapping: "Dog", propertyName: "kind" }, + xml: { nodeType: "attribute" }, + }, + }, + }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { + schemas: { + S: { + discriminator: { defaultMapping: "Dog", propertyName: "kind" }, + xml: { nodeType: "attribute" }, + }, + }, + }, + openapi: "3.1.2", + }); + }); + + it("clones unknown component keys and passes non-object components through", () => { + const withUnknownKey = downgradeSpecV32ToV31( + asSpec({ + components: { custom: { anything: true } }, + openapi: "3.2.0", + }) + ); + expect(withUnknownKey).toEqual({ + components: { custom: { anything: true } }, + openapi: "3.1.2", + }); + const withJunkComponents = downgradeSpecV32ToV31( + asSpec({ components: "junk", openapi: "3.2.0" }) + ); + expect(withJunkComponents).toEqual({ + components: "junk", + openapi: "3.1.2", + }); + }); +}); + +describe("extension and unknown key preservation", () => { + it("preserves x- keys and unknown keys at the document, path item, and operation levels", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + futureKey: { anything: [1] }, + info: { title: "t", version: "1" }, + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + openapi: "3.2.0", + 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(result).toEqual({ + futureKey: { anything: [1] }, + info: { title: "t", version: "1" }, + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + openapi: "3.1.2", + 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 } }, + }); + }); +}); + +describe("input non-mutation", () => { + 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 snapshot = structuredClone(spec); + downgradeSpecV32ToV31(spec); + expect(spec).toEqual(snapshot); + }); + + it("never mutates the input schema", () => { + const schema: OpenAPIV3_2.SchemaObject = { + discriminator: { defaultMapping: "Dog", propertyName: "kind" }, + properties: { a: { xml: { nodeType: "attribute" } } }, + type: "object", + }; + const snapshot = structuredClone(schema); + downgradeSchemaV32ToV31(schema); + expect(schema).toEqual(snapshot); + }); +}); + +describe("graceful handling of malformed nested objects", () => { + it("clones malformed operations, request bodies, responses, media types, encodings, examples, and links", () => { + const spec = asSpec({ + components: { + examples: { junkExample: 42 }, + links: { junkLink: 42 }, + }, + openapi: "3.2.0", + 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(downgradeSpecV32ToV31(spec)).toEqual({ + components: { + examples: { junkExample: 42 }, + links: { junkLink: 42 }, + }, + openapi: "3.1.2", + paths: { + "/a": { + get: "junk", + post: { + requestBody: { + content: { + "application/json": 42, + "multipart/form-data": { + encoding: { field: "junk" }, + example: 5, + }, + }, + }, + }, + put: { requestBody: 42, responses: { "200": 42 } }, + }, + }, + }); + }); +}); + +describe("media type description", () => { + it("removes the 3.2-only media type description and keeps other fields", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { + responses: { + "200": { + content: { + "application/json": { + description: "a JSON payload", + example: 5, + schema: { type: "integer" }, + }, + }, + description: "ok", + }, + }, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + get: { + responses: { + "200": { + content: { + "application/json": { + example: 5, + schema: { type: "integer" }, + }, + }, + description: "ok", + }, + }, + }, + }, + }, + }); + }); +}); + +describe("parameter allowReserved", () => { + it("keeps allowReserved on query parameters and removes it elsewhere", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a/{id}": { + parameters: [ + { allowReserved: true, in: "query", name: "q", schema: {} }, + { + allowReserved: true, + in: "path", + name: "id", + required: true, + schema: {}, + }, + { allowReserved: true, in: "cookie", name: "c", schema: {} }, + ], + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a/{id}": { + parameters: [ + { allowReserved: true, in: "query", name: "q", schema: {} }, + { in: "path", name: "id", required: true, schema: {} }, + { in: "cookie", name: "c", schema: {} }, + ], + }, + }, + }); + }); + + it("keeps allowReserved on objects without an in field", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { headers: { H: { allowReserved: true, schema: {} } } }, + openapi: "3.2.0", + }) + ); + expect(result).toEqual({ + components: { headers: { H: { allowReserved: true, schema: {} } } }, + openapi: "3.1.2", + }); + }); +}); 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..3e17682 --- /dev/null +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -0,0 +1,696 @@ +/* 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, 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`), `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; 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: 3.2 keeps the exact 3.1 schema + * dialect, and JSON Schema allows arbitrary extra keywords, so the + * 3.2-only OAS vocabulary fields (discriminator `defaultMapping`, XML + * `nodeType`) are legal to keep as-is. + * + * @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 { UnknownRecord } from "./shared"; +import { + deepClone, + getRef, + isRecord, + mapArray, + mapRecord, + omitKeys, + setKey, +} from "./shared"; + +const MEDIA_TYPES_REF_PREFIX = "#/components/mediaTypes/"; + +const HTTP_METHODS = new Set([ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +]); + +/** How many chained `components.mediaTypes` references to follow. */ +const MAX_MEDIA_TYPE_REF_DEPTH = 32; + +/** 3.2-only fields with no 3.1 equivalent, removed per object. */ +const DROPPED_SERVER_KEYS = new Set(["name"]); +const DROPPED_TAG_KEYS = new Set(["kind", "parent", "summary"]); +const DROPPED_FLOWS_KEYS = new Set(["deviceAuthorization"]); + +interface Context { + /** How deep the current media type resolution has recursed. */ + depth: number; + /** The raw `components.mediaTypes` map, used to inline references. */ + mediaTypes: UnknownRecord | undefined; +} + +const parseMediaTypeName = (ref: string): string | undefined => { + if (!ref.startsWith(MEDIA_TYPES_REF_PREFIX)) { + return undefined; + } + const name = ref.slice(MEDIA_TYPES_REF_PREFIX.length); + if (name === "" || name.includes("/")) { + return undefined; + } + return name; +}; + +/** + * Converts an OpenAPI 3.2 Schema Object to its OpenAPI 3.1 form: a deep + * clone. 3.2 keeps the exact 3.1 schema dialect, and JSON Schema allows + * arbitrary extra keywords without an `x-` prefix, so even the 3.2-only OAS + * vocabulary fields (discriminator `defaultMapping`, XML `nodeType`) are + * legal to keep as-is. + */ +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; +}; + +const convertServer = (value: unknown): unknown => + omitKeys(value, DROPPED_SERVER_KEYS); + +const convertExample = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== "dataValue" && key !== "serializedValue") { + setKey(out, key, deepClone(item)); + } + } + // 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 value) && !("externalValue" in value)) { + if ("dataValue" in value) { + setKey(out, "value", deepClone(value.dataValue)); + } else if ("serializedValue" in value) { + setKey(out, "value", deepClone(value.serializedValue)); + } + } + return out; +}; + +const convertLink = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "server") { + setKey(out, key, convertServer(item)); + } else { + setKey(out, key, deepClone(item)); + } + } + return out; +}; + +const convertSecurityScheme = (value: unknown): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + // New in 3.2, no 3.1 equivalent. + case "deprecated": + case "oauth2MetadataUrl": { + continue; + } + case "flows": { + setKey(out, key, omitKeys(item, DROPPED_FLOWS_KEYS)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +/** + * References stay references in 3.1 (including their `summary`/`description` + * overrides); everything else is converted. Converters that ignore the + * context are wrapped so every call site reads uniformly. + */ +const convertRefOr = ( + value: unknown, + context: Context, + convert: (item: unknown, innerContext: Context) => unknown +): unknown => { + if (getRef(value) !== undefined) { + return deepClone(value); + } + return convert(value, context); +}; + +/** Whether a parameter uses the 3.2-only `querystring` location. */ +const isQuerystringParameter = (value: unknown): boolean => + isRecord(value) && value.in === "querystring"; + +/** Parameter Objects and Header Objects share every field this converter touches. */ +const convertParameterOrHeader = ( + value: unknown, + context: Context +): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "style": { + // "cookie" is not a 3.1 style; removing it lets the 3.1 default + // (`form`) take over. Other styles pass through. + if (item !== "cookie") { + setKey(out, key, deepClone(item)); + } + continue; + } + case "allowReserved": { + // 3.2 broadened allowReserved beyond query parameters; 3.1 only + // defines it there. + if (!("in" in value) || value.in === "query") { + setKey(out, key, deepClone(item)); + } + continue; + } + case "content": { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertContentMap via media type encodings + setKey(out, key, convertContentMap(item, context)); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertExample) + ) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +/** Converts a parameter list, removing 3.2-only `querystring` parameters. */ +const convertParameterList = (value: unknown, context: Context): unknown => { + if (!Array.isArray(value)) { + return deepClone(value); + } + return value + .filter((item) => !isQuerystringParameter(item)) + .map((item) => convertRefOr(item, context, convertParameterOrHeader)); +}; + +const convertEncoding = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "headers": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertParameterOrHeader) + ) + ); + continue; + } + // Nested and positional encoding are new in 3.2. + case "encoding": + case "itemEncoding": + case "prefixEncoding": { + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertMediaType = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + // `itemSchema` is handled below; `description`, positional encoding, + // and nested encoding are 3.2-only. + case "description": + case "itemEncoding": + case "itemSchema": + case "prefixEncoding": { + continue; + } + case "encoding": { + setKey( + out, + key, + mapRecord(item, (entry) => convertEncoding(entry, context)) + ); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertExample) + ) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + if ("itemSchema" in value && out.schema === undefined) { + // The 3.2 sequential media type data model maps streams to arrays. + setKey(out, "schema", { + items: deepClone(value.itemSchema), + type: "array", + }); + } + return out; +}; + +const resolveContentEntry = (value: unknown, context: Context): unknown => { + const ref = getRef(value); + if (ref === undefined) { + return convertMediaType(value, context); + } + const name = parseMediaTypeName(ref); + if ( + name === undefined || + context.mediaTypes === undefined || + !(name in context.mediaTypes) || + context.depth >= MAX_MEDIA_TYPE_REF_DEPTH + ) { + // External, unknown, or cyclic target: keep the reference as-is. + return deepClone(value); + } + context.depth += 1; + const resolved = resolveContentEntry(context.mediaTypes[name], context); + context.depth -= 1; + return resolved; +}; + +/** + * Converts a content map, inlining `components.mediaTypes` references: 3.1 + * content maps hold Media Type Objects only, never references. + */ +const convertContentMap = (value: unknown, context: Context): unknown => + mapRecord(value, (item) => resolveContentEntry(item, context)); + +const convertRequestBody = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "content") { + setKey(out, key, convertContentMap(item, context)); + } else { + setKey(out, key, deepClone(item)); + } + } + return out; +}; + +const convertResponse = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "summary": { + // Promoted below when possible; 3.1 responses have no summary. + continue; + } + case "headers": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertParameterOrHeader) + ) + ); + continue; + } + case "content": { + setKey(out, key, convertContentMap(item, context)); + continue; + } + case "links": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, context, convertLink)) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + if ("summary" in value && !("description" in value)) { + setKey(out, "description", deepClone(value.summary)); + } + if (out.description === undefined) { + // Required in 3.1, optional in 3.2. + setKey(out, "description", ""); + } + 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 => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "parameters": { + setKey(out, key, convertParameterList(item, context)); + continue; + } + case "requestBody": { + setKey(out, key, convertRefOr(item, context, convertRequestBody)); + continue; + } + case "responses": { + setKey(out, key, convertResponses(item, context)); + continue; + } + case "callbacks": { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertCallback, as callbacks contain path items + const convert = convertCallback; + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, context, convert)) + ); + continue; + } + case "servers": { + setKey(out, key, mapArray(item, convertServer)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +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 => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (HTTP_METHODS.has(key)) { + setKey(out, key, convertOperation(item, context)); + continue; + } + switch (key) { + // The QUERY method and arbitrary additional operations are 3.2-only. + case "additionalOperations": + case "query": { + continue; + } + case "parameters": { + setKey(out, key, convertParameterList(item, context)); + continue; + } + case "servers": { + setKey(out, key, mapArray(item, convertServer)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertPaths = (value: unknown, context: Context): unknown => + mapRecord(value, (item, key) => + key.startsWith("/") ? convertPathItem(item, context) : deepClone(item) + ); + +/** Converts `components.parameters`, removing `querystring` parameters. */ +const convertParameterComponents = ( + value: unknown, + context: Context +): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [name, item] of Object.entries(value)) { + if (!isQuerystringParameter(item)) { + setKey(out, name, convertRefOr(item, context, convertParameterOrHeader)); + } + } + return out; +}; + +const convertComponents = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "responses": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertResponse) + ) + ); + continue; + } + case "parameters": { + setKey(out, key, convertParameterComponents(item, context)); + continue; + } + case "headers": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertParameterOrHeader) + ) + ); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertExample) + ) + ); + continue; + } + case "requestBodies": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertRequestBody) + ) + ); + continue; + } + case "mediaTypes": { + // Inlined at use sites; 3.1 has no reusable media types. + continue; + } + case "securitySchemes": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertSecurityScheme) + ) + ); + continue; + } + case "links": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, context, convertLink)) + ); + continue; + } + case "callbacks": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertCallback) + ) + ); + continue; + } + case "pathItems": { + setKey( + out, + key, + mapRecord(item, (entry) => convertPathItem(entry, context)) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } + } + } + return out; +}; + +const convertSpec = (spec: unknown): unknown => { + if (!isRecord(spec)) { + return deepClone(spec); + } + const { components } = spec; + const mediaTypes = + isRecord(components) && isRecord(components.mediaTypes) + ? components.mediaTypes + : undefined; + const context: Context = { depth: 0, mediaTypes }; + const out: UnknownRecord = {}; + for (const [key, value] of Object.entries(spec)) { + switch (key) { + case "openapi": { + setKey(out, "openapi", "3.1.2"); + continue; + } + case "$self": { + // 3.1 has no self-assigned document URI. + continue; + } + case "servers": { + setKey(out, key, mapArray(value, convertServer)); + continue; + } + case "paths": { + setKey(out, key, convertPaths(value, context)); + continue; + } + case "webhooks": { + setKey( + out, + key, + mapRecord(value, (item) => convertPathItem(item, context)) + ); + continue; + } + case "components": { + setKey(out, key, convertComponents(value, context)); + continue; + } + case "tags": { + setKey( + out, + key, + mapArray(value, (item) => omitKeys(item, DROPPED_TAG_KEYS)) + ); + continue; + } + default: { + setKey(out, key, deepClone(value)); + } + } + } + if (out.openapi === undefined) { + setKey(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..d94a03c --- /dev/null +++ b/packages/downgrader/tests/corpus.test.ts @@ -0,0 +1,260 @@ +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`: its `encoding_object_defaults` parameter is a `content` + * path parameter without `required: true`; the official 3.1 schema only + * enforces `required` for schema-based path parameters while the 3.0 schema + * enforces it for all, so the input itself is inexpressible in valid 3.0. + */ +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) => { + 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) => { + 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..275cec0 --- /dev/null +++ b/packages/downgrader/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [{ "path": "../types" }], + "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: From aaa55f3ad3542e07b112cade7adff03f845d21bf Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 29 Aug 2026 15:55:53 +0700 Subject: [PATCH 2/7] fix(downgrader): resolve adversarial-review findings - remove content-map entries whose mediaTypes reference cannot be inlined (external, unknown, or cyclic), with exact cycle detection instead of a depth cap, and resolve names without consulting the prototype chain - remove parameter-list and components references to removed querystring parameters instead of leaving them dangling - give synthesized anyOf array variants the items 3.0 requires - pass malformed $ref and allOf values through unchanged - keep tsc -b output out of the published dist (separate outDir) - document $defs/$anchor ref dangling and unknown-keyword limitations --- packages/downgrader/README.md | 8 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 61 ++++++ packages/downgrader/src/v3.1-to-v3.0.ts | 41 +++- packages/downgrader/src/v3.2-to-v3.1.test.ts | 195 +++++++++++++++---- packages/downgrader/src/v3.2-to-v3.1.ts | 91 +++++++-- packages/downgrader/tsconfig.json | 4 + 6 files changed, 328 insertions(+), 72 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 3bd6821..918b656 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -36,13 +36,13 @@ 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 | +| `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 | | 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`), `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`. +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), `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. @@ -58,7 +58,7 @@ Converted: | 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 together with the security requirements referencing them. +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 — their names are stripped from every security requirement, and requirements that referenced only such schemes are removed. Schema Objects: @@ -78,6 +78,8 @@ Schema Objects: | 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`, `propertyNames`, `unevaluatedItems`/`unevaluatedProperties`, `contentSchema` | removed — dropping these only loosens validation, which is 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. + ## 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/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 6b611e3..4ced35e 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -1469,3 +1469,64 @@ describe("xml nodeType from chained 3.2 documents", () => { ).toEqual({ type: "string", xml: "junk" }); }); }); + +describe("malformed schema values pass through the conversion triggers", () => { + it("passes a non-string $ref through unchanged", () => { + expect(downgradeSchemaV31ToV30(asSchema({ $ref: 123 }))).toEqual({ + $ref: 123, + }); + expect( + downgradeSchemaV31ToV30(asSchema({ $ref: 123, type: "string" })) + ).toEqual({ $ref: 123, type: "string" }); + }); + + it("keeps a malformed allOf when a string $ref would otherwise be wrapped", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ $ref: "#/components/schemas/A", allOf: "junk" }) + ) + ).toEqual({ $ref: "#/components/schemas/A", allOf: "junk" }); + }); + + it("keeps a malformed allOf when a type union would otherwise merge into it", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ + allOf: "junk", + anyOf: [{ type: "string" }], + type: ["integer", "string"], + }) + ) + ).toEqual({ + allOf: "junk", + anyOf: [{ type: "string" }], + }); + }); +}); + +describe("type unions containing array", () => { + it("gives synthesized array variants an empty items", () => { + expect( + downgradeSchemaV31ToV30(asSchema({ type: ["array", "string"] })) + ).toEqual({ + anyOf: [{ items: {}, type: "array" }, { type: "string" }], + }); + }); + + it("copies existing items into the synthesized array variant", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ + items: { type: "integer" }, + type: ["array", "string", "null"], + }) + ) + ).toEqual({ + anyOf: [ + { items: { type: "integer" }, nullable: true, type: "array" }, + { nullable: true, type: "string" }, + ], + items: { type: "integer" }, + }); + }); +}); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 917c46f..3962915 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -144,17 +144,27 @@ const applyTypes = ( } // Multiple non-null types: 3.0 only allows a single `type`, so the type // union moves into `anyOf` branches. - const variants = rest.map((item) => - nullable ? { nullable: true, type: item } : { type: item } - ); + 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 { + } 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 => { @@ -290,9 +300,17 @@ const convertSchema = (schema: unknown): unknown => { // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertSchemaFields, as schemas are recursive structures const out = convertSchemaFields(schema); if (typeof ref === "string") { - // 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 : [])]; + 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; }; @@ -346,8 +364,15 @@ const convertSchemaFields = (schema: UnknownRecord): UnknownRecord => { const hasPrefixItems = "prefixItems" in schema; for (const [key, value] of Object.entries(schema)) { + if (key === "$ref") { + // A string $ref is re-attached by convertSchema; malformed values pass + // through unchanged. + if (typeof value !== "string") { + setKey(out, key, deepClone(value)); + } + continue; + } if ( - key === "$ref" || DROPPED_SCHEMA_KEYWORDS.has(key) || POSTPROCESSED_SCHEMA_KEYWORDS.has(key) ) { diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index b4bb181..a85c00e 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -586,11 +586,15 @@ describe("components.mediaTypes inlining", () => { }); }); - it("leaves self-referential media type reference chains as the original reference", () => { + it("removes content entries whose reference chain is cyclic", () => { const result = downgradeSpecV32ToV31( asSpec({ components: { - mediaTypes: { Loop: { $ref: "#/components/mediaTypes/Loop" } }, + mediaTypes: { + Loop: { $ref: "#/components/mediaTypes/Loop" }, + Ping: { $ref: "#/components/mediaTypes/Pong" }, + Pong: { $ref: "#/components/mediaTypes/Ping" }, + }, }, openapi: "3.2.0", paths: { @@ -601,6 +605,50 @@ describe("components.mediaTypes inlining", () => { "application/json": { $ref: "#/components/mediaTypes/Loop", }, + "application/xml": { + $ref: "#/components/mediaTypes/Ping", + }, + }, + }, + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + post: { + requestBody: { content: {} }, + responses: {}, + }, + }, + }, + }); + }); + + 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" } }], + ]); + const result = downgradeSpecV32ToV31( + asSpec({ + components: { mediaTypes }, + openapi: "3.2.0", + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/m0" }, }, }, responses: {}, @@ -617,9 +665,7 @@ describe("components.mediaTypes inlining", () => { post: { requestBody: { content: { - "application/json": { - $ref: "#/components/mediaTypes/Loop", - }, + "application/json": { schema: { type: "string" } }, }, }, responses: {}, @@ -629,17 +675,18 @@ describe("components.mediaTypes inlining", () => { }); }); - it("leaves external and unparseable references as-is", () => { + it("removes content entries with external and unparseable references", () => { const content = { "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" }, }; const result = downgradeSpecV32ToV31( asSpec({ - components: { mediaTypes: { Known: {} } }, + components: { mediaTypes: { Known: { example: 1 } } }, openapi: "3.2.0", paths: { "/a": { @@ -653,22 +700,28 @@ describe("components.mediaTypes inlining", () => { openapi: "3.1.2", paths: { "/a": { - post: { requestBody: { content }, responses: {} }, + post: { + requestBody: { content: { "a/6": { example: 1 } } }, + responses: {}, + }, }, }, }); }); - it("leaves media type references as-is when components.mediaTypes is missing", () => { + it("does not resolve names through the prototype chain of the mediaTypes map", () => { const result = downgradeSpecV32ToV31( asSpec({ + components: { mediaTypes: {} }, openapi: "3.2.0", paths: { "/a": { post: { requestBody: { content: { - "application/json": { $ref: "#/components/mediaTypes/A" }, + "application/json": { + $ref: "#/components/mediaTypes/hasOwnProperty", + }, }, }, responses: {}, @@ -678,15 +731,12 @@ describe("components.mediaTypes inlining", () => { }) ); expect(result).toEqual({ + components: {}, openapi: "3.1.2", paths: { "/a": { post: { - requestBody: { - content: { - "application/json": { $ref: "#/components/mediaTypes/A" }, - }, - }, + requestBody: { content: {} }, responses: {}, }, }, @@ -694,39 +744,38 @@ describe("components.mediaTypes inlining", () => { }); }); - it("leaves media type references as-is when components.mediaTypes is not an object", () => { - const result = downgradeSpecV32ToV31( + it("removes media type references when components.mediaTypes is missing or malformed", () => { + const content = { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }; + const withoutComponents = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { post: { requestBody: { content }, responses: {} } }, + }, + }) + ); + expect(withoutComponents).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { post: { requestBody: { content: {} }, responses: {} } }, + }, + }); + const withJunkMap = downgradeSpecV32ToV31( asSpec({ components: { mediaTypes: "junk" }, openapi: "3.2.0", paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { $ref: "#/components/mediaTypes/A" }, - }, - }, - responses: {}, - }, - }, + "/a": { post: { requestBody: { content }, responses: {} } }, }, }) ); - expect(result).toEqual({ + expect(withJunkMap).toEqual({ components: {}, openapi: "3.1.2", paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { $ref: "#/components/mediaTypes/A" }, - }, - }, - responses: {}, - }, - }, + "/a": { post: { requestBody: { content: {} }, responses: {} } }, }, }); }); @@ -1512,9 +1561,7 @@ describe("components maps", () => { H: {}, HRef: { $ref: "#/components/headers/H" }, }, - parameters: { - PRef: { $ref: "#/components/parameters/P" }, - }, + parameters: {}, requestBodies: { B: { content: { @@ -1831,3 +1878,69 @@ describe("parameter allowReserved", () => { }); }); }); + +describe("references to removed querystring parameters", () => { + it("removes list references to removed querystring component parameters and keeps others", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + parameters: { + Keep: { in: "query", name: "k", schema: {} }, + QS: { + content: { "application/x-www-form-urlencoded": { schema: {} } }, + in: "querystring", + name: "filter", + }, + }, + }, + openapi: "3.2.0", + paths: { + "/a": { + get: { + parameters: [ + { $ref: "#/components/parameters/QS" }, + { $ref: "#/components/parameters/Keep" }, + ], + responses: {}, + }, + parameters: [{ $ref: "#/components/parameters/QS" }], + }, + }, + }) + ); + expect(result).toEqual({ + components: { + parameters: { Keep: { in: "query", name: "k", schema: {} } }, + }, + openapi: "3.1.2", + paths: { + "/a": { + get: { + parameters: [{ $ref: "#/components/parameters/Keep" }], + responses: {}, + }, + parameters: [], + }, + }, + }); + }); +}); + +describe("malformed content maps", () => { + it("clones a non-object content value through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { post: { requestBody: { content: "junk" }, responses: {} } }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { post: { requestBody: { content: "junk" }, responses: {} } }, + }, + }); + }); +}); diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 3e17682..ceccaad 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -48,6 +48,7 @@ import { } from "./shared"; const MEDIA_TYPES_REF_PREFIX = "#/components/mediaTypes/"; +const PARAMETERS_REF_PREFIX = "#/components/parameters/"; const HTTP_METHODS = new Set([ "delete", @@ -60,19 +61,21 @@ const HTTP_METHODS = new Set([ "trace", ]); -/** How many chained `components.mediaTypes` references to follow. */ -const MAX_MEDIA_TYPE_REF_DEPTH = 32; - /** 3.2-only fields with no 3.1 equivalent, removed per object. */ const DROPPED_SERVER_KEYS = new Set(["name"]); const DROPPED_TAG_KEYS = new Set(["kind", "parent", "summary"]); const DROPPED_FLOWS_KEYS = new Set(["deviceAuthorization"]); +/** Marks a content-map entry whose reference cannot be inlined. */ +const UNRESOLVED_MEDIA_TYPE = Symbol("unresolved media type"); + interface Context { - /** How deep the current media type resolution has recursed. */ - depth: number; /** The raw `components.mediaTypes` map, used to inline references. */ mediaTypes: UnknownRecord | undefined; + /** `$ref` strings of removed querystring `components.parameters` entries. */ + removedParameterRefs: ReadonlySet; + /** Media type names currently being resolved, for cycle detection. */ + resolving: Set; } const parseMediaTypeName = (ref: string): string | undefined => { @@ -185,6 +188,12 @@ const convertRefOr = ( const isQuerystringParameter = (value: unknown): boolean => isRecord(value) && value.in === "querystring"; +/** Whether the value references a removed querystring component parameter. */ +const isRemovedParameterRef = (value: unknown, context: Context): boolean => { + const ref = getRef(value); + return ref !== undefined && context.removedParameterRefs.has(ref); +}; + /** Parameter Objects and Header Objects share every field this converter touches. */ const convertParameterOrHeader = ( value: unknown, @@ -235,13 +244,19 @@ const convertParameterOrHeader = ( return out; }; -/** Converts a parameter list, removing 3.2-only `querystring` parameters. */ +/** + * Converts a parameter list, removing 3.2-only `querystring` parameters and + * references to removed querystring component parameters. + */ const convertParameterList = (value: unknown, context: Context): unknown => { if (!Array.isArray(value)) { return deepClone(value); } return value - .filter((item) => !isQuerystringParameter(item)) + .filter( + (item) => + !(isQuerystringParameter(item) || isRemovedParameterRef(item, context)) + ) .map((item) => convertRefOr(item, context, convertParameterOrHeader)); }; @@ -333,24 +348,37 @@ const resolveContentEntry = (value: unknown, context: Context): unknown => { if ( name === undefined || context.mediaTypes === undefined || - !(name in context.mediaTypes) || - context.depth >= MAX_MEDIA_TYPE_REF_DEPTH + !Object.hasOwn(context.mediaTypes, name) || + context.resolving.has(name) ) { - // External, unknown, or cyclic target: keep the reference as-is. - return deepClone(value); + // External, unknown, or cyclic target: 3.1 content maps cannot hold + // references, so the entry is removed. + return UNRESOLVED_MEDIA_TYPE; } - context.depth += 1; + context.resolving.add(name); const resolved = resolveContentEntry(context.mediaTypes[name], context); - context.depth -= 1; + context.resolving.delete(name); return resolved; }; /** - * Converts a content map, inlining `components.mediaTypes` references: 3.1 - * content maps hold Media Type Objects only, never references. + * 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) => resolveContentEntry(item, context)); +const convertContentMap = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + const resolved = resolveContentEntry(item, context); + if (resolved !== UNRESOLVED_MEDIA_TYPE) { + setKey(out, key, resolved); + } + } + return out; +}; const convertRequestBody = (value: unknown, context: Context): unknown => { if (!isRecord(value)) { @@ -506,7 +534,10 @@ const convertPaths = (value: unknown, context: Context): unknown => key.startsWith("/") ? convertPathItem(item, context) : deepClone(item) ); -/** Converts `components.parameters`, removing `querystring` parameters. */ +/** + * Converts `components.parameters`, removing `querystring` parameters and + * references to removed querystring entries. + */ const convertParameterComponents = ( value: unknown, context: Context @@ -516,7 +547,9 @@ const convertParameterComponents = ( } const out: UnknownRecord = {}; for (const [name, item] of Object.entries(value)) { - if (!isQuerystringParameter(item)) { + if ( + !(isQuerystringParameter(item) || isRemovedParameterRef(item, context)) + ) { setKey(out, name, convertRefOr(item, context, convertParameterOrHeader)); } } @@ -622,6 +655,20 @@ const convertComponents = (value: unknown, context: Context): unknown => { return out; }; +/** Collects the `$ref` strings of querystring `components.parameters` entries. */ +const indexRemovedParameterRefs = (components: unknown): Set => { + const removed = new Set(); + const parameters = isRecord(components) ? components.parameters : undefined; + if (isRecord(parameters)) { + for (const [name, parameter] of Object.entries(parameters)) { + if (isQuerystringParameter(parameter)) { + removed.add(PARAMETERS_REF_PREFIX + name); + } + } + } + return removed; +}; + const convertSpec = (spec: unknown): unknown => { if (!isRecord(spec)) { return deepClone(spec); @@ -631,7 +678,11 @@ const convertSpec = (spec: unknown): unknown => { isRecord(components) && isRecord(components.mediaTypes) ? components.mediaTypes : undefined; - const context: Context = { depth: 0, mediaTypes }; + const context: Context = { + mediaTypes, + removedParameterRefs: indexRemovedParameterRefs(components), + resolving: new Set(), + }; const out: UnknownRecord = {}; for (const [key, value] of Object.entries(spec)) { switch (key) { diff --git a/packages/downgrader/tsconfig.json b/packages/downgrader/tsconfig.json index 275cec0..4a7f724 100644 --- a/packages/downgrader/tsconfig.json +++ b/packages/downgrader/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "../../tsconfig.lib.json", "references": [{ "path": "../types" }], + "compilerOptions": { + "outDir": "out", + "tsBuildInfoFile": "out/.tsbuildinfo" + }, "include": ["package.json", "src"], "exclude": [ "**/*.test.*", From f5ccc36a2e1178dcfc7c400f6c4944ee350bede6 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 29 Aug 2026 19:35:59 +0700 Subject: [PATCH 3/7] fix(downgrader): address external review findings - drop additionalProperties together with patternProperties (its meaning would otherwise tighten onto pattern-matched keys); soften the loosening claim to positive positions and document not/oneOf polarity - remove parameters and headers whose entire content could not be inlined (3.1 requires exactly one entry there) - follow reference-alias chains when removing querystring parameters (fixpoint) and mutualTLS security schemes (cycle-guarded resolution) - omit security lists emptied by mutualTLS removal instead of leaving an explicit empty list that would make an operation public - drop empty enum, deduplicate required (3.0 structural constraints) - pass all-junk type arrays through instead of silently dropping them - narrow the never-throws claim to JSON-shaped (acyclic) input, correct the 3.2 dialect rationale, and de-vacuate the x- guard tests --- packages/downgrader/README.md | 15 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 172 ++++++++++++- packages/downgrader/src/v3.1-to-v3.0.ts | 124 ++++++++-- packages/downgrader/src/v3.2-to-v3.1.test.ts | 239 +++++++++++++++++++ packages/downgrader/src/v3.2-to-v3.1.ts | 153 +++++++----- 5 files changed, 612 insertions(+), 91 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 918b656..dbe5932 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -2,7 +2,7 @@ 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. +- **Never throws on JSON-shaped input**: malformed parts are deep-copied through unchanged instead of failing the whole conversion. Cyclic object graphs (e.g. the output of a `$ref` dereferencer) and pathologically deep nesting are out of scope — feed the converters what `JSON.parse` could have produced. - **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. @@ -29,20 +29,20 @@ const schema = downgradeSchemaV31ToV30({ type: ["string", "null"] }); ## 3.2 → 3.1 -Schema Objects pass through unchanged: 3.2 keeps the exact 3.1 schema dialect, and JSON Schema allows arbitrary extra keywords without an `x-` prefix, so even the 3.2-only OAS vocabulary fields (discriminator `defaultMapping`, XML `nodeType`) are legal to keep as-is. +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 JSON Schema allows arbitrary extra keywords without an `x-` prefix, so the 3.2-only fields (discriminator `defaultMapping`, XML `nodeType`) legally ride along as extra keys. Note the semantic cost: 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 | +| `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), `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`. +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. @@ -58,7 +58,7 @@ Converted: | 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 — their names are stripped from every security requirement, and requirements that referenced only such schemes are removed. +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: @@ -67,6 +67,7 @@ Schema Objects: | `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]` | | `type` with several non-null entries | `anyOf` of single-type schemas | | `const` | single-value `enum` | @@ -76,9 +77,9 @@ Schema Objects: | `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`, `propertyNames`, `unevaluatedItems`/`unevaluatedProperties`, `contentSchema` | removed — dropping these only loosens validation, which is the safe direction for a downgrade | +| `$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. +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 diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 4ced35e..1571f43 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -155,7 +155,9 @@ describe("downgradeSpecV31ToV30", () => { openapi: "3.1.0", paths: { "/a": { get: { summary: "s" } }, - "x-note": { keep: true }, + // Path-item-shaped on purpose: cloning must NOT convert it, so + // no responses may be synthesized inside. + "x-note": { get: { summary: "s" } }, }, }) ).toEqual({ @@ -168,7 +170,7 @@ describe("downgradeSpecV31ToV30", () => { summary: "s", }, }, - "x-note": { keep: true }, + "x-note": { get: { summary: "s" } }, }, }); }); @@ -950,7 +952,9 @@ describe("downgradeSchemaV31ToV30", () => { expect( downgradeSchemaV31ToV30(asSchema({ type: ["string", 42] })) ).toEqual({ type: "string" }); - expect(downgradeSchemaV31ToV30(asSchema({ type: [42] }))).toEqual({}); + expect(downgradeSchemaV31ToV30(asSchema({ type: [42] }))).toEqual({ + type: [42], + }); }); it("drops an empty type array", () => { @@ -1351,7 +1355,7 @@ describe("graceful handling of malformed nested objects", () => { callbacks: { junkCallback: 42, realCallback: { - "x-note": 1, + "x-note": { "{$expr}": { get: {} } }, "{$request.body#/url}": { post: { summary: "s" } }, }, }, @@ -1362,7 +1366,13 @@ describe("graceful handling of malformed nested objects", () => { paths: { "/a": { get: { - callbacks: { inline: { expr: { get: {} }, "x-k": 2 }, junk: 7 }, + callbacks: { + inline: { + expr: { get: {} }, + "x-k": { expr: { get: {} } }, + }, + junk: 7, + }, responses: {}, }, }, @@ -1373,7 +1383,7 @@ describe("graceful handling of malformed nested objects", () => { callbacks: { junkCallback: 42, realCallback: { - "x-note": 1, + "x-note": { "{$expr}": { get: {} } }, "{$request.body#/url}": { post: { responses: { default: { description: "" } }, @@ -1392,7 +1402,7 @@ describe("graceful handling of malformed nested objects", () => { callbacks: { inline: { expr: { get: { responses: { default: { description: "" } } } }, - "x-k": 2, + "x-k": { expr: { get: {} } }, }, junk: 7, }, @@ -1530,3 +1540,151 @@ describe("type unions containing array", () => { }); }); }); + +describe("patternProperties sibling handling", () => { + it("drops additionalProperties together with patternProperties", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ + additionalProperties: false, + patternProperties: { "^x-": {} }, + properties: { name: { type: "string" } }, + type: "object", + }) + ) + ).toEqual({ + properties: { name: { type: "string" } }, + type: "object", + }); + }); + + it("drops schema-valued additionalProperties together with patternProperties", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ + additionalProperties: { type: "integer" }, + patternProperties: { "^x-": {} }, + type: "object", + }) + ) + ).toEqual({ type: "object" }); + }); +}); + +describe("3.0 enum and required constraints", () => { + it("removes an empty enum", () => { + expect( + downgradeSchemaV31ToV30(asSchema({ enum: [], type: "string" })) + ).toEqual({ type: "string" }); + }); + + it("deduplicates required entries", () => { + expect( + downgradeSchemaV31ToV30( + asSchema({ required: ["a", "b", "a"], type: "object" }) + ) + ).toEqual({ required: ["a", "b"], type: "object" }); + }); +}); + +describe("mutualTLS reference aliases", () => { + it("removes reference aliases of mutualTLS schemes and their requirements", () => { + const result = downgradeSpecV31ToV30( + asSpec({ + components: { + securitySchemes: { + api: { in: "header", name: "k", type: "apiKey" }, + clientCert: { $ref: "#/components/securitySchemes/mtlsBase" }, + mtlsBase: { type: "mutualTLS" }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + security: [{ clientCert: [] }, { api: [] }], + }) + ); + expect(result).toEqual({ + components: { + securitySchemes: { api: { in: "header", name: "k", type: "apiKey" } }, + }, + info, + openapi: "3.0.4", + paths: {}, + security: [{ api: [] }], + }); + }); + + it("survives cyclic and dangling security scheme aliases", () => { + const result = downgradeSpecV31ToV30( + asSpec({ + components: { + 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" }, + }, + }, + info, + openapi: "3.1.0", + paths: {}, + }) + ); + expect(result.components?.securitySchemes).toEqual({ + 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" }, + }); + }); +}); + +describe("security lists emptied by mutualTLS removal", () => { + it("omits an operation security list that removal emptied", () => { + const result = downgradeSpecV31ToV30( + asSpec({ + components: { securitySchemes: { mtls: { type: "mutualTLS" } } }, + info, + openapi: "3.1.0", + paths: { + "/admin": { + get: { responses: {}, security: [{ mtls: [] }] }, + }, + }, + security: [{ mtls: [] }], + }) + ); + const operation = result.paths?.["/admin"]?.get; + expect(operation).toEqual({ responses: {} }); + expect(operation).not.toHaveProperty("security"); + expect(result).not.toHaveProperty("security"); + }); + + it("keeps an explicitly empty security list", () => { + const result = downgradeSpecV31ToV30( + asSpec({ + info, + openapi: "3.1.0", + paths: { "/a": { get: { responses: {}, security: [] } } }, + security: [], + }) + ); + expect(result.paths?.["/a"]?.get?.security).toEqual([]); + expect(result.security).toEqual([]); + }); +}); + +describe("deep documents", () => { + 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(); + }); +}); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3962915..848aefe 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -4,9 +4,10 @@ * 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, and existing specification extensions - * (`x-` keys) as well as unknown keys are always preserved. Constructs 3.0 + * The conversion never throws on JSON-shaped (acyclic) input: parts that do + * not match the expected shape are deep-copied through unchanged, 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: * @@ -55,8 +56,11 @@ const HTTP_METHODS = new Set([ ]); /** - * JSON Schema keywords with no OpenAPI 3.0 equivalent. Dropping them only - * loosens validation, which is the safe direction for a downgrade. + * JSON Schema keywords with no OpenAPI 3.0 equivalent. In positive schema + * positions, dropping them 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. */ const DROPPED_SCHEMA_KEYWORDS = new Set([ "$anchor", @@ -178,6 +182,11 @@ const convertType = (schema: UnknownRecord, out: UnknownRecord): void => { } 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; } @@ -320,7 +329,7 @@ const convertSubschemaKeyword = ( out: UnknownRecord, key: string, value: unknown, - hasPrefixItems: boolean + schema: UnknownRecord ): boolean => { switch (key) { case "allOf": @@ -332,7 +341,7 @@ const convertSubschemaKeyword = ( case "items": { // With `prefixItems` dropped, a trailing `items` would wrongly // constrain every item, so it is dropped alongside. - if (!hasPrefixItems) { + if (!("prefixItems" in schema)) { setKey(out, key, convertSchema(value)); } return true; @@ -346,11 +355,16 @@ const convertSubschemaKeyword = ( return true; } case "additionalProperties": { - setKey( - out, - key, - typeof value === "boolean" ? value : convertSchema(value) - ); + // With `patternProperties` dropped, `additionalProperties` would also + // constrain the previously pattern-matched keys, so it is dropped + // alongside (removing a constraint is the safe direction). + if (!("patternProperties" in schema)) { + setKey( + out, + key, + typeof value === "boolean" ? value : convertSchema(value) + ); + } return true; } default: { @@ -361,7 +375,6 @@ const convertSubschemaKeyword = ( const convertSchemaFields = (schema: UnknownRecord): UnknownRecord => { const out: UnknownRecord = {}; - const hasPrefixItems = "prefixItems" in schema; for (const [key, value] of Object.entries(schema)) { if (key === "$ref") { @@ -378,7 +391,7 @@ const convertSchemaFields = (schema: UnknownRecord): UnknownRecord => { ) { continue; } - if (convertSubschemaKeyword(out, key, value, hasPrefixItems)) { + if (convertSubschemaKeyword(out, key, value, schema)) { continue; } if ( @@ -389,8 +402,17 @@ const convertSchemaFields = (schema: UnknownRecord): UnknownRecord => { // accepted gracefully) pass through the default clone below. continue; } - if (key === "required" && Array.isArray(value) && value.length === 0) { - // 3.0 requires the array to be non-empty. + if (key === "required" && Array.isArray(value)) { + // 3.0 requires the array to be non-empty with unique entries. + const unique = [...new Set(value)]; + if (unique.length > 0) { + setKey(out, key, deepClone(unique)); + } + continue; + } + if (key === "enum" && Array.isArray(value) && value.length === 0) { + // 3.0 requires at least one enum entry; an empty enum only constrains, + // so removing it is the safe direction. continue; } if (key === "xml") { @@ -427,6 +449,38 @@ export const downgradeSchemaV31ToV30 = ( return converted as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject; }; +const SECURITY_SCHEMES_REF_PREFIX = "#/components/securitySchemes/"; + +/** + * 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: UnknownRecord): SecuritySchemeIndex => { const types = new Map(); const mutualTls = new Set(); @@ -434,10 +488,13 @@ const indexSecuritySchemes = (spec: UnknownRecord): SecuritySchemeIndex => { ? spec.components.securitySchemes : undefined; if (isRecord(schemes)) { - for (const [name, scheme] of Object.entries(schemes)) { - if (isRecord(scheme) && typeof scheme.type === "string") { - types.set(name, scheme.type); - if (scheme.type === "mutualTLS") { + for (const name of Object.keys(schemes)) { + const type = resolveSchemeType(name, schemes, new Set()); + if (type !== undefined) { + types.set(name, type); + if (type === "mutualTLS") { + // Reference aliases of mutualTLS schemes are removed as well, so + // no dangling references survive. mutualTls.add(name); } } @@ -487,6 +544,29 @@ const convertSecurityList = ( return out; }; +/** + * Converts and attaches a `security` list. When mutualTLS removal empties a + * previously non-empty list, the key is omitted entirely: 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 setSecurity = ( + out: UnknownRecord, + value: unknown, + index: SecuritySchemeIndex +): void => { + const converted = convertSecurityList(value, index); + if ( + Array.isArray(value) && + value.length > 0 && + Array.isArray(converted) && + converted.length === 0 + ) { + return; + } + setKey(out, "security", converted); +}; + /** The SPDX `identifier` has no 3.0 equivalent. */ const DROPPED_LICENSE_KEYS = new Set(["identifier"]); @@ -696,7 +776,7 @@ const convertOperation = ( continue; } case "security": { - setKey(out, key, convertSecurityList(item, index)); + setSecurity(out, item, index); continue; } default: { @@ -874,7 +954,7 @@ const convertSpec = (spec: unknown): unknown => { continue; } case "security": { - setKey(out, "security", convertSecurityList(value, index)); + setSecurity(out, value, index); continue; } default: { diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index a85c00e..bb3cde1 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -1944,3 +1944,242 @@ describe("malformed content maps", () => { }); }); }); + +describe("parameters and headers losing their entire content", () => { + it("removes a parameter whose only content entry could not be inlined", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { + parameters: [ + { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Missing", + }, + }, + in: "query", + name: "q", + }, + { in: "query", name: "keep", schema: {} }, + ], + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + get: { + parameters: [{ in: "query", name: "keep", schema: {} }], + responses: {}, + }, + }, + }, + }); + }); + + it("keeps a parameter when part of its content could be inlined", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { mediaTypes: { Known: { example: 1 } } }, + openapi: "3.2.0", + paths: { + "/a": { + get: { + parameters: [ + { + content: { + "application/json": { + $ref: "#/components/mediaTypes/Missing", + }, + "application/xml": { + $ref: "#/components/mediaTypes/Known", + }, + }, + in: "query", + name: "q", + }, + ], + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: {}, + openapi: "3.1.2", + paths: { + "/a": { + 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 = downgradeSpecV32ToV31( + asSpec({ + components: { + headers: { + Broken: { + content: { + "text/plain": { $ref: "#/components/mediaTypes/Missing" }, + }, + }, + Keep: { schema: {} }, + }, + parameters: { + Broken: { + content: { + "text/plain": { $ref: "#/components/mediaTypes/Missing" }, + }, + in: "query", + name: "q", + }, + }, + }, + openapi: "3.2.0", + paths: { + "/a": { + get: { + responses: { + "200": { + description: "ok", + headers: { + "X-Broken": { + content: { + "text/plain": { + $ref: "#/components/mediaTypes/Missing", + }, + }, + }, + "X-Keep": { schema: {} }, + }, + }, + }, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: { + headers: { Keep: { schema: {} } }, + parameters: {}, + }, + openapi: "3.1.2", + paths: { + "/a": { + get: { + responses: { + "200": { + description: "ok", + headers: { "X-Keep": { schema: {} } }, + }, + }, + }, + }, + }, + }); + }); +}); + +describe("querystring reference alias chains", () => { + it("removes aliases of removed querystring parameters and references to them", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + 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", + }, + }, + }, + openapi: "3.2.0", + paths: { + "/a": { + get: { + parameters: [ + { $ref: "#/components/parameters/AliasOfAlias" }, + { $ref: "#/components/parameters/Keep" }, + ], + responses: {}, + }, + }, + }, + }) + ); + expect(result).toEqual({ + components: { + parameters: { Keep: { in: "query", name: "k", schema: {} } }, + }, + openapi: "3.1.2", + paths: { + "/a": { + get: { + parameters: [{ $ref: "#/components/parameters/Keep" }], + responses: {}, + }, + }, + }, + }); + }); +}); + +describe("deep documents", () => { + 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(); + }); +}); + +describe("malformed header maps", () => { + it("clones a non-object headers value through", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + openapi: "3.2.0", + paths: { + "/a": { + get: { + responses: { "200": { description: "ok", headers: "junk" } }, + }, + }, + }, + }) + ); + expect(result).toEqual({ + openapi: "3.1.2", + paths: { + "/a": { + get: { + responses: { "200": { description: "ok", headers: "junk" } }, + }, + }, + }, + }); + }); +}); diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index ceccaad..ae91014 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -4,31 +4,38 @@ * 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, 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: + * The conversion never throws on JSON-shaped (acyclic) input: parts that do + * not match the expected shape are deep-copied through unchanged, 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`), `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`. + * `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; 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: 3.2 keeps the exact 3.1 schema - * dialect, and JSON Schema allows arbitrary extra keywords, so the - * 3.2-only OAS vocabulary fields (discriminator `defaultMapping`, XML - * `nodeType`) are legal to keep as-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), and JSON Schema allows + * arbitrary extra keywords, so the 3.2-only fields (discriminator + * `defaultMapping`, XML `nodeType`) legally ride along as extra keys — + * 3.1 tooling will not act on them. * * @see {@link https://spec.openapis.org/oas/v3.2.0.html} * @see {@link https://spec.openapis.org/oas/v3.1.2.html} @@ -69,6 +76,9 @@ const DROPPED_FLOWS_KEYS = new Set(["deviceAuthorization"]); /** Marks a content-map entry whose reference cannot be inlined. */ const UNRESOLVED_MEDIA_TYPE = Symbol("unresolved media type"); +/** Marks a parameter or header whose entire `content` could not be inlined. */ +const DROPPED_PARAMETER = Symbol("dropped parameter"); + interface Context { /** The raw `components.mediaTypes` map, used to inline references. */ mediaTypes: UnknownRecord | undefined; @@ -91,10 +101,12 @@ const parseMediaTypeName = (ref: string): string | undefined => { /** * Converts an OpenAPI 3.2 Schema Object to its OpenAPI 3.1 form: a deep - * clone. 3.2 keeps the exact 3.1 schema dialect, and JSON Schema allows - * arbitrary extra keywords without an `x-` prefix, so even the 3.2-only OAS - * vocabulary fields (discriminator `defaultMapping`, XML `nodeType`) are - * legal to keep as-is. + * 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), and JSON Schema allows arbitrary extra keywords without an `x-` + * prefix, so even the 3.2-only fields (discriminator `defaultMapping`, XML + * `nodeType`) are legal to keep as-is — though 3.1 tooling will not act on + * them. */ export const downgradeSchemaV32ToV31 = ( schema: OpenAPIV3_2.SchemaObject @@ -241,12 +253,23 @@ const convertParameterOrHeader = ( } } } + if ( + isRecord(value.content) && + Object.keys(value.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 DROPPED_PARAMETER; + } return out; }; /** - * Converts a parameter list, removing 3.2-only `querystring` parameters and - * references to removed querystring component parameters. + * Converts a parameter list, removing 3.2-only `querystring` parameters, + * references to removed querystring component parameters, and parameters + * whose entire `content` could not be inlined. */ const convertParameterList = (value: unknown, context: Context): unknown => { if (!Array.isArray(value)) { @@ -257,7 +280,26 @@ const convertParameterList = (value: unknown, context: Context): unknown => { (item) => !(isQuerystringParameter(item) || isRemovedParameterRef(item, context)) ) - .map((item) => convertRefOr(item, context, convertParameterOrHeader)); + .map((item) => convertRefOr(item, context, convertParameterOrHeader)) + .filter((item) => item !== DROPPED_PARAMETER); +}; + +/** + * Converts a map of Header Objects, removing headers whose entire `content` + * could not be inlined. + */ +const convertHeaderMap = (value: unknown, context: Context): unknown => { + if (!isRecord(value)) { + return deepClone(value); + } + const out: UnknownRecord = {}; + for (const [name, item] of Object.entries(value)) { + const converted = convertRefOr(item, context, convertParameterOrHeader); + if (converted !== DROPPED_PARAMETER) { + setKey(out, name, converted); + } + } + return out; }; const convertEncoding = (value: unknown, context: Context): unknown => { @@ -268,13 +310,7 @@ const convertEncoding = (value: unknown, context: Context): unknown => { for (const [key, item] of Object.entries(value)) { switch (key) { case "headers": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertParameterOrHeader) - ) - ); + setKey(out, key, convertHeaderMap(item, context)); continue; } // Nested and positional encoding are new in 3.2. @@ -407,13 +443,7 @@ const convertResponse = (value: unknown, context: Context): unknown => { continue; } case "headers": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertParameterOrHeader) - ) - ); + setKey(out, key, convertHeaderMap(item, context)); continue; } case "content": { @@ -547,10 +577,12 @@ const convertParameterComponents = ( } const out: UnknownRecord = {}; for (const [name, item] of Object.entries(value)) { - if ( - !(isQuerystringParameter(item) || isRemovedParameterRef(item, context)) - ) { - setKey(out, name, convertRefOr(item, context, convertParameterOrHeader)); + if (isQuerystringParameter(item) || isRemovedParameterRef(item, context)) { + continue; + } + const converted = convertRefOr(item, context, convertParameterOrHeader); + if (converted !== DROPPED_PARAMETER) { + setKey(out, name, converted); } } return out; @@ -578,13 +610,7 @@ const convertComponents = (value: unknown, context: Context): unknown => { continue; } case "headers": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertParameterOrHeader) - ) - ); + setKey(out, key, convertHeaderMap(item, context)); continue; } case "examples": { @@ -659,10 +685,27 @@ const convertComponents = (value: unknown, context: Context): unknown => { const indexRemovedParameterRefs = (components: unknown): Set => { const removed = new Set(); const parameters = isRecord(components) ? components.parameters : undefined; - if (isRecord(parameters)) { - for (const [name, parameter] of Object.entries(parameters)) { - if (isQuerystringParameter(parameter)) { - removed.add(PARAMETERS_REF_PREFIX + name); + if (!isRecord(parameters)) { + return removed; + } + const entries = Object.entries(parameters); + // Iterated to a fixpoint so chains of reference aliases to removed + // querystring entries are removed with their targets. + let changed = true; + while (changed) { + changed = false; + for (const [name, parameter] of entries) { + const selfRef = PARAMETERS_REF_PREFIX + name; + if (removed.has(selfRef)) { + continue; + } + const target = getRef(parameter); + if ( + isQuerystringParameter(parameter) || + (target !== undefined && removed.has(target)) + ) { + removed.add(selfRef); + changed = true; } } } From 78688e156eb8da4343d720939fd933774f1cd654 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 29 Aug 2026 20:03:55 +0700 Subject: [PATCH 4/7] fix(downgrader): survive cyclic object graphs deepClone now tracks visited containers in a WeakMap, preserving cycles and shared references in the clone instead of recursing forever, and the recursive converter choke points (schema, path item, media type) carry a cycle guard: a subtree that cycles back into an ancestor falls back to the cycle-preserving clone instead of converting without bound. The never-throws contract now genuinely covers dereferencer-style cyclic input; only pathologically deep nesting remains out of scope. --- packages/downgrader/README.md | 2 +- packages/downgrader/src/shared.test.ts | 47 ++++++ packages/downgrader/src/shared.ts | 142 +++++++++++++------ packages/downgrader/src/v3.1-to-v3.0.test.ts | 24 ++++ packages/downgrader/src/v3.1-to-v3.0.ts | 136 +++++++++--------- packages/downgrader/src/v3.2-to-v3.1.test.ts | 16 +++ packages/downgrader/src/v3.2-to-v3.1.ts | 138 +++++++++--------- 7 files changed, 328 insertions(+), 177 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index dbe5932..4b08a0d 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -2,7 +2,7 @@ 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 on JSON-shaped input**: malformed parts are deep-copied through unchanged instead of failing the whole conversion. Cyclic object graphs (e.g. the output of a `$ref` dereferencer) and pathologically deep nesting are out of scope — feed the converters what `JSON.parse` could have produced. +- **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. diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 5470a34..7e5a128 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -5,6 +5,7 @@ import { mapArray, mapRecord, setKey, + withCycleGuard, } from "./shared"; import type { UnknownRecord } from "./shared"; @@ -218,3 +219,49 @@ describe("setKey", () => { expect(({} as UnknownRecord).polluted).toBeUndefined(); }); }); + +describe("deepClone with cycles and shared references", () => { + 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"); + // SAFETY: deepClone preserves the runtime shape; the test pins the cycle. + expect((clone.child as UnknownRecord).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 input = { x: shared, y: shared }; + const clone = deepClone(input); + expect(clone.x).toEqual({ a: 1 }); + expect(clone.x).not.toBe(shared); + expect(clone.x).toBe(clone.y); + }); +}); + +describe("withCycleGuard", () => { + it("falls back to a cycle-preserving clone on re-entry", () => { + const target: UnknownRecord = {}; + target.self = target; + const result = withCycleGuard(target, () => + withCycleGuard(target, () => "converted") + ); + expect(result).not.toBe("converted"); + expect(result).not.toBe(target); + // SAFETY: the assertion above pins the fallback to the cloned record. + const clone = result as UnknownRecord; + expect(clone.self).toBe(clone); + }); +}); diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index a8972fe..e6e0f7c 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -1,4 +1,4 @@ -/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, 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 */ +/* 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: @@ -23,52 +23,89 @@ export const isRecord = (value: unknown): value is UnknownRecord => { }; /** - * A JSON-oriented deep clone that never throws: non-plain values (class - * instances, functions, ...) are kept by reference, and hostile keys like - * `__proto__` are copied as own data properties instead of being assigned. + * Sets a key on the output record with define-property semantics, so hostile + * key names like `__proto__` become plain own properties. */ -export const deepClone = (value: T): T => { +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)) { - // SAFETY: mapping an array element-wise preserves its runtime shape. - return value.map((item) => deepClone(item)) as T; + const existing = seen.get(value); + if (existing !== undefined) { + return existing; + } + const out: unknown[] = []; + seen.set(value, out); + for (const item of value) { + out.push(cloneValue(item, seen)); + } + return out; } if (isRecord(value)) { - // SAFETY: rebuilding a plain object entry-wise preserves its runtime shape. - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, deepClone(item)]) - ) as T; + const existing = seen.get(value); + if (existing !== undefined) { + return existing; + } + const out: UnknownRecord = {}; + seen.set(value, out); + for (const [key, item] of Object.entries(value)) { + setKey(out, key, cloneValue(item, seen)); + } + return out; } return value; }; /** - * Applies `convert` to every value of a plain object, preserving key order. - * Non-object input is deep-cloned unchanged. + * 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 mapRecord = ( - value: unknown, - convert: (item: unknown, key: string) => unknown -): unknown => { - if (!isRecord(value)) { - return deepClone(value); +export const deepClone = (value: T): T => { + if (!(Array.isArray(value) || isRecord(value))) { + return value; } - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, convert(item, key)]) - ); + // 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(); + /** - * Applies `convert` to every element of an array. Non-array input is - * deep-cloned unchanged. + * Runs `convert` unless `value` is already being converted higher up the + * call stack — a cyclic reference, which would otherwise recurse forever. + * The cycling subtree falls back to a cycle-preserving deep clone. */ -export const mapArray = ( - value: unknown, - convert: (item: unknown) => unknown +export const withCycleGuard = ( + value: UnknownRecord, + convert: () => unknown ): unknown => { - if (!Array.isArray(value)) { + if (converting.has(value)) { return deepClone(value); } - return value.map((item) => convert(item)); + converting.add(value); + try { + return convert(); + } finally { + converting.delete(value); + } }; /** @@ -90,6 +127,36 @@ export const omitKeys = ( ); }; +/** + * Applies `convert` to every value of a plain object, preserving key order. + * 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); + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, convert(item, key)]) + ); +}; + +/** + * Applies `convert` to every element of an array. 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)); +}; + /** * Returns the `$ref` string of a Reference-Object-shaped value, or * `undefined` when the value is not one. @@ -100,20 +167,3 @@ export const getRef = (value: unknown): string | undefined => { } return undefined; }; - -/** - * 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, - }); -}; diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 1571f43..587d98a 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -2,6 +2,7 @@ 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 => @@ -1688,3 +1689,26 @@ describe("deep documents", () => { expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow(); }); }); + +describe("cyclic input", () => { + it("converts a schema whose subtree cycles back to itself without throwing", () => { + const properties: UnknownRecord = {}; + const node: UnknownRecord = { properties, type: "object" }; + properties.self = node; + const result = downgradeSchemaV31ToV30(asSchema(node)); + expect(result).toHaveProperty(["properties", "self", "type"], "object"); + }); + + 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(() => + downgradeSpecV31ToV30( + asSpec({ info, openapi: "3.1.0", paths: { "/a": pathItem } }) + ) + ).not.toThrow(); + }); +}); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 848aefe..681a1ef 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -4,10 +4,11 @@ * Converts OpenAPI 3.1 documents and schemas to OpenAPI 3.0 (targeting the * latest patch release, 3.0.4). * - * The conversion never throws on JSON-shaped (acyclic) input: parts that do - * not match the expected shape are deep-copied through unchanged, and - * existing specification extensions (`x-` keys) as well as unknown keys are - * always preserved. Constructs 3.0 + * 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: * @@ -42,6 +43,7 @@ import { mapRecord, omitKeys, setKey, + withCycleGuard, } from "./shared"; const HTTP_METHODS = new Set([ @@ -306,22 +308,24 @@ const convertSchema = (schema: unknown): unknown => { if (typeof ref === "string" && Object.keys(schema).length === 1) { return { $ref: ref }; } - // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertSchemaFields, as schemas are recursive structures - const out = convertSchemaFields(schema); - 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; + return withCycleGuard(schema, () => { + // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertSchemaFields, as schemas are recursive structures + const out = convertSchemaFields(schema); + 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; + }); }; /** Converts the keywords holding nested schemas; returns whether `key` was one. */ @@ -651,31 +655,33 @@ const convertMediaType = (value: unknown): unknown => { if (!isRecord(value)) { return deepClone(value); } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "schema": { - setKey(out, key, convertSchema(item)); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, deepClone)) - ); - continue; - } - case "encoding": { - setKey(out, key, mapRecord(item, convertEncoding)); - continue; - } - default: { - setKey(out, key, deepClone(item)); + return withCycleGuard(value, () => { + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + case "schema": { + setKey(out, key, convertSchema(item)); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => convertRefOr(entry, deepClone)) + ); + continue; + } + case "encoding": { + setKey(out, key, mapRecord(item, convertEncoding)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } } } - } - return out; + return out; + }); }; const convertRequestBody = (value: unknown): unknown => { @@ -805,29 +811,31 @@ const convertPathItem = ( if (!isRecord(value)) { return deepClone(value); } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (HTTP_METHODS.has(key)) { - setKey(out, key, convertOperation(item, index)); - continue; - } - switch (key) { - case "parameters": { - setKey( - out, - key, - mapArray(item, (entry) => - convertRefOr(entry, convertParameterOrHeader) - ) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); + return withCycleGuard(value, () => { + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (HTTP_METHODS.has(key)) { + setKey(out, key, convertOperation(item, index)); + continue; + } + switch (key) { + case "parameters": { + setKey( + out, + key, + mapArray(item, (entry) => + convertRefOr(entry, convertParameterOrHeader) + ) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } } } - } - return out; + return out; + }); }; const convertPaths = (value: unknown, index: SecuritySchemeIndex): unknown => diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index bb3cde1..be5a18c 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -3,6 +3,7 @@ import type { OpenAPIV3_2 } from "@oasty/types"; import { describe, expect, it } from "vitest"; +import type { UnknownRecord } from "./shared"; import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from "./v3.2-to-v3.1"; const asSpec = (value: unknown): OpenAPIV3_2.OpenAPIObject => @@ -2183,3 +2184,18 @@ describe("malformed header maps", () => { }); }); }); + +describe("cyclic input", () => { + 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(() => + downgradeSpecV32ToV31( + asSpec({ openapi: "3.2.0", paths: { "/a": pathItem } }) + ) + ).not.toThrow(); + }); +}); diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index ae91014..2ba3cb3 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -4,10 +4,11 @@ * Converts OpenAPI 3.2 documents and schemas to OpenAPI 3.1 (targeting the * latest patch release, 3.1.2). * - * The conversion never throws on JSON-shaped (acyclic) input: parts that do - * not match the expected shape are deep-copied through unchanged, and - * existing specification extensions (`x-` keys) as well as unknown keys are - * always preserved. Constructs 3.1 cannot express are converted where an + * 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: * @@ -52,6 +53,7 @@ import { mapRecord, omitKeys, setKey, + withCycleGuard, } from "./shared"; const MEDIA_TYPES_REF_PREFIX = "#/components/mediaTypes/"; @@ -331,48 +333,50 @@ const convertMediaType = (value: unknown, context: Context): unknown => { if (!isRecord(value)) { return deepClone(value); } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - // `itemSchema` is handled below; `description`, positional encoding, - // and nested encoding are 3.2-only. - case "description": - case "itemEncoding": - case "itemSchema": - case "prefixEncoding": { - continue; - } - case "encoding": { - setKey( - out, - key, - mapRecord(item, (entry) => convertEncoding(entry, context)) - ); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertExample) - ) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); + return withCycleGuard(value, () => { + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + switch (key) { + // `itemSchema` is handled below; `description`, positional encoding, + // and nested encoding are 3.2-only. + case "description": + case "itemEncoding": + case "itemSchema": + case "prefixEncoding": { + continue; + } + case "encoding": { + setKey( + out, + key, + mapRecord(item, (entry) => convertEncoding(entry, context)) + ); + continue; + } + case "examples": { + setKey( + out, + key, + mapRecord(item, (entry) => + convertRefOr(entry, context, convertExample) + ) + ); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } } } - } - if ("itemSchema" in value && out.schema === undefined) { - // The 3.2 sequential media type data model maps streams to arrays. - setKey(out, "schema", { - items: deepClone(value.itemSchema), - type: "array", - }); - } - return out; + if ("itemSchema" in value && out.schema === undefined) { + // The 3.2 sequential media type data model maps streams to arrays. + setKey(out, "schema", { + items: deepClone(value.itemSchema), + type: "array", + }); + } + return out; + }); }; const resolveContentEntry = (value: unknown, context: Context): unknown => { @@ -531,32 +535,34 @@ const convertPathItem = (value: unknown, context: Context): unknown => { if (!isRecord(value)) { return deepClone(value); } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (HTTP_METHODS.has(key)) { - setKey(out, key, convertOperation(item, context)); - continue; - } - switch (key) { - // The QUERY method and arbitrary additional operations are 3.2-only. - case "additionalOperations": - case "query": { - continue; - } - case "parameters": { - setKey(out, key, convertParameterList(item, context)); + return withCycleGuard(value, () => { + const out: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + if (HTTP_METHODS.has(key)) { + setKey(out, key, convertOperation(item, context)); continue; } - case "servers": { - setKey(out, key, mapArray(item, convertServer)); - continue; - } - default: { - setKey(out, key, deepClone(item)); + switch (key) { + // The QUERY method and arbitrary additional operations are 3.2-only. + case "additionalOperations": + case "query": { + continue; + } + case "parameters": { + setKey(out, key, convertParameterList(item, context)); + continue; + } + case "servers": { + setKey(out, key, mapArray(item, convertServer)); + continue; + } + default: { + setKey(out, key, deepClone(item)); + } } } - } - return out; + return out; + }); }; const convertPaths = (value: unknown, context: Context): unknown => From 106d75b783ade739aad70174c283565bd3c7c1e1 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 29 Aug 2026 21:11:53 +0700 Subject: [PATCH 5/7] fix(downgrader): address second external review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - correct the schema pass-through docs: the OAS 3.1 base-vocabulary meta-schema closes XML and Discriminator Objects to x- extras, so nodeType/defaultMapping are deliberately retained rather than 'legal' - pre-index components.parameters AND components.headers entries that conversion removes (querystring, or content fully uninlinable), iterated to a fixpoint over reference aliases, and remove references to them from parameter lists and header maps — no more dangling refs - synthesize required: true on path parameters (3.0 mandates it; 3.1 only structurally enforces it for schema-based ones) - add a prepack build guard so a publish can never ship an empty dist - corpus now validates every INPUT document too (3.1 and 3.2, the validator supports both), making valid-in -> valid-out an asserted property; style-defaults exclusion note corrected to the real blocker (x-comment in an Encoding Object, which the official 3.0 schema rejects for lack of an x- carve-out) --- packages/downgrader/README.md | 2 +- packages/downgrader/package.json | 1 + packages/downgrader/src/v3.1-to-v3.0.test.ts | 35 +++++ packages/downgrader/src/v3.1-to-v3.0.ts | 14 +- packages/downgrader/src/v3.2-to-v3.1.test.ts | 64 +++++++++ packages/downgrader/src/v3.2-to-v3.1.ts | 132 +++++++++++++++---- packages/downgrader/tests/corpus.test.ts | 11 +- 7 files changed, 225 insertions(+), 34 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 4b08a0d..87853a5 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -29,7 +29,7 @@ const schema = downgradeSchemaV31ToV30({ type: ["string", "null"] }); ## 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 JSON Schema allows arbitrary extra keywords without an `x-` prefix, so the 3.2-only fields (discriminator `defaultMapping`, XML `nodeType`) legally ride along as extra keys. Note the semantic cost: 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). +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: diff --git a/packages/downgrader/package.json b/packages/downgrader/package.json index 7238436..cf8fc94 100644 --- a/packages/downgrader/package.json +++ b/packages/downgrader/package.json @@ -59,6 +59,7 @@ }, "scripts": { "build": "unbuild", + "prepack": "unbuild", "type:check": "tsc -b" }, "dependencies": { diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 587d98a..7bde745 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -1712,3 +1712,38 @@ describe("cyclic input", () => { ).not.toThrow(); }); }); + +describe("path parameter required synthesis", () => { + it("adds required: true to path parameters that lack it", () => { + const result = downgradeSpecV31ToV30( + asSpec({ + info, + openapi: "3.1.0", + paths: { + "/a/{id}": { + get: { + parameters: [ + { + content: { "text/plain": { schema: { type: "string" } } }, + in: "path", + name: "id", + }, + { in: "query", name: "q", schema: {} }, + ], + responses: {}, + }, + }, + }, + }) + ); + expect(result.paths?.["/a/{id}"]?.get?.parameters).toEqual([ + { + content: { "text/plain": { schema: { type: "string" } } }, + in: "path", + name: "id", + required: true, + }, + { in: "query", name: "q", schema: {} }, + ]); + }); +}); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 681a1ef..af04015 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -267,10 +267,11 @@ const convertContentKeywords = ( }; /** - * 3.1 documents produced from 3.2 ones may carry the 3.2 `nodeType` field in - * XML Objects (a legal extra key in 3.1, but 3.0 forbids unknown XML Object - * fields): it maps back to the `attribute`/`wrapped` flags where expressible - * and is removed. + * 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 => { if (!isRecord(value) || !("nodeType" in value)) { @@ -627,6 +628,11 @@ const convertParameterOrHeader = (value: unknown): unknown => { } } } + if (value.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; }; diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index be5a18c..d20a67c 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -2199,3 +2199,67 @@ describe("cyclic input", () => { ).not.toThrow(); }); }); + +describe("references to components removed for uninlinable content", () => { + it("removes parameter and header references whose targets lost their entire content", () => { + const result = downgradeSpecV32ToV31( + asSpec({ + components: { + headers: { + Broken: { + content: { + "text/plain": { $ref: "#/components/mediaTypes/Loop" }, + }, + }, + BrokenAlias: { $ref: "#/components/headers/Broken" }, + }, + mediaTypes: { Loop: { $ref: "#/components/mediaTypes/Loop" } }, + parameters: { + Broken: { + content: { + "text/plain": { $ref: "#/components/mediaTypes/Missing" }, + }, + in: "query", + name: "q", + }, + BrokenAlias: { $ref: "#/components/parameters/Broken" }, + }, + }, + openapi: "3.2.0", + 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).toEqual({ + components: { headers: {}, parameters: {} }, + openapi: "3.1.2", + paths: { + "/a": { + get: { + parameters: [], + responses: { "200": { description: "ok", headers: {} } }, + }, + }, + }, + }); + }); +}); diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 2ba3cb3..4d87c10 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -33,10 +33,12 @@ * 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), and JSON Schema allows - * arbitrary extra keywords, so the 3.2-only fields (discriminator - * `defaultMapping`, XML `nodeType`) legally ride along as extra keys — - * 3.1 tooling will not act on them. + * 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} @@ -56,6 +58,7 @@ import { withCycleGuard, } from "./shared"; +const HEADERS_REF_PREFIX = "#/components/headers/"; const MEDIA_TYPES_REF_PREFIX = "#/components/mediaTypes/"; const PARAMETERS_REF_PREFIX = "#/components/parameters/"; @@ -84,7 +87,9 @@ const DROPPED_PARAMETER = Symbol("dropped parameter"); interface Context { /** The raw `components.mediaTypes` map, used to inline references. */ mediaTypes: UnknownRecord | undefined; - /** `$ref` strings of removed querystring `components.parameters` entries. */ + /** `$ref` strings of `components.headers` entries conversion removes. */ + removedHeaderRefs: ReadonlySet; + /** `$ref` strings of `components.parameters` entries conversion removes. */ removedParameterRefs: ReadonlySet; /** Media type names currently being resolved, for cycle detection. */ resolving: Set; @@ -105,10 +110,11 @@ const parseMediaTypeName = (ref: string): string | undefined => { * 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), and JSON Schema allows arbitrary extra keywords without an `x-` - * prefix, so even the 3.2-only fields (discriminator `defaultMapping`, XML - * `nodeType`) are legal to keep as-is — though 3.1 tooling will not act on - * them. + * 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 @@ -202,10 +208,13 @@ const convertRefOr = ( const isQuerystringParameter = (value: unknown): boolean => isRecord(value) && value.in === "querystring"; -/** Whether the value references a removed querystring component parameter. */ -const isRemovedParameterRef = (value: unknown, context: Context): boolean => { +/** Whether the value references a component entry conversion removes. */ +const isRemovedRef = ( + value: unknown, + removed: ReadonlySet +): boolean => { const ref = getRef(value); - return ref !== undefined && context.removedParameterRefs.has(ref); + return ref !== undefined && removed.has(ref); }; /** Parameter Objects and Header Objects share every field this converter touches. */ @@ -280,7 +289,10 @@ const convertParameterList = (value: unknown, context: Context): unknown => { return value .filter( (item) => - !(isQuerystringParameter(item) || isRemovedParameterRef(item, context)) + !( + isQuerystringParameter(item) || + isRemovedRef(item, context.removedParameterRefs) + ) ) .map((item) => convertRefOr(item, context, convertParameterOrHeader)) .filter((item) => item !== DROPPED_PARAMETER); @@ -296,6 +308,9 @@ const convertHeaderMap = (value: unknown, context: Context): unknown => { } const out: UnknownRecord = {}; for (const [name, item] of Object.entries(value)) { + if (isRemovedRef(item, context.removedHeaderRefs)) { + continue; + } const converted = convertRefOr(item, context, convertParameterOrHeader); if (converted !== DROPPED_PARAMETER) { setKey(out, name, converted); @@ -583,7 +598,10 @@ const convertParameterComponents = ( } const out: UnknownRecord = {}; for (const [name, item] of Object.entries(value)) { - if (isQuerystringParameter(item) || isRemovedParameterRef(item, context)) { + if ( + isQuerystringParameter(item) || + isRemovedRef(item, context.removedParameterRefs) + ) { continue; } const converted = convertRefOr(item, context, convertParameterOrHeader); @@ -688,26 +706,78 @@ const convertComponents = (value: unknown, context: Context): unknown => { }; /** Collects the `$ref` strings of querystring `components.parameters` entries. */ -const indexRemovedParameterRefs = (components: unknown): Set => { +/** + * Whether a content-map entry is a reference that cannot be inlined + * (external, unknown, or cyclic target) and would therefore be removed. + */ +const isUnresolvableContentRef = ( + item: unknown, + mediaTypes: UnknownRecord | undefined, + seen: Set +): boolean => { + const ref = getRef(item); + if (ref === undefined) { + return false; + } + const name = parseMediaTypeName(ref); + if ( + name === undefined || + mediaTypes === undefined || + !Object.hasOwn(mediaTypes, name) || + seen.has(name) + ) { + return true; + } + seen.add(name); + return isUnresolvableContentRef(mediaTypes[name], mediaTypes, seen); +}; + +/** Whether conversion would strip 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) => + isUnresolvableContentRef(item, mediaTypes, new Set()) + ) + ); +}; + +/** + * 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(); - const parameters = isRecord(components) ? components.parameters : undefined; - if (!isRecord(parameters)) { + if (!isRecord(map)) { return removed; } - const entries = Object.entries(parameters); - // Iterated to a fixpoint so chains of reference aliases to removed - // querystring entries are removed with their targets. + const entries = Object.entries(map); let changed = true; while (changed) { changed = false; - for (const [name, parameter] of entries) { - const selfRef = PARAMETERS_REF_PREFIX + name; + for (const [name, item] of entries) { + const selfRef = prefix + name; if (removed.has(selfRef)) { continue; } - const target = getRef(parameter); + const target = getRef(item); if ( - isQuerystringParameter(parameter) || + isDirectlyRemoved(item) || + losesEntireContent(item, mediaTypes) || (target !== undefined && removed.has(target)) ) { removed.add(selfRef); @@ -727,9 +797,21 @@ const convertSpec = (spec: unknown): unknown => { isRecord(components) && isRecord(components.mediaTypes) ? components.mediaTypes : undefined; + const componentMaps = isRecord(components) ? components : undefined; const context: Context = { mediaTypes, - removedParameterRefs: indexRemovedParameterRefs(components), + removedHeaderRefs: indexRemovedComponentRefs( + componentMaps?.headers, + HEADERS_REF_PREFIX, + mediaTypes, + () => false + ), + removedParameterRefs: indexRemovedComponentRefs( + componentMaps?.parameters, + PARAMETERS_REF_PREFIX, + mediaTypes, + isQuerystringParameter + ), resolving: new Set(), }; const out: UnknownRecord = {}; diff --git a/packages/downgrader/tests/corpus.test.ts b/packages/downgrader/tests/corpus.test.ts index d94a03c..6711e8b 100644 --- a/packages/downgrader/tests/corpus.test.ts +++ b/packages/downgrader/tests/corpus.test.ts @@ -114,10 +114,11 @@ const expectValidAs = async ( * - `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`: its `encoding_object_defaults` parameter is a `content` - * path parameter without `required: true`; the official 3.1 schema only - * enforces `required` for schema-based path parameters while the 3.0 schema - * enforces it for all, so the input itself is inexpressible in valid 3.0. + * - `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, @@ -234,6 +235,7 @@ 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"); @@ -247,6 +249,7 @@ 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"); From a86b758c3d4860b1b9ef1216db848f0720c5f100 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 30 Aug 2026 10:56:12 +0700 Subject: [PATCH 6/7] fix(downgrader): intersect enum/const with a dropped null-only type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When type: "null" is dropped for 3.0 (nullable needs a sibling type), sibling enum/const constraints that the type used to filter became reachable: {type: "null", enum: ["a", null]} started accepting "a" and {type: "null", const: 7} started accepting 7 although the source accepted null and nothing respectively. The null-only branch now intersects: an enum containing null collapses to [null], and a sibling excluding null emits a match-nothing not: {} — which also neutralizes the enum convertConst writes. Types that survive keep enforcing the intersection themselves, so no other branch changes. --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 28 +++++++++++++++++--- packages/downgrader/src/v3.1-to-v3.0.ts | 22 +++++++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 87853a5..6542055 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -68,7 +68,7 @@ Schema Objects: | `$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]` | +| `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) | diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 7bde745..15c99ad 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -879,15 +879,37 @@ describe("downgradeSchemaV31ToV30", () => { }); }); - it("does not overwrite an existing enum for a null-only type", () => { + it("intersects an existing enum with a null-only type", () => { expect( downgradeSchemaV31ToV30({ enum: ["a", null], type: ["null"] }) - ).toEqual({ enum: ["a", null], nullable: true }); + ).toEqual({ enum: [null], nullable: true }); }); - it("lets const provide the enum for a null-only type", () => { + it("makes the schema match nothing when the enum of a null-only type excludes null", () => { + expect(downgradeSchemaV31ToV30({ enum: ["a"], type: ["null"] })).toEqual({ + enum: ["a"], + not: {}, + nullable: true, + }); + }); + + it("clones a malformed enum of a null-only type through", () => { + expect( + downgradeSchemaV31ToV30(asSchema({ enum: "junk", type: ["null"] })) + ).toEqual({ enum: "junk", nullable: true }); + }); + + it("keeps a null const as the enum of a null-only type", () => { + expect(downgradeSchemaV31ToV30({ const: null, type: ["null"] })).toEqual({ + enum: [null], + nullable: true, + }); + }); + + it("makes the schema match nothing when a non-null const contradicts a null-only type", () => { expect(downgradeSchemaV31ToV30({ const: 7, type: ["null"] })).toEqual({ enum: [7], + not: {}, nullable: true, }); }); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index af04015..2f9cc76 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -141,9 +141,27 @@ const applyTypes = ( return; } // `type: "null"` alone: 3.0's `nullable` needs a sibling `type`, so a - // single-value `enum` is the closest expressible form. + // 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 (!("enum" in schema) && !("const" in schema)) { + 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; From 69ae357deb834210011a8c142a77039a27b3ec2d Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 5 Sep 2026 21:59:59 +0700 Subject: [PATCH 7/7] refactor(downgrader): drive the converters with declarative field tables Replace the per-object `isRecord` guard / entries loop / switch / setKey boilerplate with a single `convertRecord(value, fields, finish?)` helper: each OpenAPI object is now a table mapping field names to a converter or `DROP`, with unknown keys and `x-` extensions deep-cloned by default. `DROP` is the one signal for "no place in the target version" and is honored by `convertRecord`, `mapRecord`, and `mapArray`, which retires the UNRESOLVED_MEDIA_TYPE / DROPPED_PARAMETER sentinels, `omitKeys`, and the separate DROPPED_*_KEYS sets. The 3.1 Schema Object keyword mapping is one sorted table instead of two sets plus two loops. The cycle guard now lives inside `convertRecord`, so every converted object is protected instead of three hand-picked choke points, and the 3.2 media-type reference resolution is shared between conversion and the removed-component pre-pass. Output is byte-for-byte unchanged (e2e snapshots and the official-schema corpus still pass); source shrinks from 808 to 467 statements at 100% coverage. Tests are regrouped by topic, the review-round additions are folded into their groups, repeated document scaffolding moves into small helpers, and pure keyword mappings become it.each tables. --- packages/downgrader/src/shared.test.ts | 272 +- packages/downgrader/src/shared.ts | 136 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 2253 +++++-------- packages/downgrader/src/v3.1-to-v3.0.ts | 968 ++---- packages/downgrader/src/v3.2-to-v3.1.test.ts | 3010 ++++++------------ packages/downgrader/src/v3.2-to-v3.1.ts | 883 ++--- 6 files changed, 2718 insertions(+), 4804 deletions(-) diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 7e5a128..a1a610b 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -1,16 +1,32 @@ +/* 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, - withCycleGuard, } from "./shared"; -import type { UnknownRecord } 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); @@ -60,17 +76,11 @@ describe("deepClone", () => { expect(clone.nested.inner).not.toBe(input.nested.inner); }); - it("keeps functions by reference", () => { - const input = { fn: identity }; - const clone = deepClone(input); - expect(clone.fn).toBe(identity); - }); - - it("keeps class instances by reference", () => { + it("keeps functions and class instances by reference", () => { const date = new Date(); const map = new Map(); - const input = { date, map }; - const clone = deepClone(input); + const clone = deepClone({ date, fn: identity, map }); + expect(clone.fn).toBe(identity); expect(clone.date).toBe(date); expect(clone.map).toBe(map); }); @@ -84,18 +94,84 @@ describe("deepClone", () => { it("copies a hostile __proto__ own key as a plain own data property without prototype pollution", () => { const input: unknown = JSON.parse('{"__proto__": {"polluted": true}}'); - const clone = deepClone(input); - // SAFETY: JSON.parse produces a plain object; the cast lets the test inspect its own keys. - const cloneRecord = clone as UnknownRecord; - expect(Object.getOwnPropertyNames(cloneRecord)).toContain("__proto__"); - const descriptor = Object.getOwnPropertyDescriptor( - cloneRecord, - "__proto__" + 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(descriptor?.value).toEqual({ polluted: true }); - expect(Object.getPrototypeOf(cloneRecord)).toBe(Object.prototype); - // SAFETY: probing an arbitrary key on a fresh object to prove Object.prototype was not polluted. - expect(({} as UnknownRecord).polluted).toBeUndefined(); + 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", () => { @@ -103,8 +179,80 @@ describe("deepClone", () => { input.zebra = 1; input.apple = 2; input.mango = 3; - const clone = deepClone(input); - expect(Object.keys(clone)).toEqual(["zebra", "apple", "mango"]); + 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 + ); }); }); @@ -123,13 +271,19 @@ describe("mapRecord", () => { ]); }); + 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 = mapRecord(input, (item) => item); - // SAFETY: mapRecord returns a plain object for plain-object input. - expect(Object.keys(result as UnknownRecord)).toEqual(["zebra", "apple"]); + const result = asRecord(mapRecord(input, identity)); + expect(Object.keys(result)).toEqual(["zebra", "apple"]); }); it("deep-clones non-object input unchanged without calling the converter", () => { @@ -138,7 +292,6 @@ describe("mapRecord", () => { const result = mapRecord(array, convert); expect(result).toEqual(array); expect(result).not.toBe(array); - expect(convert).not.toHaveBeenCalled(); expect(mapRecord("text", convert)).toBe("text"); expect(mapRecord(null, convert)).toBe(null); expect(convert).not.toHaveBeenCalled(); @@ -156,13 +309,17 @@ describe("mapArray", () => { 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(convert).not.toHaveBeenCalled(); expect(mapArray(7, convert)).toBe(7); expect(mapArray(undefined, convert)).toBe(undefined); expect(convert).not.toHaveBeenCalled(); @@ -183,12 +340,9 @@ describe("getRef", () => { expect(getRef([{ $ref: "#/x" }])).toBeUndefined(); }); - it("returns undefined when $ref is missing", () => { + it("returns undefined when $ref is missing or not a string", () => { expect(getRef({})).toBeUndefined(); expect(getRef({ ref: "#/x" })).toBeUndefined(); - }); - - it("returns undefined when $ref is not a string", () => { expect(getRef({ $ref: 42 })).toBeUndefined(); expect(getRef({ $ref: { nested: true } })).toBeUndefined(); expect(getRef({ $ref: null })).toBeUndefined(); @@ -199,8 +353,7 @@ describe("setKey", () => { it("defines an enumerable, writable, configurable own property", () => { const target: UnknownRecord = {}; setKey(target, "name", "value"); - const descriptor = Object.getOwnPropertyDescriptor(target, "name"); - expect(descriptor).toEqual({ + expect(Object.getOwnPropertyDescriptor(target, "name")).toEqual({ configurable: true, enumerable: true, value: "value", @@ -215,53 +368,6 @@ describe("setKey", () => { expect(descriptor?.value).toEqual({ polluted: true }); expect(descriptor?.enumerable).toBe(true); expect(Object.getPrototypeOf(target)).toBe(Object.prototype); - // SAFETY: probing an arbitrary key on a fresh object to prove Object.prototype was not polluted. - expect(({} as UnknownRecord).polluted).toBeUndefined(); - }); -}); - -describe("deepClone with cycles and shared references", () => { - 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"); - // SAFETY: deepClone preserves the runtime shape; the test pins the cycle. - expect((clone.child as UnknownRecord).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 input = { x: shared, y: shared }; - const clone = deepClone(input); - expect(clone.x).toEqual({ a: 1 }); - expect(clone.x).not.toBe(shared); - expect(clone.x).toBe(clone.y); - }); -}); - -describe("withCycleGuard", () => { - it("falls back to a cycle-preserving clone on re-entry", () => { - const target: UnknownRecord = {}; - target.self = target; - const result = withCycleGuard(target, () => - withCycleGuard(target, () => "converted") - ); - expect(result).not.toBe("converted"); - expect(result).not.toBe(target); - // SAFETY: the assertion above pins the fallback to the cloned record. - const clone = result as UnknownRecord; - expect(clone.self).toBe(clone); + expect(asRecord({}).polluted).toBeUndefined(); }); }); diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index e6e0f7c..58906db 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -9,6 +9,42 @@ // 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 @@ -43,11 +79,14 @@ 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 existing = seen.get(value); - if (existing !== undefined) { - return existing; - } const out: unknown[] = []; seen.set(value, out); for (const item of value) { @@ -55,19 +94,12 @@ const cloneValue = ( } return out; } - if (isRecord(value)) { - const existing = seen.get(value); - if (existing !== undefined) { - return existing; - } - const out: UnknownRecord = {}; - seen.set(value, out); - for (const [key, item] of Object.entries(value)) { - setKey(out, key, 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 value; + return out; }; /** @@ -89,48 +121,47 @@ export const deepClone = (value: T): T => { const converting = new WeakSet(); /** - * Runs `convert` unless `value` is already being converted higher up the - * call stack — a cyclic reference, which would otherwise recurse forever. - * The cycling subtree falls back to a cycle-preserving deep clone. + * 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 withCycleGuard = ( - value: UnknownRecord, - convert: () => unknown +export const convertRecord = ( + value: unknown, + fields: FieldTable, + finish?: (out: UnknownRecord, source: UnknownRecord) => unknown ): unknown => { - if (converting.has(value)) { + if (!isRecord(value) || converting.has(value)) { return deepClone(value); } converting.add(value); try { - return convert(); + 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); } }; /** - * Deep-clones a plain object without the given keys, the building block for - * removing fields the target version cannot express. Non-object input is + * 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 omitKeys = ( - value: unknown, - keys: ReadonlySet -): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - return Object.fromEntries( - Object.entries(value) - .filter(([key]) => !keys.has(key)) - .map(([key, item]) => [key, deepClone(item)]) - ); -}; - -/** - * Applies `convert` to every value of a plain object, preserving key order. - * Non-object input is deep-cloned unchanged. - */ export const mapRecord = ( value: unknown, convert: (item: unknown, key: string) => unknown @@ -138,14 +169,19 @@ export const mapRecord = ( if (!isRecord(value)) { return deepClone(value); } - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, convert(item, key)]) - ); + 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. Non-array input is - * deep-cloned unchanged. + * 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, @@ -154,7 +190,7 @@ export const mapArray = ( if (!Array.isArray(value)) { return deepClone(value); } - return value.map((item) => convert(item)); + return value.map((item) => convert(item)).filter((item) => item !== DROP); }; /** diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 15c99ad..73efa05 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -1,4 +1,4 @@ -/* oxlint-disable anti-slop/no-unknown-parameters -- the cast helpers deliberately accept `unknown` so tests can feed malformed input to the graceful-degradation branches */ +/* 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"; @@ -13,263 +13,173 @@ 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("version and top-level keys", () => { + describe("document", () => { it("rewrites the openapi version to 3.0.4", () => { expect( downgradeSpecV31ToV30({ info, openapi: "3.1.1", paths: {} }) - ).toEqual({ info, openapi: "3.0.4", paths: {} }); - }); - - it("adds openapi 3.0.4 when the openapi key is missing", () => { - expect(downgradeSpecV31ToV30(asSpec({ info, paths: {} }))).toEqual({ - info, - openapi: "3.0.4", - paths: {}, - }); - }); - - it("clones non-object spec input unchanged", () => { - expect(downgradeSpecV31ToV30(asSpec(null))).toBeNull(); - expect(downgradeSpecV31ToV30(asSpec(42))).toBe(42); - expect(downgradeSpecV31ToV30(asSpec("spec"))).toBe("spec"); + ).toEqual(converted); }); - it("clones array spec input into a new array", () => { - const input = [1, { a: 1 }]; - const result = downgradeSpecV31ToV30(asSpec(input)); - expect(result).toEqual(input); - expect(result).not.toBe(input); + it("adds openapi 3.0.4 and an empty paths object when they are missing", () => { + expect(downgradeSpecV31ToV30(asSpec({ info }))).toEqual(converted); }); - it("drops jsonSchemaDialect", () => { - expect( - downgradeSpecV31ToV30({ - info, - jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", - openapi: "3.1.0", - paths: {}, - }) - ).toEqual({ info, openapi: "3.0.4", paths: {} }); + 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( - downgradeSpecV31ToV30( - asSpec({ - future: { a: 1 }, - info, - openapi: "3.1.0", - paths: {}, - "x-root": true, - }) - ) - ).toEqual({ + expect(convertSpec({ future: { a: 1 }, "x-root": true })).toEqual({ + ...converted, future: { a: 1 }, - info, - openapi: "3.0.4", - paths: {}, "x-root": true, }); }); - }); - describe("info", () => { - it("removes info.summary", () => { - expect( - downgradeSpecV31ToV30({ - info: { summary: "short", title: "t", version: "1" }, - openapi: "3.1.0", - paths: {}, - }) - ).toEqual({ - info: { title: "t", version: "1" }, - openapi: "3.0.4", - paths: {}, - }); + 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); }); + }); - it("removes license.identifier and keeps the other license fields", () => { + describe("info", () => { + it("removes summary and license.identifier and keeps the other fields", () => { expect( - downgradeSpecV31ToV30({ + convertSpec({ info: { license: { identifier: "MIT", name: "MIT", url: "https://opensource.org/license/mit", }, + summary: "short", title: "t", version: "1", }, - openapi: "3.1.0", - paths: {}, - }) + }).info ).toEqual({ - info: { - license: { name: "MIT", url: "https://opensource.org/license/mit" }, - title: "t", - version: "1", - }, - openapi: "3.0.4", - paths: {}, + license: { name: "MIT", url: "https://opensource.org/license/mit" }, + title: "t", + version: "1", }); }); - it("clones malformed info unchanged", () => { + it("clones malformed info and license values unchanged", () => { + expect(convertSpec({ info: 42 }).info).toBe(42); expect( - downgradeSpecV31ToV30(asSpec({ info: 42, openapi: "3.1.0", paths: {} })) - ).toEqual({ info: 42, openapi: "3.0.4", paths: {} }); - }); - - it("clones a malformed license unchanged", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ - info: { license: "MIT", title: "t", version: "1" }, - openapi: "3.1.0", - paths: {}, - }) - ) + convertSpec({ info: { license: "MIT", title: "t", version: "1" } }).info ).toEqual({ - info: { license: "MIT", title: "t", version: "1" }, - openapi: "3.0.4", - paths: {}, + license: "MIT", + title: "t", + version: "1", }); }); }); describe("paths", () => { - it("adds an empty paths object when missing", () => { - expect(downgradeSpecV31ToV30({ info, openapi: "3.1.0" })).toEqual({ - info, - openapi: "3.0.4", - paths: {}, - }); - }); - - it("converts path items and clones non-path keys in paths", () => { + it("converts path items and clones non-path keys", () => { expect( - downgradeSpecV31ToV30({ - info, - openapi: "3.1.0", + 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({ - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { - responses: { default: { description: "" } }, - summary: "s", - }, - }, - "x-note": { get: { summary: "s" } }, - }, - }); - }); - - it("clones a malformed paths value unchanged", () => { - expect( - downgradeSpecV31ToV30(asSpec({ info, openapi: "3.1.0", paths: "junk" })) - ).toEqual({ info, openapi: "3.0.4", paths: "junk" }); - }); - }); - - describe("webhooks", () => { - it("removes webhooks entirely", () => { - const result = downgradeSpecV31ToV30({ - info, - openapi: "3.1.0", - webhooks: { newPet: { post: { summary: "s" } } }, - }); - expect(result).toEqual({ info, openapi: "3.0.4", paths: {} }); - expect(result).not.toHaveProperty("webhooks"); - expect(result).not.toHaveProperty("x-webhooks"); - }); - }); - - describe("components.pathItems", () => { - it("removes components.pathItems and keeps the other component maps", () => { - const result = downgradeSpecV31ToV30({ - components: { - pathItems: { Reusable: { get: { summary: "s" } } }, - schemas: { S: { type: "string" } }, + "/a": { + get: { responses: { default: { description: "" } }, summary: "s" }, }, - info, - openapi: "3.1.0", - paths: {}, + "x-note": { get: { summary: "s" } }, }); - expect(result).toEqual({ - components: { schemas: { S: { type: "string" } } }, - info, - openapi: "3.0.4", - paths: {}, - }); - expect(result.components).not.toHaveProperty("pathItems"); - expect(result.components).not.toHaveProperty("x-pathItems"); }); - it("leaves a path item $ref field pointing at components.pathItems untouched", () => { + it("leaves a path item $ref field untouched, string or not", () => { expect( - downgradeSpecV31ToV30({ - info, - openapi: "3.1.0", - paths: { - "/a": { $ref: "#/components/pathItems/Reusable", summary: "s" }, - }, + convertPathItem({ + $ref: "#/components/pathItems/Reusable", + summary: "s", }) ).toEqual({ - info, - openapi: "3.0.4", - paths: { - "/a": { $ref: "#/components/pathItems/Reusable", summary: "s" }, - }, + $ref: "#/components/pathItems/Reusable", + summary: "s", }); + expect(convertPathItem({ $ref: 42 })).toEqual({ $ref: 42 }); }); - it("clones a non-string path item $ref unchanged", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ info, openapi: "3.1.0", paths: { "/a": { $ref: 42 } } }) - ) - ).toEqual({ info, openapi: "3.0.4", paths: { "/a": { $ref: 42 } } }); - }); - - it("leaves reference objects pointing at components.pathItems untouched apart from override stripping", () => { - expect( - downgradeSpecV31ToV30({ - components: { - callbacks: { - cb: { $ref: "#/components/pathItems/Reusable", summary: "s" }, + 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: {}, }, - info, - openapi: "3.1.0", - paths: {}, - }) - ).toEqual({ - components: { - callbacks: { cb: { $ref: "#/components/pathItems/Reusable" } }, }, - info, - openapi: "3.0.4", - paths: {}, - }); + "/c": { get: "junk" }, + "/junk": "junk", + }; + expect(convertSpec({ paths }).paths).toEqual(paths); }); }); describe("reference objects", () => { it("strips reference summary and description across components maps", () => { expect( - downgradeSpecV31ToV30({ + convertSpec({ components: { callbacks: { C: { $ref: "#/c/cb", summary: "s" } }, examples: { E: { $ref: "#/c/e", description: "d" } }, @@ -282,488 +192,414 @@ describe("downgradeSpecV31ToV30", () => { responses: { R: { $ref: "#/c/r", description: "d" } }, securitySchemes: { S: { $ref: "#/c/s", description: "d" } }, }, - info, - openapi: "3.1.0", - paths: {}, - }) + }).components ).toEqual({ - components: { - 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" } }, - }, - info, - openapi: "3.0.4", - paths: {}, + 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( - downgradeSpecV31ToV30({ - info, - openapi: "3.1.0", - paths: { - "/a": { - 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" }], - }, + 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({ - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { - callbacks: { cb: { $ref: "#/c/cb" } }, - parameters: [{ $ref: "#/c/p" }], - requestBody: { $ref: "#/c/b" }, - responses: { "200": { $ref: "#/c/r" } }, - }, - parameters: [{ $ref: "#/c/pp" }], - }, + 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( - downgradeSpecV31ToV30({ - components: { - responses: { - R: { - 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" } }, - }, + convertComponent("responses", { + content: { + "application/json": { + examples: { e: { $ref: "#/c/e", summary: "s" } }, + schema: { type: ["string", "null"] }, }, }, - info, - openapi: "3.1.0", - paths: {}, + description: "ok", + headers: { H: { $ref: "#/c/h", summary: "s" } }, + links: { l: { $ref: "#/c/l", description: "d" } }, }) ).toEqual({ - components: { - responses: { - R: { - 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" } }, - }, + content: { + "application/json": { + examples: { e: { $ref: "#/c/e" } }, + schema: { nullable: true, type: "string" }, }, }, - info, - openapi: "3.0.4", - paths: {}, + description: "ok", + headers: { H: { $ref: "#/c/h" } }, + links: { l: { $ref: "#/c/l" } }, }); }); it("keeps x- entries in a responses map unconverted", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ - info, - openapi: "3.1.0", - paths: { - "/a": { - get: { - responses: { - "200": { description: "ok" }, - "x-note": { $ref: "#/c/r", summary: "s" }, - }, - }, - }, - }, - }) - ) - ).toEqual({ - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { - responses: { - "200": { description: "ok" }, - "x-note": { $ref: "#/c/r", summary: "s" }, - }, - }, - }, - }, + 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( - downgradeSpecV31ToV30({ - info, - openapi: "3.1.0", - paths: { "/a": { get: { operationId: "getA" } } }, - }) - ).toEqual({ - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { - operationId: "getA", - responses: { default: { description: "" } }, - }, - }, + expect(convertPathItem({ get: { operationId: "getA" } })).toEqual({ + get: { + operationId: "getA", + responses: { default: { description: "" } }, }, }); }); - it("clones a malformed operation unchanged", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ - info, - openapi: "3.1.0", - paths: { "/a": { get: "junk" } }, - }) - ) - ).toEqual({ - info, - openapi: "3.0.4", - paths: { "/a": { get: "junk" } }, - }); - }); - it("converts parameter schemas, content, and examples", () => { expect( - downgradeSpecV31ToV30({ - components: { - parameters: { - P: { + convertPathItem({ + get: { + parameters: [ + { examples: { e: { $ref: "#/c/e", summary: "s" } }, in: "query", name: "p", schema: { type: ["string", "null"] }, }, - Q: { + { content: { "text/plain": { schema: { type: ["integer", "null"] } }, }, in: "query", name: "q", }, - }, + ], + responses: {}, }, - info, - openapi: "3.1.0", - paths: {}, }) ).toEqual({ - components: { - parameters: { - P: { + get: { + parameters: [ + { examples: { e: { $ref: "#/c/e" } }, in: "query", name: "p", schema: { nullable: true, type: "string" }, }, - Q: { + { content: { - "text/plain": { - schema: { nullable: true, type: "integer" }, - }, + "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: {}, }, - info, - openapi: "3.0.4", - paths: {}, }); }); it("converts request body content, media type encoding, and encoding headers", () => { expect( - downgradeSpecV31ToV30({ - components: { - requestBodies: { - B: { - 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" }, + convertComponent("requestBodies", { + content: { + "multipart/form-data": { + encoding: { + field: { + contentType: "text/plain", + headers: { + H: { $ref: "#/c/h", summary: "s" }, + H2: { schema: { type: ["string", "null"] } }, }, }, - description: "body", - required: true, }, + example: { field: "v" }, + schema: { type: "object" }, }, }, - info, - openapi: "3.1.0", - paths: {}, + description: "body", + required: true, }) ).toEqual({ - components: { - requestBodies: { - B: { - 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" }, + content: { + "multipart/form-data": { + encoding: { + field: { + contentType: "text/plain", + headers: { + H: { $ref: "#/c/h" }, + H2: { schema: { nullable: true, type: "string" } }, }, }, - description: "body", - required: true, }, + example: { field: "v" }, + schema: { type: "object" }, }, }, - info, - openapi: "3.0.4", - paths: {}, + description: "body", + required: true, }); }); - it("converts schemas under components.schemas including boolean schemas", () => { + it("converts inline callback objects, cloning x- keys and junk entries", () => { expect( - downgradeSpecV31ToV30({ - components: { - schemas: { S: { type: ["string", "null"] }, T: true }, + convertPathItem({ + get: { + callbacks: { + inline: { expr: { get: {} }, "x-k": { expr: { get: {} } } }, + junk: 7, + }, + responses: {}, }, - info, - openapi: "3.1.0", - paths: {}, }) ).toEqual({ - components: { - schemas: { S: { nullable: true, type: "string" }, T: {} }, + get: { + callbacks: { + inline: { + expr: { get: { responses: { default: { description: "" } } } }, + "x-k": { expr: { get: {} } }, + }, + junk: 7, + }, + responses: {}, }, - info, - openapi: "3.0.4", - paths: {}, }); }); }); - describe("security", () => { - it("removes mutualTLS schemes and drops requirements that become empty", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ - components: { - securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - mtls: { type: "mutualTLS" }, - }, - }, - info, - openapi: "3.1.0", - paths: {}, - security: [{ mtls: [] }, { api: [], mtls: [] }, {}], - }) - ) - ).toEqual({ + describe("components", () => { + it("removes pathItems and keeps the other component maps", () => { + const result = convertSpec({ components: { - securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - }, + pathItems: { Reusable: { get: { summary: "s" } } }, + schemas: { S: { type: "string" } }, }, - info, - openapi: "3.0.4", - paths: {}, - security: [{ api: [] }, {}], }); + 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("empties roles on non-OAuth schemes", () => { + it("converts component callbacks and schemas, including boolean schemas", () => { expect( - downgradeSpecV31ToV30( - asSpec({ - components: { - securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - basic: { scheme: "basic", type: "http" }, + convertSpec({ + components: { + callbacks: { + junkCallback: 42, + realCallback: { + "x-note": { "{$expr}": { get: {} } }, + "{$request.body#/url}": { post: { summary: "s" } }, }, }, - info, - openapi: "3.1.0", - paths: {}, - security: [{ api: ["read"], basic: ["admin"] }], - }) - ) + schemas: { S: { type: ["string", "null"] }, T: true }, + "x-extra": { keep: true }, + }, + }).components ).toEqual({ - components: { - securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - basic: { scheme: "basic", type: "http" }, + callbacks: { + junkCallback: 42, + realCallback: { + "x-note": { "{$expr}": { get: {} } }, + "{$request.body#/url}": { + post: { + responses: { default: { description: "" } }, + summary: "s", + }, + }, }, }, - info, - openapi: "3.0.4", - paths: {}, - security: [{ api: [], basic: [] }], + schemas: { S: { nullable: true, type: "string" }, T: {} }, + "x-extra": { keep: true }, }); }); - it("keeps scopes for oauth2, openIdConnect, and unknown schemes", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ - components: { - securitySchemes: { - oauth: { flows: {}, type: "oauth2" }, - oidc: { openIdConnectUrl: "https://x", type: "openIdConnect" }, - }, - }, - info, - openapi: "3.1.0", - paths: {}, - security: [{ oauth: ["read"], oidc: ["a"], unknownScheme: ["s"] }], - }) - ) - ).toEqual({ + 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: { - oauth: { flows: {}, type: "oauth2" }, - oidc: { openIdConnectUrl: "https://x", type: "openIdConnect" }, - }, + securitySchemes: { api: apiKey, mtls: { type: "mutualTLS" } }, }, - info, - openapi: "3.0.4", - paths: {}, - security: [{ oauth: ["read"], oidc: ["a"], unknownScheme: ["s"] }], + security: [{ mtls: [] }, { api: [], mtls: [] }, {}], }); + expect(result.components).toEqual({ securitySchemes: { api: apiKey } }); + expect(result.security).toEqual([{ api: [] }, {}]); }); - it("converts operation-level security lists", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ - components: { - securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - mtls: { type: "mutualTLS" }, - }, - }, - info, - openapi: "3.1.0", - paths: { - "/a": { - get: { - responses: {}, - security: [{ mtls: [] }, { api: ["read"] }], - }, - }, - }, - }) - ) - ).toEqual({ + it("removes reference aliases of mutualTLS schemes and their requirements", () => { + const result = convertSpec({ components: { securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - }, - }, - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { responses: {}, security: [{ api: [] }] }, + api: apiKey, + clientCert: { $ref: "#/components/securitySchemes/mtlsBase" }, + mtlsBase: { type: "mutualTLS" }, }, }, + security: [{ clientCert: [] }, { api: [] }], }); - }); - - it("clones non-record security requirements unchanged", () => { + 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( - downgradeSpecV31ToV30( - asSpec({ - info, - openapi: "3.1.0", - paths: {}, - security: [{ api: [] }, "junk", 42], - }) - ) + convertSpec({ components: { securitySchemes } }).components ).toEqual({ - info, - openapi: "3.0.4", - paths: {}, - security: [{ api: [] }, "junk", 42], + securitySchemes, }); }); - it("clones a non-array security value unchanged", () => { + it("empties roles on non-OAuth schemes and keeps them elsewhere", () => { expect( - downgradeSpecV31ToV30( - asSpec({ info, openapi: "3.1.0", paths: {}, security: { api: [] } }) - ) - ).toEqual({ - info, - openapi: "3.0.4", - paths: {}, - security: { api: [] }, - }); + 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("clones a malformed securitySchemes value unchanged", () => { + 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( - downgradeSpecV31ToV30( - asSpec({ - components: { securitySchemes: "junk" }, - info, - openapi: "3.1.0", - paths: {}, - }) - ) + convertSpec({ components: { securitySchemes: "junk" } }).components ).toEqual({ - components: { securitySchemes: "junk" }, - info, - openapi: "3.0.4", - paths: {}, + securitySchemes: "junk", }); }); }); - describe("input non-mutation", () => { + describe("robustness", () => { it("never mutates the input document", () => { const input = asSpec({ components: { @@ -794,367 +630,366 @@ describe("downgradeSpecV31ToV30", () => { 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 true schema to an empty object", () => { + it("converts the boolean schemas", () => { expect(downgradeSchemaV31ToV30(true)).toEqual({}); - }); - - it("converts the false schema to a match-nothing object", () => { expect(downgradeSchemaV31ToV30(false)).toEqual({ not: {} }); }); - it("clones junk primitive inputs unchanged", () => { - expect(downgradeSchemaV31ToV30(asSchema(null))).toBeNull(); - expect(downgradeSchemaV31ToV30(asSchema(42))).toBe(42); - expect(downgradeSchemaV31ToV30(asSchema("x"))).toBe("x"); - }); - - it("clones array inputs into a new array", () => { - const input = [{ type: "string" }]; - const result = downgradeSchemaV31ToV30(asSchema(input)); - expect(result).toEqual(input); - expect(result).not.toBe(input); + 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 handling", () => { - it("keeps a pure $ref as a bare reference object", () => { + 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({ $ref: "#/components/schemas/Pet" }); + expect(result).toEqual(input); expect(result).not.toBe(input); - }); - - it("leaves a schema $ref pointing at components.pathItems untouched", () => { - expect( - downgradeSchemaV31ToV30({ $ref: "#/components/pathItems/Foo" }) - ).toEqual({ $ref: "#/components/pathItems/Foo" }); - }); - - it("wraps a $ref with sibling keywords into allOf", () => { - expect(downgradeSchemaV31ToV30({ $ref: "#/c/s", minLength: 1 })).toEqual({ - allOf: [{ $ref: "#/c/s" }], - minLength: 1, + expect(convertSchema({ $ref: "#/components/pathItems/Foo" })).toEqual({ + $ref: "#/components/pathItems/Foo", }); }); - it("merges a $ref into an existing allOf", () => { - expect( - downgradeSchemaV31ToV30({ $ref: "#/c/s", allOf: [{ type: "string" }] }) - ).toEqual({ allOf: [{ $ref: "#/c/s" }, { type: "string" }] }); + 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("keeps a single string type", () => { - expect(downgradeSchemaV31ToV30({ type: "string" })).toEqual({ - type: "string", - }); - }); - - it("converts a type array with null into type plus nullable", () => { - expect(downgradeSchemaV31ToV30({ type: ["string", "null"] })).toEqual({ - nullable: true, - type: "string", - }); - }); - - it("converts a null-only type into nullable plus a null enum", () => { - expect(downgradeSchemaV31ToV30({ type: ["null"] })).toEqual({ - enum: [null], - nullable: true, - }); - expect(downgradeSchemaV31ToV30({ type: "null" })).toEqual({ - enum: [null], - nullable: true, - }); - }); - - it("intersects an existing enum with a null-only type", () => { - expect( - downgradeSchemaV31ToV30({ enum: ["a", null], type: ["null"] }) - ).toEqual({ enum: [null], nullable: true }); - }); - - it("makes the schema match nothing when the enum of a null-only type excludes null", () => { - expect(downgradeSchemaV31ToV30({ enum: ["a"], type: ["null"] })).toEqual({ - enum: ["a"], - not: {}, - nullable: true, - }); - }); - - it("clones a malformed enum of a null-only type through", () => { - expect( - downgradeSchemaV31ToV30(asSchema({ enum: "junk", type: ["null"] })) - ).toEqual({ enum: "junk", nullable: true }); - }); - - it("keeps a null const as the enum of a null-only type", () => { - expect(downgradeSchemaV31ToV30({ const: null, type: ["null"] })).toEqual({ - enum: [null], - nullable: true, - }); - }); - - it("makes the schema match nothing when a non-null const contradicts a null-only type", () => { - expect(downgradeSchemaV31ToV30({ const: 7, type: ["null"] })).toEqual({ - enum: [7], - not: {}, - nullable: true, - }); - }); - - it("converts multiple non-null types into anyOf variants", () => { - expect(downgradeSchemaV31ToV30({ type: ["string", "integer"] })).toEqual({ - anyOf: [{ type: "string" }, { type: "integer" }], - }); - }); - - it("converts multiple types with null into nullable anyOf variants", () => { - expect( - downgradeSchemaV31ToV30({ type: ["string", "integer", "null"] }) - ).toEqual({ - anyOf: [ - { nullable: true, type: "string" }, - { nullable: true, type: "integer" }, - ], - }); - }); - - it("wraps the type union into allOf when anyOf already exists", () => { - expect( - downgradeSchemaV31ToV30({ + 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 }], - type: ["string", "integer"], - }) - ).toEqual({ - allOf: [{ anyOf: [{ type: "string" }, { type: "integer" }] }], - anyOf: [{ minLength: 1 }], - }); - }); - - it("appends the type union to an existing allOf when anyOf also exists", () => { - expect( - downgradeSchemaV31ToV30({ + }, + ], + [ + "appends the type union to an existing allOf when anyOf also exists", + { allOf: [{ title: "t" }], anyOf: [{ minLength: 1 }], type: ["string", "integer"], - }) - ).toEqual({ - allOf: [ - { title: "t" }, - { anyOf: [{ type: "string" }, { type: "integer" }] }, - ], - anyOf: [{ minLength: 1 }], - }); - }); - - it("passes junk type values through unchanged", () => { - expect(downgradeSchemaV31ToV30(asSchema({ type: 42 }))).toEqual({ - type: 42, - }); - expect(downgradeSchemaV31ToV30(asSchema({ type: { a: 1 } }))).toEqual({ - type: { a: 1 }, - }); - }); - - it("deduplicates and filters non-string entries in type arrays", () => { - expect( - downgradeSchemaV31ToV30(asSchema({ type: ["string", "string"] })) - ).toEqual({ type: "string" }); - expect( - downgradeSchemaV31ToV30(asSchema({ type: ["string", 42] })) - ).toEqual({ type: "string" }); - expect(downgradeSchemaV31ToV30(asSchema({ type: [42] }))).toEqual({ - type: [42], - }); - }); - - it("drops an empty type array", () => { - expect(downgradeSchemaV31ToV30({ type: [] })).toEqual({}); + }, + { + 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("converts const into a single-value enum", () => { - expect(downgradeSchemaV31ToV30({ const: "a" })).toEqual({ enum: ["a"] }); - }); - - it("converts falsy const values", () => { - expect(downgradeSchemaV31ToV30({ const: 0 })).toEqual({ enum: [0] }); - expect(downgradeSchemaV31ToV30({ const: false })).toEqual({ - enum: [false], - }); - expect(downgradeSchemaV31ToV30({ const: "" })).toEqual({ enum: [""] }); - }); - - it("converts a null const and marks the schema nullable", () => { - expect(downgradeSchemaV31ToV30({ const: null })).toEqual({ - enum: [null], - nullable: true, - }); - }); - - it("replaces an existing enum with the const value", () => { - expect(downgradeSchemaV31ToV30({ const: 5, enum: [1, 2] })).toEqual({ - enum: [5], - }); + 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("converts a numeric exclusiveMinimum into minimum plus flag", () => { - expect(downgradeSchemaV31ToV30({ exclusiveMinimum: 3 })).toEqual({ - exclusiveMinimum: true, - minimum: 3, - }); - }); - - it("keeps a tighter inclusive minimum and drops the exclusive one", () => { - expect( - downgradeSchemaV31ToV30({ exclusiveMinimum: 3, minimum: 5 }) - ).toEqual({ minimum: 5 }); - }); - - it("overrides a looser inclusive minimum with the exclusive bound", () => { - expect( - downgradeSchemaV31ToV30({ exclusiveMinimum: 5, minimum: 3 }) - ).toEqual({ exclusiveMinimum: true, minimum: 5 }); - }); - - it("prefers the exclusive form for equal minimum bounds", () => { - expect( - downgradeSchemaV31ToV30({ exclusiveMinimum: 3, minimum: 3 }) - ).toEqual({ exclusiveMinimum: true, minimum: 3 }); - }); - - it("converts a numeric exclusiveMaximum into maximum plus flag", () => { - expect(downgradeSchemaV31ToV30({ exclusiveMaximum: 10 })).toEqual({ - exclusiveMaximum: true, - maximum: 10, - }); - }); - - it("keeps a tighter inclusive maximum and drops the exclusive one", () => { - expect( - downgradeSchemaV31ToV30({ exclusiveMaximum: 10, maximum: 5 }) - ).toEqual({ maximum: 5 }); - }); - - it("overrides a looser inclusive maximum with the exclusive bound", () => { - expect( - downgradeSchemaV31ToV30({ exclusiveMaximum: 5, maximum: 10 }) - ).toEqual({ exclusiveMaximum: true, maximum: 5 }); - }); - - it("prefers the exclusive form for equal maximum bounds", () => { - expect( - downgradeSchemaV31ToV30({ exclusiveMaximum: 5, maximum: 5 }) - ).toEqual({ exclusiveMaximum: true, maximum: 5 }); - }); - - it("passes 3.0-style boolean exclusive bounds through unchanged", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ exclusiveMinimum: true, minimum: 3 }) - ) - ).toEqual({ exclusiveMinimum: true, minimum: 3 }); - expect( - downgradeSchemaV31ToV30( - asSchema({ exclusiveMaximum: false, maximum: 3 }) - ) - ).toEqual({ exclusiveMaximum: false, maximum: 3 }); + 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("promotes the first examples entry to example", () => { - expect(downgradeSchemaV31ToV30({ examples: ["a", "b"] })).toEqual({ - example: "a", - }); - }); - - it("keeps an explicit example over the examples entries", () => { - expect( - downgradeSchemaV31ToV30({ example: "e", examples: ["a"] }) - ).toEqual({ example: "e" }); - }); - - it("drops empty examples arrays", () => { - expect(downgradeSchemaV31ToV30({ examples: [] })).toEqual({}); - }); - - it("drops non-array examples values", () => { - expect(downgradeSchemaV31ToV30(asSchema({ examples: "junk" }))).toEqual( - {} - ); + 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("converts contentEncoding base64 into format byte", () => { - expect(downgradeSchemaV31ToV30({ contentEncoding: "base64" })).toEqual({ - format: "byte", - }); - }); - - it("keeps an existing format over contentEncoding", () => { - expect( - downgradeSchemaV31ToV30({ - contentEncoding: "base64", - format: "custom", - }) - ).toEqual({ format: "custom" }); - }); - - it("drops other content encodings", () => { - expect(downgradeSchemaV31ToV30({ contentEncoding: "gzip" })).toEqual({}); - }); - - it("converts contentMediaType application/octet-stream into format binary", () => { - expect( - downgradeSchemaV31ToV30({ - contentMediaType: "application/octet-stream", - }) - ).toEqual({ format: "binary" }); - }); - - it("does not emit format binary when a contentEncoding is present", () => { - expect( - downgradeSchemaV31ToV30({ + 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", - }) - ).toEqual({}); - }); - - it("drops other content media types", () => { - expect( - downgradeSchemaV31ToV30({ contentMediaType: "image/png" }) - ).toEqual({}); - }); - - it("drops contentSchema", () => { - expect( - downgradeSchemaV31ToV30({ contentSchema: { type: "string" } }) - ).toEqual({}); + }, + {}, + ], + [ + "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( - downgradeSchemaV31ToV30({ + convertSchema({ $anchor: "a", $comment: "c", $defs: { D: { type: "string" } }, @@ -1182,127 +1017,189 @@ describe("downgradeSchemaV31ToV30", () => { }) ).toEqual({ type: "string" }); }); - }); - - describe("items and prefixItems", () => { - it("drops prefixItems together with its trailing items", () => { - expect( - downgradeSchemaV31ToV30({ - items: { type: "integer" }, - prefixItems: [{ type: "string" }], - }) - ).toEqual({}); - }); - it("keeps and converts items when there are no prefixItems", () => { - expect( - downgradeSchemaV31ToV30({ items: { type: ["string", "null"] } }) - ).toEqual({ items: { nullable: true, type: "string" } }); - }); - - it("converts boolean items", () => { - expect(downgradeSchemaV31ToV30({ items: true })).toEqual({ items: {} }); - expect(downgradeSchemaV31ToV30({ items: false })).toEqual({ - items: { not: {} }, - }); - }); - - it("adds empty items when type array has none", () => { - expect(downgradeSchemaV31ToV30({ type: "array" })).toEqual({ - items: {}, - type: "array", - }); - expect(downgradeSchemaV31ToV30({ type: ["array", "null"] })).toEqual({ - items: {}, - nullable: true, - type: "array", - }); + 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("required", () => { - it("drops an empty required array", () => { - expect(downgradeSchemaV31ToV30({ required: [] })).toEqual({}); - }); - - it("keeps a non-empty required array", () => { - expect(downgradeSchemaV31ToV30({ required: ["a"] })).toEqual({ - required: ["a"], - }); - }); - - it("clones a non-array required value unchanged", () => { - expect(downgradeSchemaV31ToV30(asSchema({ required: "junk" }))).toEqual({ - required: "junk", - }); + 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("recursion", () => { - it("converts nested property schemas", () => { - expect( - downgradeSchemaV31ToV30({ + describe("subschemas", () => { + it.each([ + [ + "converts nested property schemas", + { properties: { a: { type: ["string", "null"] }, b: true }, type: "object", - }) - ).toEqual({ - properties: { a: { nullable: true, type: "string" }, b: {} }, - type: "object", - }); - }); - - it("keeps boolean additionalProperties and converts schema ones", () => { - expect(downgradeSchemaV31ToV30({ additionalProperties: true })).toEqual({ - additionalProperties: true, - }); - expect(downgradeSchemaV31ToV30({ additionalProperties: false })).toEqual({ - additionalProperties: false, - }); - expect( - downgradeSchemaV31ToV30({ - additionalProperties: { type: ["string", "null"] }, - }) - ).toEqual({ - additionalProperties: { nullable: true, type: "string" }, - }); - }); - - it("converts allOf, anyOf, oneOf, and not members", () => { - expect( - downgradeSchemaV31ToV30({ + }, + { + 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"] }], - }) - ).toEqual({ - allOf: [{}], - anyOf: [{ enum: [1] }], - not: { not: {} }, - oneOf: [{ nullable: true, type: "integer" }], - }); + }, + { + 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); }); + }); - it("clones a non-array allOf value unchanged", () => { - expect(downgradeSchemaV31ToV30(asSchema({ allOf: "junk" }))).toEqual({ - allOf: "junk", - }); + 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", () => { + describe("extensions and unknown keywords", () => { it("preserves x- keys and unknown keywords", () => { - expect( - downgradeSchemaV31ToV30({ - customKeyword: "v", - title: "t", - "x-foo": { a: 1 }, - }) - ).toEqual({ customKeyword: "v", title: "t", "x-foo": { a: 1 } }); + 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("input non-mutation", () => { + describe("robustness", () => { it("never mutates the input schema", () => { const input = asSchema({ $ref: "#/c/s", @@ -1316,456 +1213,26 @@ describe("downgradeSchemaV31ToV30", () => { type: ["object", "null"], }); const before = structuredClone(input); - downgradeSchemaV31ToV30(input); - expect(input).toEqual(before); }); - }); -}); - -describe("graceful handling of malformed nested objects", () => { - it("clones malformed path items, parameters, request bodies, responses, media types, and encodings", () => { - const spec = asSpec({ - info, - openapi: "3.1.0", - paths: { - "/a": { - get: { requestBody: 42, responses: { "200": "junk" } }, - parameters: [42], - }, - "/b": { - post: { - requestBody: { - content: { - "application/json": "junk", - "multipart/form-data": { encoding: { field: "junk" } }, - }, - }, - responses: {}, - }, - }, - "/junk": "junk", - }, - }); - expect(downgradeSpecV31ToV30(spec)).toEqual({ - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { requestBody: 42, responses: { "200": "junk" } }, - parameters: [42], - }, - "/b": { - post: { - requestBody: { - content: { - "application/json": "junk", - "multipart/form-data": { encoding: { field: "junk" } }, - }, - }, - responses: {}, - }, - }, - "/junk": "junk", - }, - }); - }); - - it("converts inline callback objects, cloning x- keys and junk entries", () => { - const spec = asSpec({ - components: { - callbacks: { - junkCallback: 42, - realCallback: { - "x-note": { "{$expr}": { get: {} } }, - "{$request.body#/url}": { post: { summary: "s" } }, - }, - }, - "x-extra": { keep: true }, - }, - info, - openapi: "3.1.0", - paths: { - "/a": { - get: { - callbacks: { - inline: { - expr: { get: {} }, - "x-k": { expr: { get: {} } }, - }, - junk: 7, - }, - responses: {}, - }, - }, - }, - }); - expect(downgradeSpecV31ToV30(spec)).toEqual({ - components: { - callbacks: { - junkCallback: 42, - realCallback: { - "x-note": { "{$expr}": { get: {} } }, - "{$request.body#/url}": { - post: { - responses: { default: { description: "" } }, - summary: "s", - }, - }, - }, - }, - "x-extra": { keep: true }, - }, - info, - openapi: "3.0.4", - paths: { - "/a": { - get: { - callbacks: { - inline: { - expr: { get: { responses: { default: { description: "" } } } }, - "x-k": { expr: { get: {} } }, - }, - junk: 7, - }, - responses: {}, - }, - }, - }, - }); - }); - - it("clones a malformed components value unchanged", () => { - expect( - downgradeSpecV31ToV30( - asSpec({ components: "junk", info, openapi: "3.1.0", paths: {} }) - ) - ).toEqual({ components: "junk", info, openapi: "3.0.4", paths: {} }); - }); -}); - -describe("xml nodeType from chained 3.2 documents", () => { - it("converts nodeType attribute to attribute: true", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ type: "string", xml: { name: "n", nodeType: "attribute" } }) - ) - ).toEqual({ type: "string", xml: { attribute: true, name: "n" } }); - }); - - it("converts nodeType element on an array schema to wrapped: true", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ items: {}, type: "array", xml: { nodeType: "element" } }) - ) - ).toEqual({ items: {}, type: "array", xml: { wrapped: true } }); - }); - - it("converts nodeType element on a nullable array schema to wrapped: true", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ type: ["array", "null"], xml: { nodeType: "element" } }) - ) - ).toEqual({ - items: {}, - nullable: true, - type: "array", - xml: { wrapped: true }, - }); - }); - - it("removes nodeType element on non-array schemas", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ type: "string", xml: { nodeType: "element" } }) - ) - ).toEqual({ type: "string", xml: {} }); - }); - - it("removes inexpressible nodeType values", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ type: "string", xml: { name: "n", nodeType: "text" } }) - ) - ).toEqual({ type: "string", xml: { name: "n" } }); - }); - - it("clones xml objects without nodeType and malformed xml values unchanged", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ type: "string", xml: { attribute: true, name: "n" } }) - ) - ).toEqual({ type: "string", xml: { attribute: true, name: "n" } }); - expect( - downgradeSchemaV31ToV30(asSchema({ type: "string", xml: "junk" })) - ).toEqual({ type: "string", xml: "junk" }); - }); -}); - -describe("malformed schema values pass through the conversion triggers", () => { - it("passes a non-string $ref through unchanged", () => { - expect(downgradeSchemaV31ToV30(asSchema({ $ref: 123 }))).toEqual({ - $ref: 123, - }); - expect( - downgradeSchemaV31ToV30(asSchema({ $ref: 123, type: "string" })) - ).toEqual({ $ref: 123, type: "string" }); - }); - - it("keeps a malformed allOf when a string $ref would otherwise be wrapped", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ $ref: "#/components/schemas/A", allOf: "junk" }) - ) - ).toEqual({ $ref: "#/components/schemas/A", allOf: "junk" }); - }); - - it("keeps a malformed allOf when a type union would otherwise merge into it", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ - allOf: "junk", - anyOf: [{ type: "string" }], - type: ["integer", "string"], - }) - ) - ).toEqual({ - allOf: "junk", - anyOf: [{ type: "string" }], - }); - }); -}); - -describe("type unions containing array", () => { - it("gives synthesized array variants an empty items", () => { - expect( - downgradeSchemaV31ToV30(asSchema({ type: ["array", "string"] })) - ).toEqual({ - anyOf: [{ items: {}, type: "array" }, { type: "string" }], - }); - }); - - it("copies existing items into the synthesized array variant", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ - items: { type: "integer" }, - type: ["array", "string", "null"], - }) - ) - ).toEqual({ - anyOf: [ - { items: { type: "integer" }, nullable: true, type: "array" }, - { nullable: true, type: "string" }, - ], - items: { type: "integer" }, - }); - }); -}); - -describe("patternProperties sibling handling", () => { - it("drops additionalProperties together with patternProperties", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ - additionalProperties: false, - patternProperties: { "^x-": {} }, - properties: { name: { type: "string" } }, - type: "object", - }) - ) - ).toEqual({ - properties: { name: { type: "string" } }, - type: "object", - }); - }); - - it("drops schema-valued additionalProperties together with patternProperties", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ - additionalProperties: { type: "integer" }, - patternProperties: { "^x-": {} }, - type: "object", - }) - ) - ).toEqual({ type: "object" }); - }); -}); - -describe("3.0 enum and required constraints", () => { - it("removes an empty enum", () => { - expect( - downgradeSchemaV31ToV30(asSchema({ enum: [], type: "string" })) - ).toEqual({ type: "string" }); - }); - - it("deduplicates required entries", () => { - expect( - downgradeSchemaV31ToV30( - asSchema({ required: ["a", "b", "a"], type: "object" }) - ) - ).toEqual({ required: ["a", "b"], type: "object" }); - }); -}); -describe("mutualTLS reference aliases", () => { - it("removes reference aliases of mutualTLS schemes and their requirements", () => { - const result = downgradeSpecV31ToV30( - asSpec({ - components: { - securitySchemes: { - api: { in: "header", name: "k", type: "apiKey" }, - clientCert: { $ref: "#/components/securitySchemes/mtlsBase" }, - mtlsBase: { type: "mutualTLS" }, - }, - }, - info, - openapi: "3.1.0", - paths: {}, - security: [{ clientCert: [] }, { api: [] }], - }) - ); - expect(result).toEqual({ - components: { - securitySchemes: { api: { in: "header", name: "k", type: "apiKey" } }, - }, - info, - openapi: "3.0.4", - paths: {}, - security: [{ api: [] }], + 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("survives cyclic and dangling security scheme aliases", () => { - const result = downgradeSpecV31ToV30( - asSpec({ - components: { - 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" }, - }, - }, - info, - openapi: "3.1.0", - paths: {}, - }) - ); - expect(result.components?.securitySchemes).toEqual({ - 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" }, + 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" + ); }); }); }); - -describe("security lists emptied by mutualTLS removal", () => { - it("omits an operation security list that removal emptied", () => { - const result = downgradeSpecV31ToV30( - asSpec({ - components: { securitySchemes: { mtls: { type: "mutualTLS" } } }, - info, - openapi: "3.1.0", - paths: { - "/admin": { - get: { responses: {}, security: [{ mtls: [] }] }, - }, - }, - security: [{ mtls: [] }], - }) - ); - const operation = result.paths?.["/admin"]?.get; - expect(operation).toEqual({ responses: {} }); - expect(operation).not.toHaveProperty("security"); - expect(result).not.toHaveProperty("security"); - }); - - it("keeps an explicitly empty security list", () => { - const result = downgradeSpecV31ToV30( - asSpec({ - info, - openapi: "3.1.0", - paths: { "/a": { get: { responses: {}, security: [] } } }, - security: [], - }) - ); - expect(result.paths?.["/a"]?.get?.security).toEqual([]); - expect(result.security).toEqual([]); - }); -}); - -describe("deep documents", () => { - 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(); - }); -}); - -describe("cyclic input", () => { - it("converts a schema whose subtree cycles back to itself without throwing", () => { - const properties: UnknownRecord = {}; - const node: UnknownRecord = { properties, type: "object" }; - properties.self = node; - const result = downgradeSchemaV31ToV30(asSchema(node)); - expect(result).toHaveProperty(["properties", "self", "type"], "object"); - }); - - 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(() => - downgradeSpecV31ToV30( - asSpec({ info, openapi: "3.1.0", paths: { "/a": pathItem } }) - ) - ).not.toThrow(); - }); -}); - -describe("path parameter required synthesis", () => { - it("adds required: true to path parameters that lack it", () => { - const result = downgradeSpecV31ToV30( - asSpec({ - info, - openapi: "3.1.0", - paths: { - "/a/{id}": { - get: { - parameters: [ - { - content: { "text/plain": { schema: { type: "string" } } }, - in: "path", - name: "id", - }, - { in: "query", name: "q", schema: {} }, - ], - responses: {}, - }, - }, - }, - }) - ); - expect(result.paths?.["/a/{id}"]?.get?.parameters).toEqual([ - { - content: { "text/plain": { schema: { type: "string" } } }, - in: "path", - name: "id", - required: true, - }, - { in: "query", name: "q", schema: {} }, - ]); - }); -}); diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2f9cc76..c718edd 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -8,9 +8,9 @@ * 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: + * 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`. @@ -34,92 +34,43 @@ import type { OpenAPIV3_0, OpenAPIV3_1 } from "@oasty/types"; -import type { UnknownRecord } from "./shared"; +import type { FieldConverter, FieldTable, UnknownRecord } from "./shared"; import { + convertRecord, deepClone, + DROP, getRef, isRecord, mapArray, mapRecord, - omitKeys, + operationFields, setKey, - withCycleGuard, } from "./shared"; -const HTTP_METHODS = new Set([ - "delete", - "get", - "head", - "options", - "patch", - "post", - "put", - "trace", -]); - /** - * JSON Schema keywords with no OpenAPI 3.0 equivalent. In positive schema - * positions, dropping them 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. - */ -const DROPPED_SCHEMA_KEYWORDS = new Set([ - "$anchor", - "$comment", - "$defs", - "$dynamicAnchor", - "$dynamicRef", - "$id", - "$schema", - "$vocabulary", - "contains", - "contentSchema", - "dependentRequired", - "dependentSchemas", - "else", - "if", - "maxContains", - "minContains", - "patternProperties", - "prefixItems", - "propertyNames", - "then", - "unevaluatedItems", - "unevaluatedProperties", -]); - -/** Keywords converted after the main loop, from their raw values. */ -const POSTPROCESSED_SCHEMA_KEYWORDS = new Set([ - "const", - "contentEncoding", - "contentMediaType", - "examples", - "type", -]); - -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; -} - -/** - * Strips a 3.1 Reference Object down to its bare `$ref` (3.0 references - * allow no `summary`/`description` overrides), or converts the value. + * 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); - if (ref !== undefined) { - return { $ref: ref }; - } - return convert(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, @@ -226,9 +177,6 @@ const convertConst = (schema: UnknownRecord, out: UnknownRecord): void => { }; const convertExamples = (schema: UnknownRecord, out: UnknownRecord): void => { - if (!("examples" in schema)) { - return; - } // 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 ( @@ -291,24 +239,47 @@ const convertContentKeywords = ( * 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 => { - if (!isRecord(value) || !("nodeType" in value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (key !== "nodeType") { - setKey(out, key, deepClone(item)); +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 = {}; } - if (value.nodeType === "attribute") { - out.attribute = true; - } else if ( - value.nodeType === "element" && - (schemaType === "array" || - (Array.isArray(schemaType) && schemaType.includes("array"))) - ) { - out.wrapped = true; + 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; }; @@ -320,142 +291,99 @@ const convertSchema = (schema: unknown): unknown => { if (schema === false) { return { not: {} }; } - if (!isRecord(schema)) { - return deepClone(schema); - } - const ref = schema.$ref; - if (typeof ref === "string" && Object.keys(schema).length === 1) { - return { $ref: ref }; - } - return withCycleGuard(schema, () => { - // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertSchemaFields, as schemas are recursive structures - const out = convertSchemaFields(schema); - 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; - }); -}; - -/** Converts the keywords holding nested schemas; returns whether `key` was one. */ -const convertSubschemaKeyword = ( - out: UnknownRecord, - key: string, - value: unknown, - schema: UnknownRecord -): boolean => { - switch (key) { - case "allOf": - case "anyOf": - case "oneOf": { - setKey(out, key, mapArray(value, convertSchema)); - return true; - } - case "items": { - // With `prefixItems` dropped, a trailing `items` would wrongly - // constrain every item, so it is dropped alongside. - if (!("prefixItems" in schema)) { - setKey(out, key, convertSchema(value)); - } - return true; - } - case "not": { - setKey(out, key, convertSchema(value)); - return true; - } - case "properties": { - setKey(out, key, mapRecord(value, convertSchema)); - return true; - } - case "additionalProperties": { - // With `patternProperties` dropped, `additionalProperties` would also - // constrain the previously pattern-matched keys, so it is dropped - // alongside (removing a constraint is the safe direction). - if (!("patternProperties" in schema)) { - setKey( - out, - key, - typeof value === "boolean" ? value : convertSchema(value) - ); - } - return true; - } - default: { - return false; - } + 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 convertSchemaFields = (schema: UnknownRecord): UnknownRecord => { - const out: UnknownRecord = {}; +const convertSubschemas: FieldConverter = (item) => + mapArray(item, convertSchema); - for (const [key, value] of Object.entries(schema)) { - if (key === "$ref") { - // A string $ref is re-attached by convertSchema; malformed values pass - // through unchanged. - if (typeof value !== "string") { - setKey(out, key, deepClone(value)); - } - continue; - } - if ( - DROPPED_SCHEMA_KEYWORDS.has(key) || - POSTPROCESSED_SCHEMA_KEYWORDS.has(key) - ) { - continue; - } - if (convertSubschemaKeyword(out, key, value, schema)) { - continue; - } - if ( - (key === "exclusiveMaximum" || key === "exclusiveMinimum") && - typeof value === "number" - ) { - // Converted after the loop; 3.0-style booleans (invalid in 3.1, but - // accepted gracefully) pass through the default clone below. - continue; - } - if (key === "required" && Array.isArray(value)) { - // 3.0 requires the array to be non-empty with unique entries. - const unique = [...new Set(value)]; - if (unique.length > 0) { - setKey(out, key, deepClone(unique)); - } - continue; - } - if (key === "enum" && Array.isArray(value) && value.length === 0) { - // 3.0 requires at least one enum entry; an empty enum only constrains, - // so removing it is the safe direction. - continue; - } - if (key === "xml") { - setKey(out, key, convertXml(value, schema.type)); - continue; - } - setKey(out, key, deepClone(value)); - } - - 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 = {}; - } - return out; +/** + * 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), }; /** @@ -474,6 +402,13 @@ export const downgradeSchemaV31ToV30 = ( 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. @@ -504,323 +439,152 @@ const resolveSchemeType = ( return undefined; }; -const indexSecuritySchemes = (spec: UnknownRecord): SecuritySchemeIndex => { - const types = new Map(); - const mutualTls = new Set(); - const schemes = isRecord(spec.components) - ? spec.components.securitySchemes - : undefined; - if (isRecord(schemes)) { - for (const name of Object.keys(schemes)) { - const type = resolveSchemeType(name, schemes, new Set()); - if (type !== undefined) { - types.set(name, type); - if (type === "mutualTLS") { - // Reference aliases of mutualTLS schemes are removed as well, so - // no dangling references survive. - mutualTls.add(name); - } +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 { mutualTls, types }; + return index; }; -const convertSecurityList = ( +const convertRequirement = ( value: unknown, index: SecuritySchemeIndex ): unknown => { - if (!Array.isArray(value)) { + if (!isRecord(value)) { return deepClone(value); } - const out: unknown[] = []; - for (const requirement of value) { - if (!isRecord(requirement)) { - out.push(deepClone(requirement)); - continue; - } - const converted: UnknownRecord = {}; - for (const [name, scopes] of Object.entries(requirement)) { - if (index.mutualTls.has(name)) { - continue; - } - const type = index.types.get(name); - const supportsScopes = - type === undefined || type === "oauth2" || type === "openIdConnect"; - if (!supportsScopes && Array.isArray(scopes) && scopes.length > 0) { - // 3.0 allows roles only on OAuth-family schemes. - setKey(converted, name, []); - continue; - } - setKey(converted, name, deepClone(scopes)); - } + 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. - if ( - Object.keys(requirement).length > 0 && - Object.keys(converted).length === 0 - ) { - continue; - } - out.push(converted); + return DROP; } - return out; + 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 and attaches a `security` list. When mutualTLS removal empties a - * previously non-empty list, the key is omitted entirely: an explicit empty - * `security` array means "no security required" and, on an operation, would - * override the root declaration and silently make the operation public. + * 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 setSecurity = ( - out: UnknownRecord, +const convertSecurity = ( value: unknown, index: SecuritySchemeIndex -): void => { - const converted = convertSecurityList(value, index); - if ( - Array.isArray(value) && - value.length > 0 && - Array.isArray(converted) && - converted.length === 0 - ) { - return; - } - setKey(out, "security", converted); -}; - -/** The SPDX `identifier` has no 3.0 equivalent. */ -const DROPPED_LICENSE_KEYS = new Set(["identifier"]); - -const convertInfo = (value: unknown): unknown => { - if (!isRecord(value)) { +): unknown => { + if (!Array.isArray(value)) { return deepClone(value); } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "summary": { - // No `summary` in 3.0. - continue; - } - case "license": { - setKey(out, "license", omitKeys(item, DROPPED_LICENSE_KEYS)); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; + 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 => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "schema": { - setKey(out, key, convertSchema(item)); - continue; - } - case "content": { - // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertMediaType via encoding headers - setKey(out, key, mapRecord(item, convertMediaType)); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, deepClone)) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } +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; } - } - if (value.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 => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (key === "headers") { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, convertParameterOrHeader) - ) - ); - } else { - setKey(out, key, deepClone(item)); - } - } - return out; -}; +const convertEncoding = (value: unknown): unknown => + convertRecord(value, { headers: refMap(convertParameterOrHeader) }); -const convertMediaType = (value: unknown): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - return withCycleGuard(value, () => { - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "schema": { - setKey(out, key, convertSchema(item)); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, deepClone)) - ); - continue; - } - case "encoding": { - setKey(out, key, mapRecord(item, convertEncoding)); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; +const convertMediaType = (value: unknown): unknown => + convertRecord(value, { + encoding: (item) => mapRecord(item, convertEncoding), + examples: refMap(deepClone), + schema: convertSchema, }); -}; -const convertRequestBody = (value: unknown): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (key === "content") { - setKey(out, key, mapRecord(item, convertMediaType)); - } else { - setKey(out, key, deepClone(item)); - } - } - return out; -}; +const convertContent: FieldConverter = (item) => + mapRecord(item, convertMediaType); -const convertResponse = (value: unknown): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "headers": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, convertParameterOrHeader) - ) - ); - continue; - } - case "content": { - setKey(out, key, mapRecord(item, convertMediaType)); - continue; - } - case "links": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, deepClone)) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; -}; +const convertRequestBody = (value: unknown): unknown => + convertRecord(value, { content: convertContent }); -const convertResponses = (value: unknown): unknown => - mapRecord(value, (item, key) => - key.startsWith("x-") ? deepClone(item) : convertRefOr(item, convertResponse) +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 => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "parameters": { - setKey( - out, - key, - mapArray(item, (entry) => - convertRefOr(entry, convertParameterOrHeader) - ) - ); - continue; - } - case "requestBody": { - setKey(out, key, convertRefOr(item, convertRequestBody)); - continue; - } - case "responses": { - setKey(out, key, convertResponses(item)); - continue; - } - case "callbacks": { - // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertCallback, as callbacks contain path items - const convert = convertCallback; - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, (inner) => convert(inner, index)) - ) - ); - continue; - } - case "security": { - setSecurity(out, item, index); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } +): 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; } - } - 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. - setKey(out, "responses", { default: { description: "" } }); - } - return out; -}; + ); const convertCallback = (value: unknown, index: SecuritySchemeIndex): unknown => mapRecord(value, (item, key) => @@ -828,39 +592,11 @@ const convertCallback = (value: unknown, index: SecuritySchemeIndex): unknown => key.startsWith("x-") ? deepClone(item) : convertPathItem(item, index) ); -const convertPathItem = ( - value: unknown, - index: SecuritySchemeIndex -): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - return withCycleGuard(value, () => { - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (HTTP_METHODS.has(key)) { - setKey(out, key, convertOperation(item, index)); - continue; - } - switch (key) { - case "parameters": { - setKey( - out, - key, - mapArray(item, (entry) => - convertRefOr(entry, convertParameterOrHeader) - ) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; +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) => @@ -870,138 +606,46 @@ const convertPaths = (value: unknown, index: SecuritySchemeIndex): unknown => const convertComponents = ( value: unknown, index: SecuritySchemeIndex -): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "schemas": { - setKey(out, key, mapRecord(item, convertSchema)); - continue; - } - case "responses": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, convertResponse)) - ); - continue; - } - case "parameters": - case "headers": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, convertParameterOrHeader) - ) - ); - continue; - } - case "examples": - case "links": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, deepClone)) - ); - continue; - } - case "requestBodies": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, convertRequestBody)) - ); - continue; - } - case "securitySchemes": { - const schemes: UnknownRecord = {}; - if (isRecord(item)) { - for (const [name, scheme] of Object.entries(item)) { - if (!index.mutualTls.has(name)) { - setKey(schemes, name, convertRefOr(scheme, deepClone)); - } - } - setKey(out, key, schemes); - } else { - setKey(out, key, deepClone(item)); - } - continue; - } - case "callbacks": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, (inner) => convertCallback(inner, index)) - ) - ); - continue; - } - case "pathItems": { - // 3.0 has no reusable path items. - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; -}; +): 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 => { - if (!isRecord(spec)) { - return deepClone(spec); - } const index = indexSecuritySchemes(spec); - const out: UnknownRecord = {}; - for (const [key, value] of Object.entries(spec)) { - switch (key) { - case "openapi": { - setKey(out, "openapi", "3.0.4"); - continue; - } - case "jsonSchemaDialect": { - // 3.0 has no schema-dialect selection. - continue; - } - case "info": { - setKey(out, "info", convertInfo(value)); - continue; - } - case "paths": { - setKey(out, "paths", convertPaths(value, index)); - continue; - } - case "webhooks": { - // No webhooks in 3.0. - continue; - } - case "components": { - setKey(out, "components", convertComponents(value, index)); - continue; - } - case "security": { - setSecurity(out, value, index); - continue; - } - default: { - setKey(out, key, deepClone(value)); - } + 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; } - } - if (out.openapi === undefined) { - setKey(out, "openapi", "3.0.4"); - } - if (out.paths === undefined) { - // Required in 3.0; an empty Paths Object is valid. - setKey(out, "paths", {}); - } - return out; + ); }; /** diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index d20a67c..a34745e 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -1,7 +1,6 @@ -/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-unsafe-dictionary-type -- these tests feed deliberately malformed OpenAPI documents through the converters and inspect loosely-typed output, mirroring the converters' own defensive contract */ +/* 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 { describe, expect, it } from "vitest"; import type { UnknownRecord } from "./shared"; import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from "./v3.2-to-v3.1"; @@ -18,1223 +17,1237 @@ 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 Record)[key]; + current = (current as UnknownRecord)[key]; } return current; }; -describe("document version", () => { - it("rewrites the openapi field to 3.1.2", () => { - const result = downgradeSpecV32ToV31({ - info: { title: "t", version: "1.0.0" }, - openapi: "3.2.0", - }); - expect(result).toEqual({ - info: { title: "t", version: "1.0.0" }, - openapi: "3.1.2", +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", () => { - const result = downgradeSpecV32ToV31(asSpec({})); - expect(result).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]); - }); -}); -describe("$self", () => { - it("removes $self", () => { - const result = downgradeSpecV32ToV31( - asSpec({ $self: "https://example.com/api.json", openapi: "3.2.0" }) - ); - expect(result).toEqual({ 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" }); + }); -describe("servers", () => { - it("removes server name in the root servers list", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - servers: [ - { description: "d", name: "prod", url: "https://example.com" }, - ], - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - servers: [{ description: "d", url: "https://example.com" }], + it("removes $self", () => { + expect(convertSpec({ $self: "https://example.com/api.json" })).toEqual({ + openapi: "3.1.2", + }); }); - }); - it("removes server name in path item servers", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { "/a": { servers: [{ name: "s", url: "/u" }] } }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { "/a": { servers: [{ url: "/u" }] } }, + 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("removes server name in operation servers", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", + 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: { responses: {}, servers: [{ name: "s", url: "/u" }] }, + get: { + operationId: "getA", + responses: {}, + unknownOperationKey: 1, + "x-op": true, + }, + unknownPathItemKey: "kept", + "x-item": [1, 2], }, }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { get: { responses: {}, servers: [{ url: "/u" }] } }, - }, + security: [{ oauth: ["read"] }], + "x-root": { deep: { value: 1 } }, + }; + expect(convertSpec(fields)).toEqual({ ...fields, openapi: "3.1.2" }); }); }); - it("removes server name inside a link server", () => { - const result = downgradeSpecV32ToV31( - asSpec({ + 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" } }, + links: { L: { operationId: "op", server: { name: "s", url: "/u" } } }, + }, + paths: { + "/a": { + get: { responses: {}, servers: [{ name: "s", url: "/u" }] }, + servers: [{ name: "s", url: "/u" }], }, }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - links: { L: { operationId: "op", server: { url: "/u" } } }, - }, - openapi: "3.1.2", + 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", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", + 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], - }) - ); - expect(result).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", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - tags: [ - { - description: "d", - externalDocs: { url: "https://example.com" }, - kind: "nav", - name: "pets", - parent: "animals", - summary: "Pets", - }, - ], - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - tags: [ + }); + }); + }); + + 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, + ]); }); }); - it("clones non-object tag entries through", () => { - const result = downgradeSpecV32ToV31( - asSpec({ openapi: "3.2.0", tags: ["junk", 1] }) - ); - expect(result).toEqual({ openapi: "3.1.2", tags: ["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": {}, + }); + }); -describe("path item query and additionalOperations", () => { - it("removes the query operation and keeps regular operations", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - get: { responses: {} }, - query: { description: "q", responses: {} }, + it("converts only keys starting with a slash and clones the rest", () => { + expect( + convertSpec({ + paths: { + "/a": { query: { description: "dropped" } }, + "x-meta": { query: { description: "kept" } }, }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { "/a": { get: { responses: {} } } }, + }).paths + ).toEqual({ "/a": {}, "x-meta": { query: { description: "kept" } } }); }); - }); - it("removes additionalOperations whatever its shape", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { additionalOperations: { NOTIFY: { description: "n" } } }, - "/b": { additionalOperations: "junk" }, - "/c": { additionalOperations: 42, query: "junk" }, + 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(result).toEqual({ - openapi: "3.1.2", - paths: { "/a": {}, "/b": {}, "/c": {} }, + }; + expect(convertSpec({ paths }).paths).toEqual(paths); }); }); -}); -describe("paths", () => { - it("converts only keys starting with a slash and clones the rest", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { query: { description: "dropped" } }, - "x-meta": { query: { description: "kept" } }, + 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: {}, }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": {}, - "x-meta": { query: { description: "kept" } }, - }, + parameters: [{ in: "path", name: "id", required: true }], + }); }); - }); - it("clones non-object paths and path items through", () => { - const result = downgradeSpecV32ToV31( - asSpec({ openapi: "3.2.0", paths: { "/a": "junk" } }) - ); - expect(result).toEqual({ openapi: "3.1.2", paths: { "/a": "junk" } }); - }); -}); + 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" }, + }, + }); + }); -describe("parameters", () => { - it("removes querystring parameters from operation parameter lists, keeping neighbors and references", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", + 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: [ - { - content: { "application/x-www-form-urlencoded": {} }, - in: "querystring", - name: "q", - }, - { in: "query", name: "keep" }, - { $ref: "#/components/parameters/P" }, + { $ref: "#/components/parameters/AliasOfAlias" }, + { $ref: "#/components/parameters/Qs" }, + { $ref: "#/components/parameters/Keep" }, ], responses: {}, }, + parameters: [{ $ref: "#/components/parameters/Qs" }], }, }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { + }); + expect(result.components).toEqual({ + parameters: { Keep: { in: "query", name: "k", schema: {} } }, + }); + expect(result.paths).toEqual({ "/a": { get: { - parameters: [ - { in: "query", name: "keep" }, - { $ref: "#/components/parameters/P" }, - ], + parameters: [{ $ref: "#/components/parameters/Keep" }], responses: {}, }, + parameters: [], }, - }, + }); }); - }); - it("removes querystring parameters from path item parameter lists", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - parameters: [ - { in: "querystring", name: "q" }, - { in: "path", name: "id", required: true }, - ], + 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" } }, }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { parameters: [{ in: "path", name: "id", required: true }] }, - }, - }); - }); - - it("removes querystring entries from components.parameters, keeping neighbors and reference entries", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - parameters: { - N: { in: "header", name: "h" }, - Q: { in: "querystring", name: "q" }, - R: { $ref: "#/components/parameters/N" }, + { + examples: { + inline: { value: 1 }, + referenced: { $ref: "#/components/examples/E" }, }, + in: "query", + name: "q", + schema: { type: "string", xml: { nodeType: "attribute" } }, }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - parameters: { - N: { in: "header", name: "h" }, - R: { $ref: "#/components/parameters/N" }, + ], + ])("%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 } }, + }, + }, }, - }, - openapi: "3.1.2", + }); }); - }); - it("clones a malformed components.parameters map through", () => { - const result = downgradeSpecV32ToV31( - asSpec({ components: { parameters: "junk" }, openapi: "3.2.0" }) - ); - expect(result).toEqual({ - components: { parameters: "junk" }, - openapi: "3.1.2", + 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 style: cookie and keeps the other fields", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - parameters: { P: { in: "cookie", name: "c", style: "cookie" } }, - }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { parameters: { P: { in: "cookie", name: "c" } } }, - openapi: "3.1.2", + 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("keeps other style values", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - parameters: { - P: { in: "query", name: "q", style: "deepObject" }, + it("does not resolve names through the prototype chain of the mediaTypes map", () => { + expect( + convertContent( + { + "application/json": { + $ref: "#/components/mediaTypes/hasOwnProperty", + }, }, - }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - parameters: { P: { in: "query", name: "q", style: "deepObject" } }, - }, - openapi: "3.1.2", + { mediaTypes: {} } + ) + ).toEqual({}); }); - }); - it("keeps parameter schemas verbatim and converts example maps", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - parameters: { - P: { - examples: { - inline: { dataValue: 1 }, - referenced: { $ref: "#/components/examples/E" }, - }, - in: "query", - name: "q", - schema: { type: "string", xml: { nodeType: "attribute" } }, - }, + 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: {} } }, }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - parameters: { - P: { - examples: { - inline: { value: 1 }, - referenced: { $ref: "#/components/examples/E" }, - }, - in: "query", - name: "q", - schema: { type: "string", xml: { nodeType: "attribute" } }, + }); + expect(convertContent(content, { mediaTypes: "junk" })).toEqual({}); + }); + + it("removes the mediaTypes map from components", () => { + expect( + convertSpec({ + components: { + mediaTypes: { Json: { schema: {} } }, + schemas: { S: { type: "string" } }, }, - }, - }, - openapi: "3.1.2", + }).components + ).toEqual({ schemas: { S: { type: "string" } } }); }); - }); - it("clones non-object parameter entries and non-array parameter lists through", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { parameters: [null, "junk"] }, - "/b": { parameters: "junk" }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { parameters: [null, "junk"] }, - "/b": { parameters: "junk" }, - }, + it("clones a non-object content value through", () => { + expect(convertContent("junk")).toBe("junk"); }); - }); -}); -describe("components.mediaTypes inlining", () => { - it("inlines a media type reference in a request body content map with the converted media type", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - mediaTypes: { Stream: { itemSchema: { type: "object" } } }, - }, - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/jsonl": { - $ref: "#/components/mediaTypes/Stream", - }, - }, - }, - responses: {}, - }, - }, - }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/jsonl": { - schema: { items: { type: "object" }, type: "array" }, - }, - }, - }, - responses: {}, - }, - }, - }, - }); - }); + describe("parameters and headers losing their entire content", () => { + const missing = { + "application/json": { $ref: "#/components/mediaTypes/Missing" }, + }; - it("inlines media type references in response, parameter, and header content maps", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - mediaTypes: { Json: { schema: { type: "string" } } }, - }, - openapi: "3.2.0", - paths: { - "/a": { + it("removes a parameter whose only content entry could not be inlined", () => { + expect( + convertPathItem({ get: { parameters: [ - { - content: { - "application/json": { - $ref: "#/components/mediaTypes/Json", - }, - }, - in: "query", - name: "q", - }, + { content: missing, in: "query", name: "q" }, + { in: "query", name: "keep", schema: {} }, ], - responses: { - "200": { - content: { - "application/json": { - $ref: "#/components/mediaTypes/Json", - }, - }, - description: "ok", - headers: { - "X-H": { - content: { - "application/json": { - $ref: "#/components/mediaTypes/Json", - }, + 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: {}, }, }, - }, - }, - }) - ); - const inlined = { schema: { type: "string" } }; - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { + { mediaTypes: { Known: { example: 1 } } } + ) + ).toEqual({ get: { parameters: [ { - content: { "application/json": inlined }, + content: { "application/xml": { example: 1 } }, in: "query", name: "q", }, ], - responses: { - "200": { - content: { "application/json": inlined }, - description: "ok", - headers: { - "X-H": { content: { "application/json": inlined } }, + 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: {} }, + }, + }, }, }, }, }, - }, - }, - }); - }); - - it("resolves chained media type references down to the final object", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - mediaTypes: { - A: { $ref: "#/components/mediaTypes/B" }, - B: { schema: { type: "number" } }, - }, - }, - openapi: "3.2.0", - paths: { + }); + expect(result.components).toEqual({ + headers: { Keep: { schema: {} } }, + parameters: {}, + }); + expect(result.paths).toEqual({ "/a": { - post: { - requestBody: { + 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: { - "application/json": { $ref: "#/components/mediaTypes/A" }, + "text/plain": { $ref: "#/components/mediaTypes/Loop" }, }, }, - responses: {}, + BrokenAlias: { $ref: "#/components/headers/Broken" }, + }, + mediaTypes: { Loop: { $ref: "#/components/mediaTypes/Loop" } }, + parameters: { + Broken: { content: missing, in: "query", name: "q" }, + BrokenAlias: { $ref: "#/components/parameters/Broken" }, }, }, - }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { schema: { type: "number" } }, + 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", + }, + }, + }, + }, }, }, - responses: {}, }, - }, - }, + }); + expect(result.components).toEqual({ headers: {}, parameters: {} }); + expect(result.paths).toEqual({ + "/a": { + get: { + parameters: [], + responses: { "200": { description: "ok", headers: {} } }, + }, + }, + }); + }); }); }); - it("removes content entries whose reference chain is cyclic", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - mediaTypes: { - Loop: { $ref: "#/components/mediaTypes/Loop" }, - Ping: { $ref: "#/components/mediaTypes/Pong" }, - Pong: { $ref: "#/components/mediaTypes/Ping" }, + 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", }, }, - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { - $ref: "#/components/mediaTypes/Loop", - }, - "application/xml": { - $ref: "#/components/mediaTypes/Ping", - }, - }, - }, - responses: {}, - }, + }); + 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" }, }, }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { content: {} }, - responses: {}, + { + examples: { + inline: { value: "raw" }, + referenced: { $ref: "#/components/examples/E" }, }, }, - }, - }); - }); - - 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" } }], - ]); - const result = downgradeSpecV32ToV31( - asSpec({ - components: { mediaTypes }, - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { $ref: "#/components/mediaTypes/m0" }, + ], + ])("%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" }], }, - responses: {}, }, }, - }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { schema: { type: "string" } }, + }) + ).toEqual({ + "multipart/form-data": { + encoding: { + part: { + contentType: "application/json", + headers: { + Referenced: { $ref: "#/components/headers/H" }, + "X-H": { description: "h" }, }, }, - responses: {}, }, }, - }, + }); }); }); - it("removes content entries with external and unparseable references", () => { - const content = { - "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" }, - }; - const result = downgradeSpecV32ToV31( - asSpec({ - components: { mediaTypes: { Known: { example: 1 } } }, - openapi: "3.2.0", - paths: { - "/a": { - post: { requestBody: { content }, responses: {} }, - }, + 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", }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { content: { "a/6": { example: 1 } } }, - responses: {}, - }, + { + $ref: "#/components/responses/R", + description: "override", + summary: "kept", }, - }, + ], + ])("%s", (_name, response, expected) => { + expect(convertComponent("responses", response)).toEqual(expected); }); - }); - it("does not resolve names through the prototype chain of the mediaTypes map", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { mediaTypes: {} }, - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { - $ref: "#/components/mediaTypes/hasOwnProperty", - }, - }, - }, - responses: {}, + it("clones x- keys of the responses map without response conversion", () => { + expect( + convertPathItem({ + get: { + responses: { + "200": { summary: "ok" }, + "x-note": { summary: "not a response" }, }, }, - }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { content: {} }, - responses: {}, + }) + ).toEqual({ + get: { + responses: { + "200": { description: "ok" }, + "x-note": { summary: "not a response" }, }, }, - }, + }); }); - }); - it("removes media type references when components.mediaTypes is missing or malformed", () => { - const content = { - "application/json": { $ref: "#/components/mediaTypes/A" }, - }; - const withoutComponents = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { post: { requestBody: { content }, responses: {} } }, + 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" }, + }, }, - }) - ); - expect(withoutComponents).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { post: { requestBody: { content: {} }, responses: {} } }, - }, - }); - const withJunkMap = downgradeSpecV32ToV31( - asSpec({ - components: { mediaTypes: "junk" }, - openapi: "3.2.0", - paths: { - "/a": { post: { requestBody: { content }, responses: {} } }, + description: "ok", + headers: { "X-H": {} }, + links: { + inline: { server: { url: "/u" } }, + referenced: { $ref: "#/components/links/L" }, }, - }) - ); - expect(withJunkMap).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { post: { requestBody: { content: {} }, responses: {} } }, - }, + }); }); }); - it("removes the mediaTypes map from components", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - mediaTypes: { Json: { schema: {} } }, - schemas: { S: { type: "string" } }, + 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", }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { schemas: { S: { type: "string" } } }, - openapi: "3.1.2", + { 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("media types", () => { - it("turns itemSchema into a deep-cloned array schema when no schema exists", () => { - const itemSchema = { type: "object", xml: { nodeType: "text" } }; - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { "application/jsonl": { itemSchema } }, - }, - responses: {}, + 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", }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/jsonl": { - schema: { - items: { type: "object", xml: { nodeType: "text" } }, - type: "array", - }, - }, - }, + { 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" }, }, - responses: {}, }, - }, - }, + }).webhooks + ).toEqual({ + newPet: { post: { responses: { "200": { description: "ok" } } } }, + }); }); - const promoted = dig( - result, - "paths", - "/a", - "post", - "requestBody", - "content", - "application/jsonl", - "schema", - "items" - ); - expect(promoted).not.toBe(itemSchema); - expect(dig(promoted, "xml")).not.toBe(itemSchema.xml); - }); - it("removes itemSchema when a schema already exists", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/jsonl": { - itemSchema: { type: "string" }, - schema: { type: "array" }, - }, - }, - required: true, - }, - responses: {}, - }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { "application/jsonl": { schema: { type: "array" } } }, - required: true, - }, - responses: {}, - }, - }, - }, + it("converts components.pathItems path items and removes their query operation", () => { + expect( + convertComponent("pathItems", { + get: { responses: {} }, + query: { description: "q" }, + }) + ).toEqual({ get: { responses: {} } }); }); }); - it("removes media type prefixEncoding and itemEncoding", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "multipart/mixed": { - example: 1, - itemEncoding: { contentType: "text/plain" }, - prefixEncoding: [{ contentType: "application/json" }], - }, + 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" }, }, }, - responses: {}, + referenced: { $ref: "#/components/callbacks/C" }, }, + responses: {}, }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { "multipart/mixed": { example: 1 } }, + }) + ).toEqual({ + post: { + callbacks: { + onEvent: { + "x-note": { query: { description: "kept" } }, + "{$request.body#/url}": { + post: { responses: { "200": { description: "ok" } } }, + }, }, - responses: {}, + referenced: { $ref: "#/components/callbacks/C" }, }, + responses: {}, }, - }, + }); }); - }); - it("removes nested encoding keys inside encoding objects while still converting headers", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { - content: { - "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" }], - }, - }, - }, + 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" }, }, }, - responses: {}, + referenced: { $ref: "#/components/callbacks/inline" }, }, }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { - "multipart/form-data": { - encoding: { - part: { - contentType: "application/json", - headers: { - Referenced: { $ref: "#/components/headers/H" }, - "X-H": { description: "h" }, - }, - }, - }, - }, - }, + }).components + ).toEqual({ + callbacks: { + inline: { + "https://example.com/cb": { + post: { responses: { "200": { description: "ok" } } }, }, - responses: {}, }, + referenced: { $ref: "#/components/callbacks/inline" }, }, - }, + }); }); }); - it("converts example maps inside media types", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - post: { - requestBody: { + 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": { - examples: { - inline: { serializedValue: "raw" }, - referenced: { $ref: "#/components/examples/E" }, - }, - }, + "application/json": { itemSchema: { type: "string" } }, }, }, - responses: {}, + 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" }, }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - post: { - requestBody: { - content: { - "application/json": { - examples: { - inline: { value: "raw" }, - referenced: { $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" }, }, }, - responses: {}, }, + BRef: { $ref: "#/components/requestBodies/B" }, }, - }, - }); - }); -}); - -describe("responses", () => { - it("uses summary as the description when none exists", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { responses: { R: { summary: "ok" } } }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { responses: { R: { description: "ok" } } }, - openapi: "3.1.2", - }); - }); - - it("removes summary when a description exists", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - responses: { R: { description: "d", summary: "s" } }, + responses: { + R: { description: "ok" }, + RRef: { $ref: "#/components/responses/R" }, }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { responses: { R: { description: "d" } } }, - openapi: "3.1.2", - }); - }); - - it("synthesizes an empty description when neither summary nor description exist", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { responses: { R: {} } }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { responses: { R: { description: "" } } }, - openapi: "3.1.2", + }); }); - }); - it("leaves response reference objects untouched, including summary and description overrides", () => { - const reference = { - $ref: "#/components/responses/R", - description: "override", - summary: "kept", - }; - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { "/a": { get: { responses: { "200": reference } } } }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { "/a": { get: { responses: { "200": reference } } } }, + 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 x- keys of the responses map without response conversion", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - get: { - responses: { - "200": { summary: "ok" }, - "x-note": { summary: "not a response" }, - }, - }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - get: { - responses: { - "200": { description: "ok" }, - "x-note": { summary: "not a response" }, - }, - }, - }, - }, + 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"); }); }); - it("converts response headers, content, and links", () => { - const result = downgradeSpecV32ToV31( - asSpec({ + describe("robustness", () => { + it("never mutates the input document", () => { + const spec = asSpec({ + $self: "https://example.com/api.json", components: { - responses: { - R: { - 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" }, - }, - }, + 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", - }) - ); - expect(result).toEqual({ - components: { - responses: { - R: { - content: { - "application/json": { - schema: { items: { type: "string" }, type: "array" }, + paths: { + "/a": { + additionalOperations: { NOTIFY: { description: "n" } }, + get: { + parameters: [{ in: "querystring", name: "q" }], + requestBody: { + content: { + "application/json": { $ref: "#/components/mediaTypes/A" }, + }, }, + responses: { "200": { summary: "ok" } }, }, - description: "ok", - headers: { "X-H": {} }, - links: { - inline: { server: { url: "/u" } }, - referenced: { $ref: "#/components/links/L" }, - }, + query: { description: "q" }, + servers: [{ name: "s", url: "/u" }], }, }, - }, - openapi: "3.1.2", - }); - }); -}); - -describe("examples", () => { - const convertExampleComponent = (example: unknown): unknown => - dig( - downgradeSpecV32ToV31( - asSpec({ - components: { examples: { E: example } }, - openapi: "3.2.0", - }) - ), - "components", - "examples", - "E" - ); - - it("moves dataValue into the free value slot", () => { - expect(convertExampleComponent({ dataValue: { a: 1 } })).toEqual({ - value: { a: 1 }, - }); - }); - - it("moves serializedValue into the free value slot", () => { - expect(convertExampleComponent({ serializedValue: "a=1" })).toEqual({ - value: "a=1", - }); - }); - - it("removes dataValue when value already exists", () => { - expect(convertExampleComponent({ dataValue: 1, value: 2 })).toEqual({ - value: 2, + 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("removes serializedValue when value already exists", () => { - expect(convertExampleComponent({ serializedValue: "s", value: 2 })).toEqual( - { value: 2 } - ); - }); - it("removes dataValue and serializedValue when externalValue exists", () => { - expect( - convertExampleComponent({ - dataValue: 1, - externalValue: "https://example.com/e.json", - serializedValue: "s", - }) - ).toEqual({ externalValue: "https://example.com/e.json" }); - }); - - it("lets dataValue win the value slot over serializedValue", () => { - expect( - convertExampleComponent({ dataValue: 1, serializedValue: "s" }) - ).toEqual({ value: 1 }); - }); - - it("keeps other example fields untouched", () => { - expect( - convertExampleComponent({ dataValue: 1, description: "d", summary: "s" }) - ).toEqual({ description: "d", summary: "s", value: 1 }); - }); - - it("leaves an example without any value fields unchanged", () => { - expect(convertExampleComponent({ summary: "s" })).toEqual({ - summary: "s", + 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("schema passthrough", () => { +describe("downgradeSchemaV32ToV31", () => { it("deep-clones schemas, preserving discriminator defaultMapping and xml nodeType verbatim", () => { const source = { discriminator: { @@ -1271,884 +1284,39 @@ describe("schema passthrough", () => { }); it("keeps unknown schema keywords, validation keywords, and extensions unchanged", () => { - expect( - downgradeSchemaV32ToV31( - asSchema({ - customKeyword: { nested: true }, - maximum: 5, - type: "number", - "x-note": "kept", - }) - ) - ).toEqual({ + const source = { customKeyword: { nested: true }, maximum: 5, type: "number", "x-note": "kept", - }); + }; + expect(downgradeSchemaV32ToV31(asSchema(source))).toEqual(source); }); - it("passes boolean schemas through", () => { + it("passes boolean and junk schema input through", () => { expect(downgradeSchemaV32ToV31(true)).toBe(true); expect(downgradeSchemaV32ToV31(false)).toBe(false); - }); - - it("passes junk schema input through", () => { expect(downgradeSchemaV32ToV31(asSchema("junk"))).toBe("junk"); expect(downgradeSchemaV32ToV31(asSchema(null))).toBeNull(); expect( downgradeSchemaV32ToV31(asSchema({ allOf: "junk", properties: 5 })) - ).toEqual({ allOf: "junk", properties: 5 }); - }); -}); - -describe("security schemes", () => { - const convertSecuritySchemeComponent = (scheme: unknown): unknown => - dig( - downgradeSpecV32ToV31( - asSpec({ - components: { securitySchemes: { S: scheme } }, - openapi: "3.2.0", - }) - ), - "components", - "securitySchemes", - "S" - ); - - it("removes deprecated whatever its value", () => { - expect( - convertSecuritySchemeComponent({ deprecated: true, type: "http" }) - ).toEqual({ type: "http" }); - expect( - convertSecuritySchemeComponent({ deprecated: false, type: "http" }) - ).toEqual({ type: "http" }); - expect( - convertSecuritySchemeComponent({ deprecated: "yes", type: "http" }) - ).toEqual({ type: "http" }); - }); - - it("removes oauth2MetadataUrl whatever its value", () => { - expect( - convertSecuritySchemeComponent({ - oauth2MetadataUrl: "https://example.com/meta", - type: "oauth2", - }) - ).toEqual({ type: "oauth2" }); - expect( - convertSecuritySchemeComponent({ oauth2MetadataUrl: 42, type: "oauth2" }) - ).toEqual({ type: "oauth2" }); - }); - - it("removes the deviceAuthorization flow and keeps other flows", () => { - expect( - convertSecuritySchemeComponent({ - flows: { - authorizationCode: { - authorizationUrl: "https://example.com/auth", - scopes: {}, - tokenUrl: "https://example.com/token", - }, - deviceAuthorization: { - deviceAuthorizationUrl: "https://example.com/device", - scopes: {}, - tokenUrl: "https://example.com/token", - }, - }, - type: "oauth2", - }) ).toEqual({ - flows: { - authorizationCode: { - authorizationUrl: "https://example.com/auth", - scopes: {}, - tokenUrl: "https://example.com/token", - }, - }, - type: "oauth2", + allOf: "junk", + properties: 5, }); }); - it("removes deviceAuthorization whatever its value", () => { - expect( - convertSecuritySchemeComponent({ - flows: { deviceAuthorization: "junk" }, - type: "oauth2", - }) - ).toEqual({ flows: {}, type: "oauth2" }); - }); - - it("clones malformed flows through", () => { - expect( - convertSecuritySchemeComponent({ flows: "junk", type: "oauth2" }) - ).toEqual({ flows: "junk", type: "oauth2" }); - }); - - it("clones references in securitySchemes", () => { - expect( - convertSecuritySchemeComponent({ - $ref: "#/components/securitySchemes/Other", - }) - ).toEqual({ $ref: "#/components/securitySchemes/Other" }); - }); - - it("passes a non-object security scheme through", () => { - expect(convertSecuritySchemeComponent("junk")).toBe("junk"); + 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); }); -}); -describe("webhooks and components.pathItems", () => { - it("converts webhook path items and removes their query operation", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - webhooks: { - newPet: { - post: { responses: { "200": { summary: "ok" } } }, - query: { description: "q" }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - webhooks: { - newPet: { - post: { responses: { "200": { description: "ok" } } }, - }, - }, - }); - }); - - it("converts components.pathItems path items and removes their query operation", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - pathItems: { - P: { - get: { responses: {} }, - query: { description: "q" }, - }, - }, - }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { pathItems: { P: { get: { responses: {} } } } }, - openapi: "3.1.2", - }); - }); -}); - -describe("callbacks", () => { - it("converts path items in operation-level callbacks and clones x- keys", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - 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: {}, - }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - 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", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - callbacks: { - inline: { - "https://example.com/cb": { - post: { responses: { "200": { summary: "ok" } } }, - query: { description: "q" }, - }, - }, - referenced: { $ref: "#/components/callbacks/inline" }, - }, - }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - callbacks: { - inline: { - "https://example.com/cb": { - post: { responses: { "200": { description: "ok" } } }, - }, - }, - referenced: { $ref: "#/components/callbacks/inline" }, - }, - }, - openapi: "3.1.2", - }); - }); -}); - -describe("components maps", () => { - it("handles references and inline objects across component maps", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - examples: { - E: { dataValue: 1 }, - ERef: { $ref: "#/components/examples/E" }, - }, - headers: { - H: { style: "cookie" }, - HRef: { $ref: "#/components/headers/H" }, - }, - 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" }, - }, - }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - examples: { - E: { value: 1 }, - ERef: { $ref: "#/components/examples/E" }, - }, - headers: { - H: {}, - HRef: { $ref: "#/components/headers/H" }, - }, - 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" }, - }, - }, - openapi: "3.1.2", - }); - }); - - it("clones components.schemas entries unchanged, keeping 3.2 OAS vocabulary fields", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - schemas: { - S: { - discriminator: { defaultMapping: "Dog", propertyName: "kind" }, - xml: { nodeType: "attribute" }, - }, - }, - }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { - schemas: { - S: { - discriminator: { defaultMapping: "Dog", propertyName: "kind" }, - xml: { nodeType: "attribute" }, - }, - }, - }, - openapi: "3.1.2", - }); - }); - - it("clones unknown component keys and passes non-object components through", () => { - const withUnknownKey = downgradeSpecV32ToV31( - asSpec({ - components: { custom: { anything: true } }, - openapi: "3.2.0", - }) - ); - expect(withUnknownKey).toEqual({ - components: { custom: { anything: true } }, - openapi: "3.1.2", - }); - const withJunkComponents = downgradeSpecV32ToV31( - asSpec({ components: "junk", openapi: "3.2.0" }) - ); - expect(withJunkComponents).toEqual({ - components: "junk", - openapi: "3.1.2", - }); - }); -}); - -describe("extension and unknown key preservation", () => { - it("preserves x- keys and unknown keys at the document, path item, and operation levels", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - futureKey: { anything: [1] }, - info: { title: "t", version: "1" }, - jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", - openapi: "3.2.0", - 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(result).toEqual({ - futureKey: { anything: [1] }, - info: { title: "t", version: "1" }, - jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", - openapi: "3.1.2", - 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 } }, - }); - }); -}); - -describe("input non-mutation", () => { - 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 snapshot = structuredClone(spec); - downgradeSpecV32ToV31(spec); - expect(spec).toEqual(snapshot); - }); - - it("never mutates the input schema", () => { - const schema: OpenAPIV3_2.SchemaObject = { - discriminator: { defaultMapping: "Dog", propertyName: "kind" }, - properties: { a: { xml: { nodeType: "attribute" } } }, - type: "object", - }; - const snapshot = structuredClone(schema); - downgradeSchemaV32ToV31(schema); - expect(schema).toEqual(snapshot); - }); -}); - -describe("graceful handling of malformed nested objects", () => { - it("clones malformed operations, request bodies, responses, media types, encodings, examples, and links", () => { - const spec = asSpec({ - components: { - examples: { junkExample: 42 }, - links: { junkLink: 42 }, - }, - openapi: "3.2.0", - 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(downgradeSpecV32ToV31(spec)).toEqual({ - components: { - examples: { junkExample: 42 }, - links: { junkLink: 42 }, - }, - openapi: "3.1.2", - paths: { - "/a": { - get: "junk", - post: { - requestBody: { - content: { - "application/json": 42, - "multipart/form-data": { - encoding: { field: "junk" }, - example: 5, - }, - }, - }, - }, - put: { requestBody: 42, responses: { "200": 42 } }, - }, - }, - }); - }); -}); - -describe("media type description", () => { - it("removes the 3.2-only media type description and keeps other fields", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - get: { - responses: { - "200": { - content: { - "application/json": { - description: "a JSON payload", - example: 5, - schema: { type: "integer" }, - }, - }, - description: "ok", - }, - }, - }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - get: { - responses: { - "200": { - content: { - "application/json": { - example: 5, - schema: { type: "integer" }, - }, - }, - description: "ok", - }, - }, - }, - }, - }, - }); - }); -}); - -describe("parameter allowReserved", () => { - it("keeps allowReserved on query parameters and removes it elsewhere", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a/{id}": { - parameters: [ - { allowReserved: true, in: "query", name: "q", schema: {} }, - { - allowReserved: true, - in: "path", - name: "id", - required: true, - schema: {}, - }, - { allowReserved: true, in: "cookie", name: "c", schema: {} }, - ], - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a/{id}": { - parameters: [ - { allowReserved: true, in: "query", name: "q", schema: {} }, - { in: "path", name: "id", required: true, schema: {} }, - { in: "cookie", name: "c", schema: {} }, - ], - }, - }, - }); - }); - - it("keeps allowReserved on objects without an in field", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { headers: { H: { allowReserved: true, schema: {} } } }, - openapi: "3.2.0", - }) - ); - expect(result).toEqual({ - components: { headers: { H: { allowReserved: true, schema: {} } } }, - openapi: "3.1.2", - }); - }); -}); - -describe("references to removed querystring parameters", () => { - it("removes list references to removed querystring component parameters and keeps others", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - parameters: { - Keep: { in: "query", name: "k", schema: {} }, - QS: { - content: { "application/x-www-form-urlencoded": { schema: {} } }, - in: "querystring", - name: "filter", - }, - }, - }, - openapi: "3.2.0", - paths: { - "/a": { - get: { - parameters: [ - { $ref: "#/components/parameters/QS" }, - { $ref: "#/components/parameters/Keep" }, - ], - responses: {}, - }, - parameters: [{ $ref: "#/components/parameters/QS" }], - }, - }, - }) - ); - expect(result).toEqual({ - components: { - parameters: { Keep: { in: "query", name: "k", schema: {} } }, - }, - openapi: "3.1.2", - paths: { - "/a": { - get: { - parameters: [{ $ref: "#/components/parameters/Keep" }], - responses: {}, - }, - parameters: [], - }, - }, - }); - }); -}); - -describe("malformed content maps", () => { - it("clones a non-object content value through", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { post: { requestBody: { content: "junk" }, responses: {} } }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { post: { requestBody: { content: "junk" }, responses: {} } }, - }, - }); - }); -}); - -describe("parameters and headers losing their entire content", () => { - it("removes a parameter whose only content entry could not be inlined", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - get: { - parameters: [ - { - content: { - "application/json": { - $ref: "#/components/mediaTypes/Missing", - }, - }, - in: "query", - name: "q", - }, - { in: "query", name: "keep", schema: {} }, - ], - responses: {}, - }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - get: { - parameters: [{ in: "query", name: "keep", schema: {} }], - responses: {}, - }, - }, - }, - }); - }); - - it("keeps a parameter when part of its content could be inlined", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { mediaTypes: { Known: { example: 1 } } }, - openapi: "3.2.0", - paths: { - "/a": { - get: { - parameters: [ - { - content: { - "application/json": { - $ref: "#/components/mediaTypes/Missing", - }, - "application/xml": { - $ref: "#/components/mediaTypes/Known", - }, - }, - in: "query", - name: "q", - }, - ], - responses: {}, - }, - }, - }, - }) - ); - expect(result).toEqual({ - components: {}, - openapi: "3.1.2", - paths: { - "/a": { - 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 = downgradeSpecV32ToV31( - asSpec({ - components: { - headers: { - Broken: { - content: { - "text/plain": { $ref: "#/components/mediaTypes/Missing" }, - }, - }, - Keep: { schema: {} }, - }, - parameters: { - Broken: { - content: { - "text/plain": { $ref: "#/components/mediaTypes/Missing" }, - }, - in: "query", - name: "q", - }, - }, - }, - openapi: "3.2.0", - paths: { - "/a": { - get: { - responses: { - "200": { - description: "ok", - headers: { - "X-Broken": { - content: { - "text/plain": { - $ref: "#/components/mediaTypes/Missing", - }, - }, - }, - "X-Keep": { schema: {} }, - }, - }, - }, - }, - }, - }, - }) - ); - expect(result).toEqual({ - components: { - headers: { Keep: { schema: {} } }, - parameters: {}, - }, - openapi: "3.1.2", - paths: { - "/a": { - get: { - responses: { - "200": { - description: "ok", - headers: { "X-Keep": { schema: {} } }, - }, - }, - }, - }, - }, - }); - }); -}); - -describe("querystring reference alias chains", () => { - it("removes aliases of removed querystring parameters and references to them", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - 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", - }, - }, - }, - openapi: "3.2.0", - paths: { - "/a": { - get: { - parameters: [ - { $ref: "#/components/parameters/AliasOfAlias" }, - { $ref: "#/components/parameters/Keep" }, - ], - responses: {}, - }, - }, - }, - }) - ); - expect(result).toEqual({ - components: { - parameters: { Keep: { in: "query", name: "k", schema: {} } }, - }, - openapi: "3.1.2", - paths: { - "/a": { - get: { - parameters: [{ $ref: "#/components/parameters/Keep" }], - responses: {}, - }, - }, - }, - }); - }); -}); - -describe("deep documents", () => { it("converts deeply nested schemas without throwing", () => { let deep = asSchema({ type: "string" }); for (let index = 0; index < 1000; index += 1) { @@ -2157,109 +1325,3 @@ describe("deep documents", () => { expect(() => downgradeSchemaV32ToV31(deep)).not.toThrow(); }); }); - -describe("malformed header maps", () => { - it("clones a non-object headers value through", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - openapi: "3.2.0", - paths: { - "/a": { - get: { - responses: { "200": { description: "ok", headers: "junk" } }, - }, - }, - }, - }) - ); - expect(result).toEqual({ - openapi: "3.1.2", - paths: { - "/a": { - get: { - responses: { "200": { description: "ok", headers: "junk" } }, - }, - }, - }, - }); - }); -}); - -describe("cyclic input", () => { - 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(() => - downgradeSpecV32ToV31( - asSpec({ openapi: "3.2.0", paths: { "/a": pathItem } }) - ) - ).not.toThrow(); - }); -}); - -describe("references to components removed for uninlinable content", () => { - it("removes parameter and header references whose targets lost their entire content", () => { - const result = downgradeSpecV32ToV31( - asSpec({ - components: { - headers: { - Broken: { - content: { - "text/plain": { $ref: "#/components/mediaTypes/Loop" }, - }, - }, - BrokenAlias: { $ref: "#/components/headers/Broken" }, - }, - mediaTypes: { Loop: { $ref: "#/components/mediaTypes/Loop" } }, - parameters: { - Broken: { - content: { - "text/plain": { $ref: "#/components/mediaTypes/Missing" }, - }, - in: "query", - name: "q", - }, - BrokenAlias: { $ref: "#/components/parameters/Broken" }, - }, - }, - openapi: "3.2.0", - 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).toEqual({ - components: { headers: {}, parameters: {} }, - openapi: "3.1.2", - paths: { - "/a": { - get: { - parameters: [], - responses: { "200": { description: "ok", headers: {} } }, - }, - }, - }, - }); - }); -}); diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 4d87c10..ba73b5b 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -8,9 +8,9 @@ * 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: + * 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, @@ -46,44 +46,22 @@ import type { OpenAPIV3_1, OpenAPIV3_2 } from "@oasty/types"; -import type { UnknownRecord } from "./shared"; +import type { FieldConverter, UnknownRecord } from "./shared"; import { + convertRecord, deepClone, + DROP, getRef, isRecord, mapArray, mapRecord, - omitKeys, - setKey, - withCycleGuard, + operationFields, } from "./shared"; const HEADERS_REF_PREFIX = "#/components/headers/"; const MEDIA_TYPES_REF_PREFIX = "#/components/mediaTypes/"; const PARAMETERS_REF_PREFIX = "#/components/parameters/"; -const HTTP_METHODS = new Set([ - "delete", - "get", - "head", - "options", - "patch", - "post", - "put", - "trace", -]); - -/** 3.2-only fields with no 3.1 equivalent, removed per object. */ -const DROPPED_SERVER_KEYS = new Set(["name"]); -const DROPPED_TAG_KEYS = new Set(["kind", "parent", "summary"]); -const DROPPED_FLOWS_KEYS = new Set(["deviceAuthorization"]); - -/** Marks a content-map entry whose reference cannot be inlined. */ -const UNRESOLVED_MEDIA_TYPE = Symbol("unresolved media type"); - -/** Marks a parameter or header whose entire `content` could not be inlined. */ -const DROPPED_PARAMETER = Symbol("dropped parameter"); - interface Context { /** The raw `components.mediaTypes` map, used to inline references. */ mediaTypes: UnknownRecord | undefined; @@ -91,21 +69,8 @@ interface Context { removedHeaderRefs: ReadonlySet; /** `$ref` strings of `components.parameters` entries conversion removes. */ removedParameterRefs: ReadonlySet; - /** Media type names currently being resolved, for cycle detection. */ - resolving: Set; } -const parseMediaTypeName = (ref: string): string | undefined => { - if (!ref.startsWith(MEDIA_TYPES_REF_PREFIX)) { - return undefined; - } - const name = ref.slice(MEDIA_TYPES_REF_PREFIX.length); - if (name === "" || name.includes("/")) { - return undefined; - } - return name; -}; - /** * 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 @@ -124,85 +89,63 @@ export const downgradeSchemaV32ToV31 = ( return converted as OpenAPIV3_1.SchemaObject; }; -const convertServer = (value: unknown): unknown => - omitKeys(value, DROPPED_SERVER_KEYS); - -const convertExample = (value: unknown): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (key !== "dataValue" && key !== "serializedValue") { - setKey(out, key, deepClone(item)); - } - } - // 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 value) && !("externalValue" in value)) { - if ("dataValue" in value) { - setKey(out, "value", deepClone(value.dataValue)); - } else if ("serializedValue" in value) { - setKey(out, "value", deepClone(value.serializedValue)); - } - } - return out; -}; - -const convertLink = (value: unknown): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (key === "server") { - setKey(out, key, convertServer(item)); - } else { - setKey(out, key, deepClone(item)); - } - } - return out; -}; - -const convertSecurityScheme = (value: unknown): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - // New in 3.2, no 3.1 equivalent. - case "deprecated": - case "oauth2MetadataUrl": { - continue; - } - case "flows": { - setKey(out, key, omitKeys(item, DROPPED_FLOWS_KEYS)); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; -}; - /** * References stay references in 3.1 (including their `summary`/`description` - * overrides); everything else is converted. Converters that ignore the - * context are wrapped so every call site reads uniformly. + * overrides); everything else is converted. */ const convertRefOr = ( value: unknown, context: Context, convert: (item: unknown, innerContext: Context) => unknown -): unknown => { - if (getRef(value) !== undefined) { - return deepClone(value); - } - return convert(value, context); -}; +): 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 => @@ -218,202 +161,117 @@ const isRemovedRef = ( }; /** Parameter Objects and Header Objects share every field this converter touches. */ -const convertParameterOrHeader = ( - value: unknown, - context: Context -): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "style": { - // "cookie" is not a 3.1 style; removing it lets the 3.1 default - // (`form`) take over. Other styles pass through. - if (item !== "cookie") { - setKey(out, key, deepClone(item)); - } - continue; - } - case "allowReserved": { - // 3.2 broadened allowReserved beyond query parameters; 3.1 only - // defines it there. - if (!("in" in value) || value.in === "query") { - setKey(out, key, deepClone(item)); - } - continue; - } - case "content": { - // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertContentMap via media type encodings - setKey(out, key, convertContentMap(item, context)); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertExample) - ) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } +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; } - } - if ( - isRecord(value.content) && - Object.keys(value.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 DROPPED_PARAMETER; - } - return out; -}; + ); /** - * Converts a parameter list, removing 3.2-only `querystring` parameters, - * references to removed querystring component parameters, and parameters - * whose entire `content` could not be inlined. + * Converts a parameter list entry, removing 3.2-only `querystring` + * parameters and references to removed component parameters. */ -const convertParameterList = (value: unknown, context: Context): unknown => { - if (!Array.isArray(value)) { - return deepClone(value); - } - return value - .filter( - (item) => - !( - isQuerystringParameter(item) || - isRemovedRef(item, context.removedParameterRefs) - ) - ) - .map((item) => convertRefOr(item, context, convertParameterOrHeader)) - .filter((item) => item !== DROPPED_PARAMETER); -}; +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) + ); -/** - * Converts a map of Header Objects, removing headers whose entire `content` - * could not be inlined. - */ -const convertHeaderMap = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [name, item] of Object.entries(value)) { - if (isRemovedRef(item, context.removedHeaderRefs)) { - continue; - } - const converted = convertRefOr(item, context, convertParameterOrHeader); - if (converted !== DROPPED_PARAMETER) { - setKey(out, name, converted); - } - } - return out; -}; +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 convertEncoding = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "headers": { - setKey(out, key, convertHeaderMap(item, context)); - continue; - } - // Nested and positional encoding are new in 3.2. - case "encoding": - case "itemEncoding": - case "prefixEncoding": { - continue; - } - default: { - setKey(out, key, deepClone(item)); - } +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; } - } - return out; -}; - -const convertMediaType = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - return withCycleGuard(value, () => { - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - // `itemSchema` is handled below; `description`, positional encoding, - // and nested encoding are 3.2-only. - case "description": - case "itemEncoding": - case "itemSchema": - case "prefixEncoding": { - continue; - } - case "encoding": { - setKey( - out, - key, - mapRecord(item, (entry) => convertEncoding(entry, context)) - ); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertExample) - ) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - if ("itemSchema" in value && out.schema === undefined) { - // The 3.2 sequential media type data model maps streams to arrays. - setKey(out, "schema", { - items: deepClone(value.itemSchema), - type: "array", - }); - } - return out; - }); -}; + ); -const resolveContentEntry = (value: unknown, context: Context): unknown => { +/** + * 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 convertMediaType(value, context); + return value; + } + if (!ref.startsWith(MEDIA_TYPES_REF_PREFIX)) { + return DROP; } - const name = parseMediaTypeName(ref); + const name = ref.slice(MEDIA_TYPES_REF_PREFIX.length); if ( - name === undefined || - context.mediaTypes === undefined || - !Object.hasOwn(context.mediaTypes, name) || - context.resolving.has(name) + name === "" || + name.includes("/") || + mediaTypes === undefined || + !Object.hasOwn(mediaTypes, name) || + seen.has(name) ) { - // External, unknown, or cyclic target: 3.1 content maps cannot hold - // references, so the entry is removed. - return UNRESOLVED_MEDIA_TYPE; + return DROP; } - context.resolving.add(name); - const resolved = resolveContentEntry(context.mediaTypes[name], context); - context.resolving.delete(name); - return resolved; + seen.add(name); + return resolveMediaType(mediaTypes[name], mediaTypes, seen); }; /** @@ -421,76 +279,34 @@ const resolveContentEntry = (value: unknown, context: Context): unknown => { * content maps hold Media Type Objects only, never references) and removing * entries whose reference cannot be inlined. */ -const convertContentMap = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - const resolved = resolveContentEntry(item, context); - if (resolved !== UNRESOLVED_MEDIA_TYPE) { - setKey(out, key, resolved); - } - } - return out; -}; - -const convertRequestBody = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (key === "content") { - setKey(out, key, convertContentMap(item, context)); - } else { - setKey(out, key, deepClone(item)); - } - } - return out; -}; +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 convertResponse = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "summary": { - // Promoted below when possible; 3.1 responses have no summary. - continue; - } - case "headers": { - setKey(out, key, convertHeaderMap(item, context)); - continue; - } - case "content": { - setKey(out, key, convertContentMap(item, context)); - continue; - } - case "links": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, context, convertLink)) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } +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; } - } - if ("summary" in value && !("description" in value)) { - setKey(out, "description", deepClone(value.summary)); - } - if (out.description === undefined) { - // Required in 3.1, optional in 3.2. - setKey(out, "description", ""); - } - return out; -}; + ); const convertResponses = (value: unknown, context: Context): unknown => mapRecord(value, (item, key) => @@ -499,46 +315,15 @@ const convertResponses = (value: unknown, context: Context): unknown => : convertRefOr(item, context, convertResponse) ); -const convertOperation = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "parameters": { - setKey(out, key, convertParameterList(item, context)); - continue; - } - case "requestBody": { - setKey(out, key, convertRefOr(item, context, convertRequestBody)); - continue; - } - case "responses": { - setKey(out, key, convertResponses(item, context)); - continue; - } - case "callbacks": { - // oxlint-disable-next-line no-use-before-define -- mutually recursive with convertCallback, as callbacks contain path items - const convert = convertCallback; - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, context, convert)) - ); - continue; - } - case "servers": { - setKey(out, key, mapArray(item, convertServer)); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; -}; +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) => @@ -546,193 +331,39 @@ const convertCallback = (value: unknown, context: Context): unknown => key.startsWith("x-") ? deepClone(item) : convertPathItem(item, context) ); -const convertPathItem = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - return withCycleGuard(value, () => { - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - if (HTTP_METHODS.has(key)) { - setKey(out, key, convertOperation(item, context)); - continue; - } - switch (key) { - // The QUERY method and arbitrary additional operations are 3.2-only. - case "additionalOperations": - case "query": { - continue; - } - case "parameters": { - setKey(out, key, convertParameterList(item, context)); - continue; - } - case "servers": { - setKey(out, key, mapArray(item, convertServer)); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; +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) ); -/** - * Converts `components.parameters`, removing `querystring` parameters and - * references to removed querystring entries. - */ -const convertParameterComponents = ( - value: unknown, - context: Context -): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [name, item] of Object.entries(value)) { - if ( - isQuerystringParameter(item) || - isRemovedRef(item, context.removedParameterRefs) - ) { - continue; - } - const converted = convertRefOr(item, context, convertParameterOrHeader); - if (converted !== DROPPED_PARAMETER) { - setKey(out, name, converted); - } - } - return out; -}; - -const convertComponents = (value: unknown, context: Context): unknown => { - if (!isRecord(value)) { - return deepClone(value); - } - const out: UnknownRecord = {}; - for (const [key, item] of Object.entries(value)) { - switch (key) { - case "responses": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertResponse) - ) - ); - continue; - } - case "parameters": { - setKey(out, key, convertParameterComponents(item, context)); - continue; - } - case "headers": { - setKey(out, key, convertHeaderMap(item, context)); - continue; - } - case "examples": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertExample) - ) - ); - continue; - } - case "requestBodies": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertRequestBody) - ) - ); - continue; - } - case "mediaTypes": { - // Inlined at use sites; 3.1 has no reusable media types. - continue; - } - case "securitySchemes": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertSecurityScheme) - ) - ); - continue; - } - case "links": { - setKey( - out, - key, - mapRecord(item, (entry) => convertRefOr(entry, context, convertLink)) - ); - continue; - } - case "callbacks": { - setKey( - out, - key, - mapRecord(item, (entry) => - convertRefOr(entry, context, convertCallback) - ) - ); - continue; - } - case "pathItems": { - setKey( - out, - key, - mapRecord(item, (entry) => convertPathItem(entry, context)) - ); - continue; - } - default: { - setKey(out, key, deepClone(item)); - } - } - } - return out; -}; - -/** Collects the `$ref` strings of querystring `components.parameters` entries. */ -/** - * Whether a content-map entry is a reference that cannot be inlined - * (external, unknown, or cyclic target) and would therefore be removed. - */ -const isUnresolvableContentRef = ( - item: unknown, - mediaTypes: UnknownRecord | undefined, - seen: Set -): boolean => { - const ref = getRef(item); - if (ref === undefined) { - return false; - } - const name = parseMediaTypeName(ref); - if ( - name === undefined || - mediaTypes === undefined || - !Object.hasOwn(mediaTypes, name) || - seen.has(name) - ) { - return true; - } - seen.add(name); - return isUnresolvableContentRef(mediaTypes[name], mediaTypes, seen); -}; +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 strip every entry of the value's `content`. */ +/** Whether conversion would remove every entry of the value's `content`. */ const losesEntireContent = ( value: unknown, mediaTypes: UnknownRecord | undefined @@ -743,8 +374,8 @@ const losesEntireContent = ( const entries = Object.values(value.content); return ( entries.length > 0 && - entries.every((item) => - isUnresolvableContentRef(item, mediaTypes, new Set()) + entries.every( + (item) => resolveMediaType(item, mediaTypes, new Set()) === DROP ) ); }; @@ -788,80 +419,48 @@ const indexRemovedComponentRefs = ( return removed; }; -const convertSpec = (spec: unknown): unknown => { - if (!isRecord(spec)) { - return deepClone(spec); - } - const { components } = spec; +const createContext = (spec: unknown): Context => { + const components = isRecord(spec) ? spec.components : undefined; const mediaTypes = isRecord(components) && isRecord(components.mediaTypes) ? components.mediaTypes : undefined; - const componentMaps = isRecord(components) ? components : undefined; - const context: Context = { + return { mediaTypes, removedHeaderRefs: indexRemovedComponentRefs( - componentMaps?.headers, + isRecord(components) ? components.headers : undefined, HEADERS_REF_PREFIX, mediaTypes, () => false ), removedParameterRefs: indexRemovedComponentRefs( - componentMaps?.parameters, + isRecord(components) ? components.parameters : undefined, PARAMETERS_REF_PREFIX, mediaTypes, isQuerystringParameter ), - resolving: new Set(), }; - const out: UnknownRecord = {}; - for (const [key, value] of Object.entries(spec)) { - switch (key) { - case "openapi": { - setKey(out, "openapi", "3.1.2"); - continue; - } - case "$self": { - // 3.1 has no self-assigned document URI. - continue; - } - case "servers": { - setKey(out, key, mapArray(value, convertServer)); - continue; - } - case "paths": { - setKey(out, key, convertPaths(value, context)); - continue; - } - case "webhooks": { - setKey( - out, - key, - mapRecord(value, (item) => convertPathItem(item, context)) - ); - continue; - } - case "components": { - setKey(out, key, convertComponents(value, context)); - continue; - } - case "tags": { - setKey( - out, - key, - mapArray(value, (item) => omitKeys(item, DROPPED_TAG_KEYS)) - ); - continue; - } - default: { - setKey(out, key, deepClone(value)); - } +}; + +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; } - } - if (out.openapi === undefined) { - setKey(out, "openapi", "3.1.2"); - } - return out; + ); }; /**