Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"sync:sponsors": "node scripts/sync-sponsors.ts && git ls-files --modified --others --exclude-standard | xargs -r pnpm run lint:fix --no-error-on-unmatched-pattern"
},
"devDependencies": {
"@seriousme/openapi-schema-validator": "^2.9.1",
"@types/node": "^26.4.0",
"@vitest/coverage-v8": "^4.1.11",
"lint-staged": "^17.4.1",
Expand Down
86 changes: 86 additions & 0 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# @oasty/downgrader

Downgrade [OpenAPI Specification](https://spec.openapis.org/) documents one minor version at a time: 3.2 → 3.1 and 3.1 → 3.0. Each converter works on an entire document or on a single Schema Object.

- **Never throws**: malformed parts are deep-copied through unchanged instead of failing the whole conversion, and cyclic object graphs (e.g. the output of a `$ref` dereferencer) don't recurse forever — a subtree that cycles back into an ancestor is deep-copied with its cycle preserved instead of converted. Only pathologically deep nesting (thousands of levels) can still exhaust the call stack.
- **Never mutates**: the input document is left untouched.
- **Extension-preserving, never extension-inventing**: existing `x-` keys and unknown keys always survive, while constructs the target version cannot express are converted where an equivalent exists and removed otherwise.

## Usage

```ts
import {
downgradeSchemaV31ToV30,
downgradeSchemaV32ToV31,
downgradeSpecV31ToV30,
downgradeSpecV32ToV31,
} from "@oasty/downgrader";

const v31 = downgradeSpecV32ToV31(v32Document);
const v30 = downgradeSpecV31ToV30(v31Document);

// There is intentionally no direct 3.2 → 3.0 converter; compose the steps:
const downgraded = downgradeSpecV31ToV30(downgradeSpecV32ToV31(v32Document));

// Schema Objects can be converted standalone:
const schema = downgradeSchemaV31ToV30({ type: ["string", "null"] });
// { type: "string", nullable: true }
```

## 3.2 → 3.1

Schema Objects pass through unchanged: the 3.2 Schema Object keyword set is identical to 3.1's (3.2 defines its own dialect URI, but only the OAS base vocabulary gained fields), and the 3.2-only fields (discriminator `defaultMapping`, XML `nodeType`) are deliberately retained. Two caveats: the standard OpenAPI 3.1 document schema tolerates them (Schema Object internals are open there), but the strict OAS 3.1 base-vocabulary meta-schema closes the XML and Discriminator Objects to their fixed fields plus `x-`, so a base-vocabulary validator will flag them; and 3.1 tooling will not act on them — in particular a `defaultMapping` fallback stops taking effect (`nodeType` is recovered on the 3.1 → 3.0 hop).

Converted:

| 3.2 construct | 3.1 result |
| --- | --- |
| `openapi: 3.2.x` | `openapi: 3.1.2` |
| `components.mediaTypes` and content-map `$ref`s to them | references inlined, the component map removed; content entries whose reference cannot be inlined (external, unknown, or cyclic targets) are removed, as 3.1 content maps cannot hold references — a parameter or header losing its entire `content` that way is removed with it (3.1 requires exactly one entry there) |
| Media type `itemSchema` without a sibling `schema` | `schema: { type: "array", items: … }` (the 3.2 sequential media type data model) |
| Response `summary` when no `description` exists | promoted to `description` (required in 3.1, so `""` is synthesized as a last resort) |
| Example `dataValue` / `serializedValue` when `value` and `externalValue` are absent | promoted to `value` (in that order) |
| Parameter `style: "cookie"` | removed, letting the 3.1 default `form` apply |

Removed (no 3.1 equivalent): `$self`, server `name`, tag `summary`/`parent`/`kind`, the `query` operation and `additionalOperations` of Path Items, `in: "querystring"` parameters (from parameter lists and `components.parameters`, together with references to the removed component entries, following chains of reference aliases), `allowReserved` on non-query parameters, media type `description`, media type / encoding `prefixEncoding`, `itemEncoding`, and nested `encoding`, a media type `itemSchema` beside an existing `schema`, response `summary` beside an existing `description`, OAuth `deviceAuthorization` flows, and security scheme `oauth2MetadataUrl` and `deprecated`.

Known limitations: security requirements using URI keys and `$self`-relative reference resolution are passed through unchanged.

## 3.1 → 3.0

Converted:

| 3.1 construct | 3.0 result |
| --- | --- |
| `openapi: 3.1.x` | `openapi: 3.0.4` |
| missing `paths` | `{}` (required in 3.0) |
| missing operation `responses` | `{ "default": { "description": "" } }` (required and non-empty in 3.0) |
| Reference `summary` / `description` overrides | removed (3.0 references stand alone) |
| Security requirement roles on non-OAuth schemes | emptied (`[]`) |

Removed (no 3.0 equivalent): `webhooks`, `components.pathItems` (local `$ref`s pointing at it are left untouched and will dangle), `jsonSchemaDialect`, `info.summary`, `license.identifier`, and `mutualTLS` security schemes (reference aliases to them included) — their names are stripped from every security requirement, requirements that referenced only such schemes are removed, and a `security` list emptied that way is removed entirely, since an explicit empty list means "no security required" and would make an operation public.

Schema Objects:

| 3.1 construct | 3.0 result |
| --- | --- |
| `true` / `false` boolean schemas | `{}` / `{ not: {} }` |
| `$ref` with sibling keywords | siblings kept, `$ref` wrapped into `allOf` |
| `type: ["T", "null"]` | `type: "T"` plus `nullable: true` |
| `enum: []` / duplicate `required` entries | `enum` removed / `required` deduplicated (3.0 requires a non-empty `enum` and unique `required`) |
| `type: "null"` | `nullable: true` plus `enum: [null]`; a sibling `enum`/`const` is intersected with the null type — an `enum` containing `null` collapses to `[null]`, and a sibling excluding `null` yields a match-nothing schema (`not: {}`), since the source accepted no value |
| `type` with several non-null entries | `anyOf` of single-type schemas |
| `const` | single-value `enum` |
| numeric `exclusiveMinimum` / `exclusiveMaximum` | bound plus boolean flag (the tighter bound wins) |
| `examples` | first entry becomes `example` when none exists |
| `contentEncoding: base64` | `format: byte` |
| `contentMediaType: application/octet-stream` | `format: binary` |
| `type: "array"` without `items` | `items: {}` is added (required in 3.0) |
| XML `nodeType` (carried over from a 3.2 chain) | `attribute: true` / `wrapped: true` where expressible, then removed (3.0 forbids unknown XML Object fields) |
| `$schema`, `$id`, `$defs`, `$anchor`, `$dynamicRef`/`$dynamicAnchor`, `$vocabulary`, `$comment`, `if`/`then`/`else`, `dependentSchemas`/`dependentRequired`, `prefixItems` (and its trailing `items`), `contains`/`minContains`/`maxContains`, `patternProperties` (and its sibling `additionalProperties`, whose meaning would otherwise tighten onto the pattern-matched keys), `propertyNames`, `unevaluatedItems`/`unevaluatedProperties`, `contentSchema` | removed — in positive schema positions dropping these only loosens validation, the safe direction for a downgrade |

Known limitations: `$ref`s that point into dropped keywords (`#/…/$defs/…` pointers, `$anchor` targets, `$id`-based bases) will dangle — hoist reusable subschemas into `components.schemas` before downgrading. Arbitrary non-standard schema keywords are preserved per the extension-preserving contract, even though the official 3.0 schema forbids unknown Schema Object fields. Dropping keywords inside `not` (where loosening the operand tightens the whole) or inside `oneOf` branches (where loosening one branch can break exclusivity) can shift what validates.

## Sponsors

Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going through [GitHub Sponsors](https://github.com/sponsors/dinwwwh) or [Open Collective](https://opencollective.com/middleapi). Every bit helps! 🚀
68 changes: 68 additions & 0 deletions packages/downgrader/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@oasty/downgrader",
"version": "0.0.0",
"description": "Downgrade OpenAPI specifications one minor version at a time: 3.2 to 3.1 and 3.1 to 3.0, for whole documents or individual schemas",
"keywords": [
"converter",
"downgrade",
"oas",
"oasty",
"openapi",
"openapi-3.0",
"openapi-3.1",
"openapi-3.2",
"specification",
"swagger",
"typescript"
],
"homepage": "https://github.com/middleapi/oasty",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/middleapi/oasty.git",
"directory": "packages/downgrader"
},
"funding": [
"https://github.com/sponsors/dinwwwh",
"https://opencollective.com/middleapi"
],
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts",
"./v3.2-to-v3.1": "./src/v3.2-to-v3.1.ts",
"./v3.1-to-v3.0": "./src/v3.1-to-v3.0.ts"
},
"publishConfig": {
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./v3.2-to-v3.1": {
"types": "./dist/v3.2-to-v3.1.d.mts",
"import": "./dist/v3.2-to-v3.1.mjs",
"default": "./dist/v3.2-to-v3.1.mjs"
},
"./v3.1-to-v3.0": {
"types": "./dist/v3.1-to-v3.0.d.mts",
"import": "./dist/v3.1-to-v3.0.mjs",
"default": "./dist/v3.1-to-v3.0.mjs"
}
}
},
"scripts": {
"build": "unbuild",
"prepack": "unbuild",
"type:check": "tsc -b"
},
"dependencies": {
"@oasty/types": "workspace:^"
}
}
2 changes: 2 additions & 0 deletions packages/downgrader/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { downgradeSchemaV31ToV30, downgradeSpecV31ToV30 } from "./v3.1-to-v3.0";
export { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from "./v3.2-to-v3.1";
Loading