diff --git a/README.md b/README.md index b3e8e8a..5858709 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ -TypeScript tooling for the [OpenAPI Specification](https://spec.openapis.org/), maintained by [middleapi](https://github.com/middleapi). It lets you work with OpenAPI 3.0, 3.1, and 3.2 documents from one place: precise types for each version, and converters that move a document from a newer version to an older one without losing anything the older version can still express. +TypeScript tooling for the [OpenAPI Specification](https://spec.openapis.org/), maintained by [middleapi](https://github.com/middleapi). It lets you work with OpenAPI 3.0, 3.1, and 3.2 documents from one place: precise types for each version, and small, fast converters that move a document from a newer version to an older one, covering what API frameworks such as [oRPC](https://orpc.dev) generate. | Package | Description | | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 4be1214..863ae38 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -21,9 +21,9 @@ -`@openapi-spec/downgrader` downgrades [OpenAPI Specification](https://spec.openapis.org/) documents and Schema Objects one minor version at a time, 3.2 to 3.1 and 3.1 to 3.0, for tools that only support an older version, such as code generators, gateways, and validators. +`@openapi-spec/downgrader` downgrades [OpenAPI Specification](https://spec.openapis.org/) documents and Schema Objects one minor version at a time, 3.2 to 3.1 and 3.1 to 3.0, for tools that only support an older version, such as code generators, gateways, and validators. It is how [oRPC](https://orpc.dev) generates 3.1 and 3.0 documents. -Downgrading loses detail, never meaning. Anything the older version lacks becomes an equivalent or, failing that, is removed, so a downgraded schema accepts every value the original accepts, and possibly more. Each downgrader below lists what it removes and its limitations. +Downgrading loses detail, never meaning. Each step below lists what it converts and its limitations. ## Usage @@ -55,7 +55,9 @@ downgradeSchemaV31ToV30({ type: ['string', 'null'] }) Each step can also be imported on its own from `@openapi-spec/downgrader/v3.2-to-v3.1` or `@openapi-spec/downgrader/v3.1-to-v3.0`. All types come from [`@openapi-spec/types`](https://github.com/middleapi/openapi-spec/blob/main/packages/types/README.md). -Input is read as JSON would see it: a key holding `undefined`, as spreading options often leaves, counts as missing and never reaches the output. +The input is never changed, and the output shares no plain objects or arrays with it. Input is read as JSON would see it: an object counts whatever realm it comes from or null prototype it has, as in a document oRPC returns, and a key holding `undefined`, as spreading options often leaves, counts as missing. Other values, such as a `Date`, are kept as they are. + +External `$ref`s are left as written, except in a 3.2 `content` map, where 3.1 allows none and the entry is removed. The files they point at are not converted, so bundle a multi-file document into one first. ## 3.2 → 3.1 @@ -63,33 +65,28 @@ Input is read as JSON would see it: a key holding `undefined`, as spreading opti Schema Objects inside the document convert as in [Schema](#schema-downgradeschemav32tov31). -#### Removed - -| API | Why | -| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`$self`][3.2-self] | Sets the document's own URI and base URI. 3.1 has no such field and resolves against the retrieval URL. | -| [`components.mediaTypes`][3.2-components-media-types] | 3.1 has no reusable Media Type Objects. `$ref`s to them are inlined. | -| Server [`name`][3.2-server-name] | A unique name for the server. A 3.1 Server has only `url`, `description`, and `variables`. | -| Tag [`summary`][3.2-tag-summary], [`parent`][3.2-tag-parent], and [`kind`][3.2-tag-kind] | Display title, nesting, and category. A 3.1 Tag has only `name`, `description`, and `externalDocs`. | -| Path Item [`query`][3.2-path-item-query] | The HTTP QUERY method. A 3.1 Path Item allows only eight fixed methods, and `post` would wrongly claim an unsafe, non-idempotent request. | -| Path Item [`additionalOperations`][3.2-path-item-additional-operations] | Other HTTP methods, such as `PURGE`, which 3.1 cannot describe either. | -| [`in: "querystring"`][3.2-parameter-locations] parameters and `$ref`s to them | Describes the whole query string as one media-typed value. A 3.1 `in: query` parameter describes one named key. | -| [`allowReserved`][3.2-parameter-allow-reserved] outside `in: query` | 3.1 applies it only to query parameters, so other values are percent-encoded. | -| [`style: "cookie"`][3.2-style-values] | Cookie syntax from RFC 6265, without percent-encoding. 3.1 cookie parameters use `form`, the default left in place. | -| Parameter and header [`example`][3.2-parameter-example] / [`examples`][3.2-parameter-examples] beside `content` | 3.1 allows them only with `schema`. Media types inside `content` keep their own examples. | -| A [`content`][3.2-parameter-content] `$ref` that is external, missing, or looping | 3.1 `content` maps cannot hold a `$ref`, and only local targets can be inlined. A parameter or header left without `content` is removed too, because it needs exactly one entry. | -| Media type [`description`][3.2-schema] | 3.1 Media Type Objects have no description. The field comes from the official 3.2 JSON Schema, not the spec text. | -| [`itemSchema`][3.2-media-type-item-schema] beside `schema` | Validates each item of a stream, such as JSON Lines or server-sent events, which 3.1 cannot express. Alone, it becomes `schema: { type: "array", items: … }`. Beside `schema`, which already describes the whole content, it is dropped. | -| [`prefixEncoding`][3.2-media-type-prefix-encoding] and [`itemEncoding`][3.2-media-type-item-encoding] | Encode multipart parts by position. 3.1 `encoding` only matches parts by property name. | -| Encoding Object [`encoding`][3.2-encoding-encoding], [`prefixEncoding`][3.2-encoding-prefix-encoding], and [`itemEncoding`][3.2-encoding-item-encoding] | Encode the parts of a nested multipart part. 3.1 Encoding Objects cannot nest. | -| Response [`summary`][3.2-response-summary] beside `description` | 3.1 has no `summary` and requires `description`. A summary without a description becomes the description. | -| Example [`dataValue`][3.2-example-data-value] and [`serializedValue`][3.2-example-serialized-value] | 3.1 has only `value`. When `value` and `externalValue` are missing, `dataValue`, or else `serializedValue`, fills it. | -| OAuth [`deviceAuthorization`][3.2-oauth-flows-device-authorization] flow | The device flow from RFC 8628. 3.1 knows only `implicit`, `password`, `clientCredentials`, and `authorizationCode`, so a scheme with only this flow is left with `flows: {}`. | -| Security scheme [`oauth2MetadataUrl`][3.2-security-scheme-oauth2-metadata-url] | The authorization server metadata URL from RFC 8414. 3.1 has no such field. | -| Security scheme [`deprecated`][3.2-security-scheme-deprecated] | 3.1 has no such field. | -| Links ([`operationRef`][3.2-link-operation-ref]) and discriminator [`mapping`][3.2-discriminator-mapping] entries that point into a removed part | They would point at nothing. | +| 3.2 | In 3.1 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [`$self`][3.2-self] | Removed. 3.1 resolves against the retrieval URL. | +| [`jsonSchemaDialect`][3.2-json-schema-dialect] naming the 3.2 dialect | The 3.1 dialect. | +| [`components.mediaTypes`][3.2-components-media-types] | Removed. `$ref`s to them in `content` are replaced by their targets. | +| Server [`name`][3.2-server-name] | Removed. | +| Tag [`summary`][3.2-tag-summary], [`parent`][3.2-tag-parent], and [`kind`][3.2-tag-kind] | Removed. A summary becomes the `description` when that is missing. | +| Path Item [`query`][3.2-path-item-query] and [`additionalOperations`][3.2-path-item-additional-operations] | Removed. 3.1 has only the eight fixed methods. | +| [`in: "querystring"`][3.2-parameter-locations] parameters | Removed, with the `$ref`s to them. | +| [`allowReserved`][3.2-parameter-allow-reserved] outside `in: "query"` | Removed. 3.1 applies it only to query parameters. | +| [`style: "cookie"`][3.2-style-values] | Removed, leaving the `form` default. | +| Parameter [`example`][3.2-parameter-example], [`examples`][3.2-parameter-examples], `style`, `explode`, and `allowReserved` beside `content` | Removed. 3.1 allows them only beside `schema`. The media types keep their own examples. | +| Media Type [`description`][3.2-schema] | Removed. | +| Media Type [`itemSchema`][3.2-media-type-item-schema] | Alone, `schema: { type: "array", items: … }`. Beside `schema`, which describes the whole body, removed. | +| Media Type [`prefixEncoding`][3.2-media-type-prefix-encoding] and [`itemEncoding`][3.2-media-type-item-encoding], and Encoding Object [`encoding`][3.2-encoding-encoding], `prefixEncoding`, and `itemEncoding` | Removed. 3.1 matches multipart parts only by property name, and does not nest encodings. | +| Response [`summary`][3.2-response-summary] | Removed. It becomes the `description` that 3.1 requires when that is missing. | +| Example [`dataValue`][3.2-example-data-value] and [`serializedValue`][3.2-example-serialized-value] | Removed. Without `value` or `externalValue`, `dataValue`, or else `serializedValue`, fills `value`. | +| OAuth [`deviceAuthorization`][3.2-oauth-flows-device-authorization] flow | Removed. | +| Security Scheme [`oauth2MetadataUrl`][3.2-security-scheme-oauth2-metadata-url] and [`deprecated`][3.2-security-scheme-deprecated] | Removed. | [3.2-self]: https://spec.openapis.org/oas/v3.2.0.html#oas-self +[3.2-json-schema-dialect]: https://spec.openapis.org/oas/v3.2.0.html#oas-json-schema-dialect [3.2-components-media-types]: https://spec.openapis.org/oas/v3.2.0.html#components-media-types [3.2-server-name]: https://spec.openapis.org/oas/v3.2.0.html#server-name [3.2-tag-summary]: https://spec.openapis.org/oas/v3.2.0.html#tag-summary @@ -102,40 +99,32 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem [3.2-style-values]: https://spec.openapis.org/oas/v3.2.0.html#style-values [3.2-parameter-example]: https://spec.openapis.org/oas/v3.2.0.html#parameter-example [3.2-parameter-examples]: https://spec.openapis.org/oas/v3.2.0.html#parameter-examples -[3.2-parameter-content]: https://spec.openapis.org/oas/v3.2.0.html#parameter-content [3.2-schema]: https://spec.openapis.org/oas/3.2/schema/2025-09-17 [3.2-media-type-item-schema]: https://spec.openapis.org/oas/v3.2.0.html#media-type-item-schema [3.2-media-type-prefix-encoding]: https://spec.openapis.org/oas/v3.2.0.html#media-type-prefix-encoding [3.2-media-type-item-encoding]: https://spec.openapis.org/oas/v3.2.0.html#media-type-item-encoding [3.2-encoding-encoding]: https://spec.openapis.org/oas/v3.2.0.html#encoding-encoding -[3.2-encoding-prefix-encoding]: https://spec.openapis.org/oas/v3.2.0.html#encoding-prefix-encoding -[3.2-encoding-item-encoding]: https://spec.openapis.org/oas/v3.2.0.html#encoding-item-encoding [3.2-response-summary]: https://spec.openapis.org/oas/v3.2.0.html#response-summary [3.2-example-data-value]: https://spec.openapis.org/oas/v3.2.0.html#example-data-value [3.2-example-serialized-value]: https://spec.openapis.org/oas/v3.2.0.html#example-serialized-value [3.2-oauth-flows-device-authorization]: https://spec.openapis.org/oas/v3.2.0.html#oauth-flows-device-authorization [3.2-security-scheme-oauth2-metadata-url]: https://spec.openapis.org/oas/v3.2.0.html#security-scheme-oauth2-metadata-url [3.2-security-scheme-deprecated]: https://spec.openapis.org/oas/v3.2.0.html#security-scheme-deprecated -[3.2-link-operation-ref]: https://spec.openapis.org/oas/v3.2.0.html#link-operation-ref -[3.2-discriminator-mapping]: https://spec.openapis.org/oas/v3.2.0.html#discriminator-mapping #### Limitations +- Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. +- A `content` `$ref` that does not resolve is removed, which can leave a parameter or header without the one entry it needs. - Security requirements that name a scheme by URI, and `$self`-relative references, pass through unchanged. -- A Link that names a removed operation (`query` or `additionalOperations`) by `operationId` is kept. -- A `$ref` whose target is removed or moved, such as a `components.mediaTypes` entry or a parameter after a removed one, is replaced by its converted target without the Reference Object's own `summary` and `description`. A Path Item referenced from several places then repeats its `operationId`s, and a target that refers back to itself loses that inner reference. In a dense cycle of targets that refer to one another, where losing only those would take more than a few times the work of the rest of the conversion, copies are shared instead, so an inner reference can also be lost where it does not refer back to an enclosing target. -- `$ref`s that are external, use an `$anchor`, loop, already dangle, or point at a value other than an object or boolean schema are left as written, so they can dangle. ### Schema (`downgradeSchemaV32ToV31`) -Accepts the default OAS dialect. It builds on JSON Schema 2020-12 in both versions, so everything not listed below passes through. Every schema is read as 2020-12, whatever `$schema` or `jsonSchemaDialect` names. - -#### Removed +Both versions build on JSON Schema 2020-12, so everything not listed here passes through. -| API | Why | -| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| XML [`nodeType`][3.2-xml-node-type] | `"attribute"` becomes `attribute: true`, and `"element"` on an array becomes `wrapped: true`. `"text"` and `"cdata"` have no 3.1 equivalent. | -| Discriminator [`defaultMapping`][3.2-discriminator-default-mapping] | Picks the schema when the discriminating property is missing or unmapped. 3.1 has no such field. | +| 3.2 | In 3.1 | +| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| XML [`nodeType`][3.2-xml-node-type] | `"attribute"` becomes `attribute: true`, and `"element"` on an array becomes `wrapped: true`. Other values are removed. | +| Discriminator [`defaultMapping`][3.2-discriminator-default-mapping] | Removed. | [3.2-xml-node-type]: https://spec.openapis.org/oas/v3.2.0.html#xml-node-type [3.2-discriminator-default-mapping]: https://spec.openapis.org/oas/v3.2.0.html#discriminator-default-mapping @@ -143,9 +132,7 @@ Accepts the default OAS dialect. It builds on JSON Schema 2020-12 in both versio #### Limitations - A `$schema` naming the 3.2 dialect passes through unchanged. -- Where a schema inlined for a `$ref` whose target is removed or moved refers back to itself, the inner reference becomes `{}`, so an enclosing `not`, `oneOf`, `if`, or `unevaluated*` can reject values the original accepts. In a dense cycle of such schemas, an inner reference can also become `{}` where it does not refer back to an enclosing schema, as for Path Items above. -- Only the first copy of a schema inlined in several places keeps its `$id`, `$anchor`, and `$dynamicAnchor`, so each identifier stays unique. In the other copies, JSON Pointer `$ref`s that resolved against that `$id` are replaced by their converted targets, such `mapping` values are dropped, and other relative `$ref`s, such as one to an `$anchor`, resolve against the enclosing base instead. -- Older drafts are not supported. Subschemas under keywords that only draft-07 or 2019-09 define, such as `definitions`, pass through unconverted. Convert such schemas to 2020-12 first. +- Older drafts are not supported. Subschemas under keywords that only draft-07 or 2019-09 define, such as `definitions`, pass through unconverted. ## 3.1 → 3.0 @@ -153,78 +140,81 @@ Accepts the default OAS dialect. It builds on JSON Schema 2020-12 in both versio Schema Objects inside the document convert as in [Schema](#schema-downgradeschemav31tov30). -#### Removed - -| API | Why | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`jsonSchemaDialect`][3.1-json-schema-dialect] | 3.0 has one fixed schema dialect. | -| [`webhooks`][3.1-webhooks] | 3.0 only describes callbacks tied to an operation. `$ref`s to a webhook are inlined. | -| [`components.pathItems`][3.1-components-path-items] | 3.0 Components hold no Path Items. `$ref`s to them are inlined. | -| Info [`summary`][3.1-info-summary] | 3.0 has no such field. | -| License [`identifier`][3.1-license-identifier] | An SPDX expression. A 3.0 License has only `name` and `url`. | -| Reference Object [`summary`][3.1-reference-summary], [`description`][3.1-reference-description], and extensions | A 3.0 Reference Object holds only `$ref`. | -| Encoding Object [`style`][3.1-encoding-style], [`explode`][3.1-encoding-explode], and [`allowReserved`][3.1-encoding-allow-reserved] in `multipart` request bodies | 3.1 serializes a `multipart/form-data` part that sets them RFC6570-style, such as an exploded object as one part per property. 3.0 ignores them outside `application/x-www-form-urlencoded`, so the part is sent by its `contentType` or schema default instead. | -| [`mutualTLS`][3.1-security-scheme-type] security schemes, `$ref`s to them, and their names in security requirements | 3.0 has no mutual TLS scheme. An emptied requirement or `security` list is removed too, because an empty one would mean no security. An operation then falls back to the root `security`. | -| [Security requirement][3.1-security-requirement-object] scopes on `apiKey` and `http` schemes | 3.1 lets them list required roles. 3.0 requires an empty list for schemes other than OAuth2 and OpenID Connect. | -| Links ([`operationRef`][3.1-link-operation-ref]) and discriminator [`mapping`][3.1-discriminator-mapping] entries that point into a removed part | They would point at nothing. | +| 3.1 | In 3.0 | +| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`jsonSchemaDialect`][3.1-json-schema-dialect] | Removed. 3.0 has one fixed schema dialect. | +| [`webhooks`][3.1-webhooks] | Removed. `$ref`s into them are replaced by their targets. | +| [`components.pathItems`][3.1-components-path-items] | Removed. A Path Item `$ref` to one is merged: the target's fields fill in those the referencing Path Item lacks. Other `$ref`s into them are replaced by their targets. | +| A missing [`paths`][3.1-paths] or Operation [`responses`][3.1-operation-responses] | Added, as `{}` and as a `default` response, since 3.0 requires them. | +| Info [`summary`][3.1-info-summary] and License [`identifier`][3.1-license-identifier] | Removed. An Info summary becomes the `description` when that is missing. | +| Reference Object [`summary`][3.1-reference-summary] and [`description`][3.1-reference-description] | Removed. A 3.0 Reference Object holds only `$ref`. | +| [`mutualTLS`][3.1-security-scheme-type] security schemes | Removed, with the `$ref`s to them and their names in security requirements. A requirement or `security` list left empty is removed too, since an empty one means no security. | +| [Security requirement][3.1-security-requirement] scopes on `apiKey` and `http` schemes | `[]`. 3.0 allows scopes only for OAuth2 and OpenID Connect. | [3.1-json-schema-dialect]: https://spec.openapis.org/oas/v3.1.2.html#oas-json-schema-dialect [3.1-webhooks]: https://spec.openapis.org/oas/v3.1.2.html#oas-webhooks [3.1-components-path-items]: https://spec.openapis.org/oas/v3.1.2.html#components-path-items +[3.1-paths]: https://spec.openapis.org/oas/v3.1.2.html#oas-paths +[3.1-operation-responses]: https://spec.openapis.org/oas/v3.1.2.html#operation-responses [3.1-info-summary]: https://spec.openapis.org/oas/v3.1.2.html#info-summary [3.1-license-identifier]: https://spec.openapis.org/oas/v3.1.2.html#license-identifier [3.1-reference-summary]: https://spec.openapis.org/oas/v3.1.2.html#reference-summary [3.1-reference-description]: https://spec.openapis.org/oas/v3.1.2.html#reference-description -[3.1-encoding-style]: https://spec.openapis.org/oas/v3.1.2.html#encoding-style -[3.1-encoding-explode]: https://spec.openapis.org/oas/v3.1.2.html#encoding-explode -[3.1-encoding-allow-reserved]: https://spec.openapis.org/oas/v3.1.2.html#encoding-allow-reserved [3.1-security-scheme-type]: https://spec.openapis.org/oas/v3.1.2.html#security-scheme-type -[3.1-security-requirement-object]: https://spec.openapis.org/oas/v3.1.2.html#security-requirement-object -[3.1-link-operation-ref]: https://spec.openapis.org/oas/v3.1.2.html#link-operation-ref -[3.1-discriminator-mapping]: https://spec.openapis.org/oas/v3.1.2.html#discriminator-mapping +[3.1-security-requirement]: https://spec.openapis.org/oas/v3.1.2.html#security-requirement-object #### Limitations -- A Link that names a webhook operation by `operationId` is kept. -- A `$ref` whose target is removed, such as a webhook or a `components.pathItems` entry, is replaced by its converted target. A Path Item referenced from several places then repeats its `operationId`s, and a target that refers back to itself loses that inner reference. In a dense cycle of targets that refer to one another, where losing only those would take more than a few times the work of the rest of the conversion, copies are shared instead, so an inner reference can also be lost where it does not refer back to an enclosing target. -- `$ref`s that are external, use an `$anchor`, loop, already dangle, or point at a value other than an object or boolean schema are left as written, so they can dangle. +- A target replacing several `$ref`s stands in at each of them as the same object, so a Path Item referenced twice repeats its `operationId`s, and a YAML dump writes an alias. Where a target refers back to one it is being copied into, that inner reference becomes `{}`, or is removed where `{}` is not a valid value. +- `$ref`s into removed parts are recognized by their `#/webhooks/` or `#/components/pathItems/` prefix as written, not percent-encoded. +- Links and discriminator `mapping` values that point into a removed part, and Links that name a webhook operation by `operationId`, are left as written. +- Encoding Objects pass through, although 3.0 serializes `multipart` parts by other defaults: it ignores `style`, `explode`, and `allowReserved` there, and gives an untyped part no `application/octet-stream` default. +- An operation whose only `security` alternatives use mutual TLS loses its `security`, so it falls back to the document's. ### Schema (`downgradeSchemaV31ToV30`) Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDocs`, and `example` to JSON Schema 2020-12. Every schema is read as 2020-12, whatever `$schema` or `jsonSchemaDialect` names. -A schema is _loosened_ when the conversion removes a restriction from it or a subschema, or when it contains an object cycle, as in a dereferenced document. - -#### Removed - -| API | Why | -| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`$schema`][js-schema] and [`$vocabulary`][js-vocabulary] | 3.0 has one fixed dialect. | -| [`$id`][js-id] and [`$anchor`][js-anchor] | 3.0 identifies schemas only by location. JSON Pointer `$ref`s and `mapping` values inside a schema with an `$id` resolve against it, and are rewritten from the root. Others stay as written. | -| [`$defs`][js-defs] | 3.0 has no local definitions. Each `$ref` into `$defs` is replaced by its converted target, and a reference back into a target being inlined becomes `{}`, so recursion stops after one level. | -| [`$dynamicRef` and `$dynamicAnchor`][js-dynamic] | 3.0 has no dynamic references. | -| [`$comment`][js-comment] and [`contentSchema`][js-content-schema] | Annotations with no 3.0 equivalent. | -| [`if`][js-if], [`then`][js-then], and [`else`][js-else] | 3.0 has no conditionals. | -| [`dependentSchemas`][js-dependent-schemas] and [`dependentRequired`][js-dependent-required] | 3.0 has no dependencies. | -| [`prefixItems`][js-prefix-items] and its `items` | 3.0 `items` applies one schema to every item, so tuples become plain arrays. | -| [`contains`][js-contains], [`minContains`][js-min-contains], and [`maxContains`][js-max-contains] | 3.0 has no equivalent. | -| [`patternProperties`][js-pattern-properties] and its `additionalProperties` | 3.0 has no equivalent. `additionalProperties` goes too, because it would reject properties that `patternProperties` allowed. | -| [`propertyNames`][js-property-names] | 3.0 has no equivalent. | -| [`unevaluatedItems`][js-unevaluated-items] and [`unevaluatedProperties`][js-unevaluated-properties] | 3.0 has no equivalent. | -| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | 3.0 marks binary strings with `format` instead: `base64` becomes `format: byte`, and a media type without an encoding becomes `format: binary`. Anything else is lost. | -| [`examples`][js-examples] | 3.0 has a single `example`. The first entry fills it when missing, and the rest are dropped. | -| [`readOnly` and `writeOnly`][js-read-only-write-only] when both are `true` | 3.0 forbids marking a property with both. Dropping these annotations loses detail, not validation. Keeping one would misstate the intent and, in 3.0, apply `required` one way only. | -| Empty [`enum`][js-enum] | 3.0 requires at least one value. An empty `enum` rejects everything, so dropping it only loosens the schema. | -| [`not`][js-not] over a loosened schema | Negating a looser schema would reject values the original accepts. | -| The exclusivity of [`oneOf`][js-one-of] with a loosened branch | Looser branches may overlap, so "exactly one" could reject values the original accepts. It becomes `anyOf`. | -| [`nullable`][3.0-schema-nullable], a 3.0 keyword | 3.1 ignores it, but in 3.0 it admits null, so keeping it would accept null where the original rejects it. Only `"null"` in `type` becomes `nullable: true`. | -| XML [`nodeType`][3.2-xml-node-type], a 3.2 field | As in 3.2 → 3.1, kept only as `attribute: true` or `wrapped: true`. | - +| 3.1 | In 3.0 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `enum: [null]`, since 3.0 allows `nullable` only beside `type`. | +| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. | +| [`const`][js-const] | `enum` with its one value. | +| Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | +| [`examples`][js-examples] | The first entry fills `example` when that is missing. | +| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none. `items: false` becomes `maxItems`. | +| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. Without a `type`, the schema gains `type: "string"`. | +| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | +| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | +| Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | +| [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | +| [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | +| [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | +| [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | +| [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | +| [`unevaluatedItems`][js-unevaluated-items] | Removed. | +| An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | +| An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | +| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | +| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. | +| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. | +| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. | + +[js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type +[js-const]: https://json-schema.org/draft/2020-12/json-schema-validation#name-const +[js-exclusive-minimum]: https://json-schema.org/draft/2020-12/json-schema-validation#name-exclusiveminimum +[js-exclusive-maximum]: https://json-schema.org/draft/2020-12/json-schema-validation#name-exclusivemaximum +[js-examples]: https://json-schema.org/draft/2020-12/json-schema-validation#name-examples +[js-prefix-items]: https://json-schema.org/draft/2020-12/json-schema-core#name-prefixitems +[js-content-encoding]: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentencoding +[js-content-media-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentmediatype +[js-ref]: https://json-schema.org/draft/2020-12/json-schema-core#name-direct-references-with-ref +[js-defs]: https://json-schema.org/draft/2020-12/json-schema-core#name-schema-re-use-with-defs +[js-pattern-properties]: https://json-schema.org/draft/2020-12/json-schema-core#name-patternproperties [js-schema]: https://json-schema.org/draft/2020-12/json-schema-core#name-the-schema-keyword [js-vocabulary]: https://json-schema.org/draft/2020-12/json-schema-core#name-the-vocabulary-keyword [js-id]: https://json-schema.org/draft/2020-12/json-schema-core#name-the-id-keyword [js-anchor]: https://json-schema.org/draft/2020-12/json-schema-core#name-defining-location-independe -[js-defs]: https://json-schema.org/draft/2020-12/json-schema-core#name-schema-re-use-with-defs [js-dynamic]: https://json-schema.org/draft/2020-12/json-schema-core#name-dynamic-references-with-dyn [js-comment]: https://json-schema.org/draft/2020-12/json-schema-core#name-comments-with-comment [js-content-schema]: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentschema @@ -233,18 +223,12 @@ A schema is _loosened_ when the conversion removes a restriction from it or a su [js-else]: https://json-schema.org/draft/2020-12/json-schema-core#name-else [js-dependent-schemas]: https://json-schema.org/draft/2020-12/json-schema-core#name-dependentschemas [js-dependent-required]: https://json-schema.org/draft/2020-12/json-schema-validation#name-dependentrequired -[js-prefix-items]: https://json-schema.org/draft/2020-12/json-schema-core#name-prefixitems [js-contains]: https://json-schema.org/draft/2020-12/json-schema-core#name-contains [js-min-contains]: https://json-schema.org/draft/2020-12/json-schema-validation#name-mincontains [js-max-contains]: https://json-schema.org/draft/2020-12/json-schema-validation#name-maxcontains -[js-pattern-properties]: https://json-schema.org/draft/2020-12/json-schema-core#name-patternproperties [js-property-names]: https://json-schema.org/draft/2020-12/json-schema-core#name-propertynames [js-unevaluated-items]: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluateditems [js-unevaluated-properties]: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties -[js-content-encoding]: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentencoding -[js-content-media-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentmediatype -[js-examples]: https://json-schema.org/draft/2020-12/json-schema-validation#name-examples -[js-read-only-write-only]: https://json-schema.org/draft/2020-12/json-schema-validation#name-readonly-and-writeonly [js-enum]: https://json-schema.org/draft/2020-12/json-schema-validation#name-enum [js-not]: https://json-schema.org/draft/2020-12/json-schema-core#name-not [js-one-of]: https://json-schema.org/draft/2020-12/json-schema-core#name-oneof @@ -252,17 +236,17 @@ A schema is _loosened_ when the conversion removes a restriction from it or a su #### Limitations -- Older drafts are not supported. Keywords that only draft-07 or 2019-09 define, such as `definitions`, `dependencies`, array-form `items`, `additionalItems`, and `$recursiveRef`, pass through unconverted. Siblings of a `$ref` apply, although draft-07 ignores them. Convert such schemas to 2020-12 first. -- `$ref`s to an `$anchor`, or to a URI resolved against an `$id` base, are left as written and dangle. Rewrite them as JSON Pointers first. -- A `not` or `oneOf` that reaches a loosened schema through a `$ref` kept in the output can reject values the original accepts. -- A schema without `type` that has `contentEncoding: base64`, or `contentMediaType` without `contentEncoding`, gains `type: string`, so non-string values the original accepts are rejected. -- Non-standard keywords are kept, although the official 3.0 schema forbids them. +- A `not` or `oneOf` that reaches a schema that lost a restriction through a `$ref` kept in the output, such as one to `components.schemas`, or around a cycle of objects, can still reject values the original accepts. +- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. +- Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. +- Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, and array-form `items` passes through. Convert such schemas to 2020-12 first. +- `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. ## Performance -An object reached through many `$ref`s or shared references is converted once and reused, so the work grows with the size of the document, not with the number of paths through its references, even where they form cycles. +Each object is converted once, however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. -[Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. +[Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. ## Sponsors diff --git a/packages/downgrader/benches/__shared__/api.ts b/packages/downgrader/benches/__shared__/api.ts index 22c41e2..91f38f2 100644 --- a/packages/downgrader/benches/__shared__/api.ts +++ b/packages/downgrader/benches/__shared__/api.ts @@ -5,7 +5,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' // a few paths and a webhook per resource, around shared schemas, parameters, // responses, and security schemes. It uses what each step has to rewrite: // 3.1 schemas with `null` in `type`, `const`, numeric exclusive bounds, -// `examples`, `$defs`, and an `$id`; Path Items reused from +// `examples`, and `$defs`; Path Items reused from // `components.pathItems`; webhooks; mutual TLS; and, in 3.2 only, `$self`, // the `query` method, streamed `itemSchema`s, reusable Media Type Objects, // Server `name`, Tag `summary`, `parent`, and `kind`, Response `summary`, @@ -40,14 +40,13 @@ function sharedSchemas(): Record { default: 'active', }, Address: { - $id: 'https://api.example.com/schemas/address', type: 'object', required: ['line1', 'country'], properties: { line1: { type: 'string' }, line2: { type: ['string', 'null'] }, postalCode: { type: 'string', pattern: '^[0-9A-Z -]{3,10}$' }, - country: { $ref: '#/$defs/Country' }, + country: schemaRef('Address/$defs/Country'), }, $defs: { Country: { type: 'string', minLength: 2, maxLength: 2, examples: ['US'] }, diff --git a/packages/downgrader/benches/__shared__/graphs.ts b/packages/downgrader/benches/__shared__/graphs.ts index 2d4db28..0af314d 100644 --- a/packages/downgrader/benches/__shared__/graphs.ts +++ b/packages/downgrader/benches/__shared__/graphs.ts @@ -3,8 +3,7 @@ import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' // Small inputs that reach the same objects along exponentially many paths, // or around cycles. Converting each object once keeps the work linear, so a -// regression here costs orders of magnitude, not percent. The tests that -// count this work are the ones named "... once". +// regression here costs orders of magnitude, not percent. function info(): OpenAPIV3_2.InfoObject { return { title: 'Graph', version: '1.0.0' } diff --git a/packages/downgrader/benches/chained.bench.ts b/packages/downgrader/benches/chained.bench.ts index 831eb70..9ebb96b 100644 --- a/packages/downgrader/benches/chained.bench.ts +++ b/packages/downgrader/benches/chained.bench.ts @@ -1,11 +1,13 @@ // There is no direct 3.2 → 3.0 converter: the two steps compose (see the -// package README), and this is what that costs end to end. +// package README), and this is what that costs end to end, as oRPC pays it +// for every 3.0 document it generates. import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' import { bench, describe } from 'vitest' import { corpusV32 } from '../tests/corpus' +import { doc as orpcDocument } from '../tests/orpc-document' import { createApiV32 } from './__shared__/api' const API_100_RESOURCES = createApiV32(100) @@ -21,6 +23,10 @@ describe('downgradeSpecV32ToV31 + downgradeSpecV31ToV30', () => { } }) + bench('oRPC document', () => { + downgradeTwice(orpcDocument) + }) + bench('generated api, 100 resources', () => { downgradeTwice(API_100_RESOURCES) }) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 091a5d4..3cf2a0c 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -1,33 +1,15 @@ +/** Returned by a converter to leave the value out of its parent. */ export const DROP: unique symbol = Symbol('drop') -// The document root as a `$ref`, and the base outside any schema with an `$id`. -const ROOT = '#' - -const PLACEHOLDERS = new WeakSet() - export interface Context { - readonly resolve: (ref: string) => unknown - readonly locate: (ref: string) => Location - /** Where `resource`, a schema with an `$id` that starts a new resource, sits. */ - readonly findResource: (resource: object) => string | undefined - /** The resource the value being converted is in: `$ref`s written there resolve against it. */ - readonly base: string - /** The resource whose `$id` the output keeps around the value being converted, or the root. */ - readonly outputBase: string - readonly aliasEnd: (ref: string) => string | undefined - readonly dangles: (ref: string) => boolean - readonly isRemovedPart: (ref: string) => boolean - readonly markDangling: (ref: string) => void - readonly converting: unknown[] - readonly copies: Map - readonly exact: Exact - readonly identified: Set - readonly inlined: Map> + /** The document or schema being converted, which local `$ref`s resolve against. */ + readonly root: unknown + /** The target of each `$ref` resolved so far. */ + readonly targets: Map + /** The output of each object converted so far, per field table, so a shared object is converted once and a cycle ends. */ + readonly seen: Map>> + /** The targets of the `$ref`s being inlined, so a reference back into one stops there. */ readonly inlining: Set - readonly merged: Map> - readonly removals: Map - readonly seen: Map> - readonly trace: Trace } export type Convert = (value: unknown, ctx: Context) => unknown @@ -36,29 +18,27 @@ export type Field = (value: unknown, ctx: Context, parent: Record -export type Finish = (out: Record, source: Record, ctx: Context) => unknown +export type Finish = (out: Record, source: Record, ctx: Context) => void export const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const +/** + * Whether `value` is an object JSON would see as one: its prototype is null, + * or is an object with a null prototype and no keys of its own to inherit. + * That covers `Object.prototype` from any realm and empty null-prototype + * classes, such as the objects oRPC serializes to, but not a `Date`, a + * `Map`, or another class instance. + */ export function isRecord(value: unknown): value is Record { - if (typeof value !== 'object' || value === null) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false } const proto: unknown = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null + return proto === null || (Object.getPrototypeOf(proto) === null && Object.keys(proto as object).length === 0) } -/** - * Whether `object` has an own `key` whose value is not `undefined`. A key - * holding `undefined` disappears in JSON, and objects built in JavaScript - * often carry one, for example from spreading options, so the conversion - * treats it as missing: it skips such keys, and its output never holds one. - */ -export function has(object: Record, key: string): boolean { - return Object.hasOwn(object, key) && object[key] !== undefined -} - -export function setOwn(target: Record, key: string, value: unknown): void { +// Assigning `__proto__` would set the prototype instead of adding a key. +function setOwn(target: Record, key: string, value: unknown): void { if (key === '__proto__') { Object.defineProperty(target, key, { configurable: true, enumerable: true, value, writable: true }) } @@ -71,39 +51,49 @@ export function defineFields(table: Readonly return new Map(Object.entries(table)) } -export function clone(value: unknown, ctx?: Context): unknown { - return Array.isArray(value) || isRecord(value) ? copy(value, ctx?.copies ?? new Map()) : value +/** + * Deep copies plain objects and arrays, keeping any cycle among them. Keys + * holding `undefined` are left out, as JSON would, and other values, such as + * a `Date`, are kept as they are. + */ +export function clone(value: unknown): unknown { + return Array.isArray(value) || isRecord(value) ? copy(value, new Map()) : value } -function copy(value: unknown, seen: Map): unknown { +function copy(value: unknown, copies: Map): unknown { if (!(Array.isArray(value) || isRecord(value))) { return value } - const known = seen.get(value) + const known = copies.get(value) if (known !== undefined) { return known } if (Array.isArray(value)) { const out: unknown[] = [] - seen.set(value, out) + copies.set(value, out) for (const item of value) { - out.push(copy(item, seen)) + out.push(copy(item, copies)) } return out } const out: Record = {} - seen.set(value, out) + copies.set(value, out) for (const [key, item] of Object.entries(value)) { if (item !== undefined) { - setOwn(out, key, copy(item, seen)) + setOwn(out, key, copy(item, copies)) } } return out } +/** + * Converts the object `value` key by key: a key listed in `fields` goes + * through its converter or is dropped, and any other key is copied. `finish` + * then edits the output in place. + */ export function convertObject(value: unknown, ctx: Context, fields: Fields, finish?: Finish): unknown { if (!isRecord(value)) { - return clone(value, ctx) + return clone(value) } let seen = ctx.seen.get(fields) if (seen === undefined) { @@ -114,56 +104,41 @@ export function convertObject(value: unknown, ctx: Context, fields: Fields, fini if (known !== undefined) { return known } - if (ctx.converting.includes(value)) { - return cut(value, ctx) - } - work(ctx) - const again = hold(value, ctx) const out: Record = {} seen.set(value, out) - ctx.converting.push(value) for (const [key, item] of Object.entries(value)) { if (item === undefined) { continue } const field = fields.get(key) - const converted = field === undefined ? clone(item, ctx) : field === DROP ? DROP : field(item, ctx, value) + const converted = field === undefined ? clone(item) : field === DROP ? DROP : field(item, ctx, value) if (converted !== DROP) { setOwn(out, key, converted) } } - const result = finish === undefined ? out : finish(out, value, ctx) - ctx.converting.pop() - if (again) { - ctx.exact.again.pop() - } - if (result !== out) { - seen.set(value, result) - } - return result + finish?.(out, value, ctx) + return out } -export function map(convert: (value: unknown, ctx: Context, key: string) => unknown, isEntry: (key: string) => boolean = () => true): Convert { +/** Converts each entry of a map, or only those whose key passes `isEntry`, copying the rest. */ +export function map(convert: Convert, isEntry: (key: string) => boolean = () => true): Convert { return (value, ctx) => { if (!isRecord(value)) { - return clone(value, ctx) + return clone(value) } - const out: Record = {} + const out: [string, unknown][] = [] for (const [key, item] of Object.entries(value)) { - if (item === undefined) { - continue - } - const converted = isEntry(key) ? convert(item, ctx, key) : clone(item, ctx) + const converted = item === undefined ? DROP : isEntry(key) ? convert(item, ctx) : clone(item) if (converted !== DROP) { - setOwn(out, key, converted) + out.push([key, converted]) } } - return out + return Object.fromEntries(out) } } export function list(convert: Convert): Convert { - return (value, ctx) => Array.isArray(value) ? value.map(item => convert(item, ctx)).filter(item => item !== DROP) : clone(value, ctx) + return (value, ctx) => Array.isArray(value) ? value.map(item => convert(item, ctx)).filter(item => item !== DROP) : clone(value) } export function isPath(key: string): boolean { @@ -178,67 +153,11 @@ export function hasType(type: unknown, name: string): boolean { return type === name || (Array.isArray(type) && type.includes(name)) } -export function placeholder(): Record { - const out = {} - PLACEHOLDERS.add(out) - return out -} - -export function allOfItems(allOf: unknown): unknown[] { - if (Array.isArray(allOf)) { - return allOf - } - return allOf === undefined ? [] : [{ allOf }] -} - -export function convertXml(value: unknown, _ctx: Context, schema: Record): unknown { - if (!isRecord(value)) { - return clone(value) - } - const { nodeType, ...rest } = value - const out = clone(rest) as Record - if (nodeType === 'attribute') { - out.attribute = true - } - else if (nodeType === 'element' && hasType(schema.type, 'array')) { - out.wrapped = true - } - return out -} - export function getRef(value: unknown): string | undefined { return isRecord(value) && typeof value.$ref === 'string' ? value.$ref : undefined } -/** The `$ref` of `value` when no other key of it holds a value (see `has`). */ -export function getBareRef(value: unknown): string | undefined { - const ref = getRef(value) - const record = value as Record - return ref !== undefined && Object.keys(record).every(key => key === '$ref' || record[key] === undefined) ? ref : undefined -} - -export function child(value: unknown, token: string): unknown { - if (Array.isArray(value)) { - return /^(?:0|[1-9]\d*)$/.test(token) ? value[Number(token)] : undefined - } - return isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined -} - -// An `$id` sets a new base URI, unless it is empty or only a fragment, such -// as a draft-07 plain name (`#name`), which leaves the base as it is. -function isResource(value: unknown): value is Record { - return isRecord(value) && typeof value.$id === 'string' && value.$id !== '' && !value.$id.startsWith('#') -} - -// Escapes a JSON Pointer token (RFC 6901), then percent-encodes what a URI -// fragment cannot hold (RFC 3986). A lone surrogate has no encoding and -// stays as it is. -function encodeToken(token: string): string { - return token.replaceAll('~', '~0').replaceAll('/', '~1').replace(/[^\w!$&'()*+,.:;=?@~\p{Cs}-]/gu, encodeURIComponent) -} - -// The percent-decoded JSON Pointer in the fragment of `ref`, if it holds one. -function pointerOf(ref: string): string | undefined { +function find(root: unknown, ref: string): unknown { if (!ref.startsWith('#')) { return undefined } @@ -249,810 +168,60 @@ function pointerOf(ref: string): string | undefined { catch { return undefined } - return pointer === '' || pointer.startsWith('/') ? pointer : undefined -} - -function parsePointer(ref: string): string[] | undefined { - return pointerOf(ref)?.split('/').slice(1).map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) -} - -interface Location { - /** The base the `$ref`s in `target` resolve against: its own `$id`, or the nearest one around it. */ - readonly base: string - /** The `$ref` of `target`, rebased onto the root. */ - readonly next: string | undefined - readonly target: unknown -} - -function toPointer(tokens: readonly string[]): string { - return ROOT + tokens.map(token => `/${encodeToken(token)}`).join('') -} - -function locate(root: unknown, ref: string): Location { - const tokens = parsePointer(ref) - if (tokens === undefined) { - return { base: ROOT, next: undefined, target: undefined } + if (pointer !== '' && !pointer.startsWith('/')) { + return undefined } let target = root - let depth = 0 - for (const [index, token] of tokens.entries()) { - target = child(target, token) - if (isResource(target)) { - depth = index + 1 + for (const token of pointer.split('/').slice(1)) { + const key = token.replaceAll('~1', '/').replaceAll('~0', '~') + if (!(isRecord(target) || Array.isArray(target)) || !Object.hasOwn(target, key)) { + return undefined } + target = (target as Record)[key] } - const base = toPointer(tokens.slice(0, depth)) - return { base, next: rebasedRef(target, base), target } + return target } -interface Step { - readonly key: string - readonly parent: Step | undefined - readonly value: Record | unknown[] -} - -function keysTo(step: Step): string[] { - const keys: string[] = [] - for (let at = step; at.parent !== undefined; at = at.parent) { - keys.push(at.key) +/** The target of a local JSON Pointer `$ref`, such as `#/components/schemas/Pet`. */ +export function resolvePointer(ref: string, ctx: Context): unknown { + if (ctx.targets.has(ref)) { + return ctx.targets.get(ref) } - return keys.reverse() + const target = find(ctx.root, ref) + ctx.targets.set(ref, target) + return target } -// Finds where each schema resource sits, at its shortest pointer from the -// root. The walk is breadth-first and visits every object once, even where -// the document shares or cycles, so a pointer is final once found. It goes -// only as far as the resource asked for, and builds only the pointers of -// resources. -function resourceFinder(root: unknown): (resource: object) => string | undefined { - const resources = new Map() - const seen = new Set() - const queue: Step[] = [] - const visit = (value: unknown, key: string, parent: Step | undefined): void => { - if ((Array.isArray(value) || isRecord(value)) && !seen.has(value)) { - seen.add(value) - queue.push({ key, parent, value }) +/** Follows `ref`, and any `$ref` its target holds in turn, to the value they end at. */ +export function resolve(ref: string, ctx: Context): unknown { + const visited = new Set() + let target: unknown + for (let next: string | undefined = ref; next !== undefined; next = getRef(target)) { + if (visited.has(next)) { + return undefined } + visited.add(next) + target = resolvePointer(next, ctx) } - visit(root, '', undefined) - let walked = 0 - return (resource) => { - while (!resources.has(resource) && walked < queue.length) { - const step = queue[walked++] as Step - if (isResource(step.value)) { - resources.set(step.value, toPointer(keysTo(step))) - } - for (const [key, value] of Object.entries(step.value)) { - visit(value, key, step) - } - } - return resources.get(resource) - } -} - -// Rebases `ref`, written inside the resource at `base`, onto the root. Only a -// JSON Pointer can be rebased, so any other `$ref`, such as an external one or -// one to an `$anchor`, is returned as written. -function rebase(ref: string, base: string): string { - return base === ROOT || pointerOf(ref) === undefined ? ref : base + ref.slice(1) -} - -/** The `$ref` of `value`, rebased onto the root from `base`. */ -export function rebasedRef(value: unknown, base: string): string | undefined { - const ref = getRef(value) - return ref === undefined ? undefined : rebase(ref, base) -} - -/** Where `value` sits, when it is a schema with an `$id` that starts a new resource. */ -export function resourceOf(value: unknown, ctx: Context): string | undefined { - return isResource(value) ? ctx.findResource(value) : undefined + return target } /** - * The context to convert `schema` in. A schema with an `$id` starts a new - * resource, which the `$ref`s inside it resolve against. `keepsId` says - * whether its output keeps that `$id`, and with it everything inside the - * schema in place, so that those `$ref`s still resolve as written. + * Converts the target of `ref` to stand in for the reference. Returns `DROP` + * when the target is missing, or when it is already being inlined around + * this point, where inlining it again would never end. */ -export function enterSchema(schema: unknown, ctx: Context, keepsId: boolean): Context { - const base = resourceOf(schema, ctx) - return base === undefined ? ctx : { ...ctx, base, outputBase: keepsId ? base : ctx.outputBase } -} - -function cacheOf(caches: Map>, key: K): Map { - let cache = caches.get(key) - if (cache === undefined) { - cache = new Map() - caches.set(key, cache) - } - return cache -} - -function isInProgress(target: unknown, ctx: Context): boolean { - return ctx.inlining.has(target) || ctx.converting.includes(target) -} - -// Copying a target that is in progress, an enclosing object or inlined -// target, would never end, so a copy is cut there instead, and what a copy -// holds depends on what was in progress where it was made. Each copy traces -// the targets it cut at, the objects and targets it converted, and the cached -// copies it holds, and a cached copy is reused only where it comes out the -// same (see `isExact`). So a copy is cut only where it refers back to -// something that encloses it. -// -// Copies of a dense cycle that fit that way are exponentially many, as for k -// Path Items whose callbacks all reference one another. So the work of -// converting targets again because no copy fits is limited to `BUDGET` times -// the rest of the work. Past that, the conversion that started it is unwound, -// and copies are shared as before for the rest of the run: an inlined target -// as soon as it is cached, and a merge while the hops it skipped are still in -// progress. A copy can then be cut at a target that enclosed the place where -// it was made but not the place where it is reused. -interface Trace { - readonly cuts: Set - readonly held: Set - readonly parts: Set -} - -interface Skipped { - readonly hop: unknown - readonly rest: Skipped | undefined -} - -interface Cached { - // The targets it cut at outside itself, still in progress when it was done. - readonly cuts: ReadonlySet - // The objects and targets it converted, apart from those in `parts`. - readonly held: ReadonlySet - // The cached copies it holds, made or reused while it was converted. - readonly parts: Iterable - // For a merge, the hops skipped as in progress by the walks it took. - readonly skipped: Skipped | undefined - readonly value: unknown -} - -interface Exact { - on: boolean - // Each unit of work adds `BUDGET`, except that each unit of converting a - // target again because no copy fit, or of checking whether a copy fits, - // takes one instead. - budget: number - // How many conversions of a target again are in progress. - depth: number - // The objects and targets in progress that were converted before, - // innermost last. - readonly again: unknown[] - readonly converted: Set - readonly holds: Map> -} - -interface Saved { - readonly again: number - readonly converting: number - readonly identified: number - readonly inlining: number -} - -// Records nothing. It is the trace of the whole document, and of every copy -// once exact reuse is off, since nothing reads those. -class Untraced extends Set { - override add(): this { - return this - } -} - -const UNTRACED: Trace = { cuts: new Untraced(), held: new Untraced(), parts: new Untraced() } - -// Thrown to unwind a conversion that ran over budget. Its stack is never read. -const OVER_BUDGET = new Error('over budget') - -const BUDGET = 4 - -function newTrace(ctx: Context): Trace { - return ctx.exact.on ? { cuts: new Set(), held: new Set(), parts: new Set() } : UNTRACED -} - -function spend(units: number, ctx: Context): void { - const { exact } = ctx - exact.budget -= units - if (exact.budget < 0 && exact.on) { - exact.on = false - exact.converted.clear() - exact.holds.clear() - if (exact.depth > 0) { - throw OVER_BUDGET - } - } -} - -function work(ctx: Context): void { - if (ctx.exact.on) { - if (ctx.exact.depth === 0) { - ctx.exact.budget += BUDGET - } - else { - spend(1, ctx) - } - } -} - -// Traces `value` as converted by the current copy. If it was converted -// before, it goes on `again` until its conversion ends, unless `enter` just -// put it there as an inlined target. Returns whether it was put there. -function hold(value: unknown, ctx: Context): boolean { - const { exact } = ctx - if (!exact.on) { - return false - } - ctx.trace.held.add(value) - const { size } = exact.converted - if (exact.converted.add(value).size > size || exact.again.at(-1) === value) { - return false - } - exact.again.push(value) - return true -} - -// Starts converting `target` for a copy traced in `trace`. With `redo`, a -// copy of it exists but none fits, so it is converted again; the outermost -// such conversion returns the state to restore if it runs over budget. -function enter(target: unknown, trace: Trace, redo: boolean, ctx: Context): Saved | undefined { - const { exact } = ctx - const saved = redo && exact.depth === 0 ? save(ctx) : undefined - if (redo) { - exact.depth++ +export function inline(ref: string, ctx: Context, convert: Convert): unknown { + const target = resolvePointer(ref, ctx) + if (target === undefined || ctx.inlining.has(target)) { + return DROP } ctx.inlining.add(target) - if (exact.on) { - trace.held.add(target) - if (exact.converted.has(target)) { - exact.again.push(target) - } - } - return saved -} - -function leave(target: unknown, redo: boolean, ctx: Context): void { - const { exact } = ctx + const out = convert(target, ctx) ctx.inlining.delete(target) - if (redo) { - exact.depth-- - } - if (exact.on) { - exact.converted.add(target) - } - if (exact.again.at(-1) === target) { - exact.again.pop() - } -} - -function save(ctx: Context): Saved { - return { again: ctx.exact.again.length, converting: ctx.converting.length, identified: ctx.identified.size, inlining: ctx.inlining.size } -} - -// Unwinds a conversion that ran over budget. What it added to the caches -// stays, since each of those copies was finished and fits where it was made. -function restore(saved: Saved, ctx: Context): void { - ctx.converting.length = saved.converting - for (const target of [...ctx.inlining].slice(saved.inlining)) { - ctx.inlining.delete(target) - } - for (const schema of [...ctx.identified].slice(saved.identified)) { - ctx.identified.delete(schema) - } - ctx.exact.again.length = saved.again - ctx.exact.depth = 0 -} - -function cut(target: unknown, ctx: Context): typeof DROP { - ctx.trace.cuts.add(target) - return DROP -} - -function record(cuts: Iterable, parts: Iterable, ctx: Context): void { - for (const target of cuts) { - ctx.trace.cuts.add(target) - } - for (const part of parts) { - ctx.trace.parts.add(part) - } -} - -// Returns `cuts` with those of `more` that are still in progress and without -// `done`, a hop whose merge just ended. A cached copy may hold `cuts`, so it -// is copied only if that changes it. -function joinCuts(cuts: ReadonlySet, more: readonly unknown[], done: unknown, ctx: Context): ReadonlySet { - if (!ctx.exact.on) { - return cuts - } - const added = more.filter(target => !cuts.has(target) && isInProgress(target, ctx)) - if (added.length === 0 && !cuts.has(done)) { - return cuts - } - const out = new Set([...cuts, ...added]) - out.delete(done) return out } -// Whether `cached`, or a copy it holds, converted `target`. -function holds(cached: Cached, target: unknown, ctx: Context): boolean { - const known = cacheOf(ctx.exact.holds, target) - const answer = known.get(cached) - if (answer !== undefined) { - spend(1, ctx) - return answer - } - const visited = new Set([cached]) - const stack = [cached] - for (let node = stack.pop(); node !== undefined; node = stack.pop()) { - spend(1, ctx) - const nodeAnswer = known.get(node) - if (nodeAnswer === true || node.held.has(target)) { - known.set(cached, true) - return true - } - if (nodeAnswer === undefined) { - for (const part of node.parts) { - if (!visited.has(part)) { - visited.add(part) - stack.push(part) - } - } - } - } - for (const node of visited) { - known.set(node, false) - } - return false -} - -// A copy comes out the same where every target it cut at is still in -// progress, and nothing it converted is in progress again, which a -// conversion there would cut instead. Nothing in progress for the first time -// can have been converted by a copy. -function isExact(cached: Cached, ctx: Context): boolean { - spend(cached.cuts.size + ctx.exact.again.length, ctx) - for (const target of cached.cuts) { - if (!isInProgress(target, ctx)) { - return false - } - } - return ctx.exact.again.every(target => cached.cuts.has(target) || !holds(cached, target, ctx)) -} - -function isStillSkipped(skipped: Skipped | undefined, ctx: Context): boolean { - for (let node = skipped; node !== undefined; node = node.rest) { - if (!isInProgress(node.hop, ctx)) { - return false - } - } - return true -} - -function reusable(copies: readonly Cached[] | undefined, ctx: Context): Cached | undefined { - if (copies === undefined) { - return undefined - } - if (!ctx.exact.on) { - const last = copies.at(-1) as Cached - return isStillSkipped(last.skipped, ctx) ? last : undefined - } - for (let index = copies.length - 1; index >= 0; index--) { - if (isExact(copies[index] as Cached, ctx)) { - return copies[index] - } - } - return undefined -} - -// Once exact reuse is off, only the last copy of a target is read. -function keep(cache: Map, target: unknown, cached: Cached, ctx: Context): void { - const copies = cache.get(target) - if (copies === undefined || !ctx.exact.on) { - cache.set(target, [cached]) - } - else { - copies.push(cached) - } -} - -export function inline(ref: string, ctx: Context, convert: Convert): unknown { - const { base, target } = ctx.locate(ref) - if (target === undefined) { - return DROP - } - if (isInProgress(target, ctx)) { - return cut(target, ctx) - } - const cache = cacheOf(ctx.inlined, convert) - const copies = cache.get(target) - const known = reusable(copies, ctx) - if (known !== undefined) { - record(known.cuts, [known], ctx) - return known.value - } - const redo = ctx.exact.on && copies !== undefined - const identified = ctx.identified.size - const trace = newTrace(ctx) - const saved = enter(target, trace, redo, ctx) - let value: unknown - try { - value = convert(target, { ...ctx, base, seen: new Map(), trace }) - } - catch (error) { - if (saved === undefined || error !== OVER_BUDGET) { - throw error - } - restore(saved, ctx) - return inline(ref, ctx, convert) - } - leave(target, redo, ctx) - for (const cutAt of trace.cuts) { - if (!isInProgress(cutAt, ctx)) { - trace.cuts.delete(cutAt) - } - } - const out: Cached = { cuts: trace.cuts, held: trace.held, parts: trace.parts, skipped: undefined, value } - if (ctx.identified.size === identified) { - keep(cache, target, out, ctx) - } - record(out.cuts, [out], ctx) - return value -} - -function freshState(exact: boolean): Pick { - return { - converting: [], - exact: { again: [], budget: 0, converted: new Set(), depth: 0, holds: new Map(), on: exact }, - identified: new Set(), - inlined: new Map(), - inlining: new Set(), - merged: new Map(), - seen: new Map(), - trace: UNTRACED, - } -} - -function convertsToDrop(ref: string, ctx: Context, convert: Convert): boolean { - if (ctx.removals.has(ref)) { - return ctx.removals.get(ref) === true - } - ctx.removals.set(ref, undefined) - const removed = inline(ref, { ...ctx, ...freshState(false) }, convert) === DROP - ctx.removals.set(ref, removed) - return removed -} - -function isRemovedAlias(ref: string, ctx: Context, convert: Convert): boolean { - if (getRef(ctx.resolve(ref)) === undefined) { - return false - } - const end = ctx.aliasEnd(ref) - return end !== undefined && ctx.dangles(end) && convertsToDrop(end, ctx, convert) -} - -export function skipAliases(ref: string, ctx: Context, follow: (next: string, target: Record) => boolean): string { - const hops = new Set([ref]) - let hop = ref - for (;;) { - const { next, target } = ctx.locate(hop) - if (next === undefined || hops.has(next) || !follow(next, target as Record)) { - return hop - } - hops.add(next) - hop = next - } -} - -/** Inlines the target of a schema `$ref` written in the current resource. */ -export function inlineSchema(ref: string, ctx: Context, convert: Convert): unknown { - const start = rebase(ref, ctx.base) - return inline(skipAliases(start, ctx, (next, target) => getBareRef(target) !== undefined && ctx.dangles(next)), ctx, convert) -} - -/** - * The `$ref` to write in place of a schema `$ref` written in the current - * resource, or `undefined` when `gone` says its target is gone, by default - * when it dangles, so that it must be inlined with `inlineSchema` instead. - * - * Where the output keeps the `$id` of that resource, the `$ref` resolves as - * written. Elsewhere the output resolves it against the document, so it is - * rebased onto the root, even when that leaves it dangling as in the source. - */ -export function keepSchemaRef(ref: string, ctx: Context, gone: (ref: string) => boolean = ctx.dangles): string | undefined { - if (ctx.base !== ROOT && ctx.base === ctx.outputBase) { - return ref - } - const rebased = rebase(ref, ctx.base) - return gone(rebased) ? undefined : rebased -} - -export function refOr(convert: Convert, keep: (value: Record) => unknown = clone): Convert { - const self: Convert = (value, ctx) => { - const ref = getRef(value) - if (ref === undefined) { - return convert(value, ctx) - } - if (isRemovedAlias(ref, ctx, self)) { - ctx.markDangling(ref) - return DROP - } - if (ctx.dangles(ref)) { - return inline(skipAliases(ref, ctx, next => ctx.dangles(next)), ctx, self) - } - return keep(value as Record) - } - return self -} - -function isGone(ref: string, ctx: Context): boolean { - return ctx.isRemovedPart(ref) || ctx.dangles(ref) -} - -/** - * Converts a discriminator `mapping` value. A schema name stays as it is. A - * reference is kept like a schema `$ref`, but cannot be inlined, so it is - * dropped where its target is removed. - */ -export function convertMappingRef(value: unknown, ctx: Context): unknown { - return typeof value === 'string' ? keepSchemaRef(value, ctx, ref => isGone(ref, ctx)) ?? DROP : clone(value) -} - -export function hasDanglingOperationRef(link: unknown, ctx: Context): boolean { - return isRecord(link) && typeof link.operationRef === 'string' && isGone(link.operationRef, ctx) -} - -function isOperationPointer(tokens: readonly string[]): boolean { - const key = tokens.at(-1) as string - if ((HTTP_METHODS as readonly string[]).includes(key) || key === 'query') { - return isPathItemPointer(tokens.slice(0, -1)) - } - return tokens.at(-2) === 'additionalOperations' && isPathItemPointer(tokens.slice(0, -2)) -} - -function isPathItemPointer(tokens: readonly string[]): boolean { - const [first, second] = tokens - if (tokens.length === 2) { - return first === 'paths' || first === 'webhooks' - } - if (tokens.length === 3 && first === 'components' && second === 'pathItems') { - return true - } - return tokens.length > 3 - && tokens.at(-3) === 'callbacks' - && !(tokens.at(-1) as string).startsWith('x-') - && (tokens.length === 4 ? first === 'components' : isOperationPointer(tokens.slice(0, -3))) -} - -function mergeMissing(out: Record, target: unknown): void { - if (isRecord(target)) { - for (const [key, item] of Object.entries(target)) { - if (!Object.hasOwn(out, key)) { - setOwn(out, key, item) - } - } - } -} - -function followsPathItem(ref: string | undefined, ctx: Context): ref is string { - const tokens = ref === undefined ? undefined : parsePointer(ref) - return tokens !== undefined && isPathItemPointer(tokens) && ctx.dangles(ref as string) -} - -type Hop = { readonly target: unknown } | { readonly identified: number, readonly own: unknown, readonly redo: boolean, readonly target: unknown, readonly trace: Trace } - -// The rest of a chain: a hop's cached merge, or the chain end. -type Tail = Pick - -interface Walk { - readonly hops: Hop[] - saved?: { readonly hops: number, readonly ref: string, readonly state: Saved } -} - -// Walks the chain from `ref`, converting the own fields of each hop that is -// not in progress, up to the chain end or a hop with a copy to reuse. -function walkChain(ref: string, ctx: Context, convert: Convert, walk: Walk): Tail { - const cache = cacheOf(ctx.merged, convert) - for (;;) { - work(ctx) - const { next, target } = ctx.locate(ref) - if (!followsPathItem(next, ctx)) { - const trace = newTrace(ctx) - const value = inline(ref, { ...ctx, trace }, convert) - return { cuts: trace.cuts, parts: trace.parts, skipped: value === DROP ? { hop: target, rest: undefined } : undefined, value } - } - if (isInProgress(target, ctx)) { - walk.hops.push({ target }) - } - else { - const copies = cache.get(target) - const known = reusable(copies, ctx) - if (known !== undefined) { - return { cuts: known.cuts, parts: [known], skipped: known.skipped, value: known.value } - } - const redo = ctx.exact.on && copies !== undefined - const { $ref: _, ...own } = target as Record - const identified = ctx.identified.size - const trace = newTrace(ctx) - const saved = enter(target, trace, redo, ctx) - if (saved !== undefined) { - walk.saved = { hops: walk.hops.length, ref, state: saved } - } - walk.hops.push({ identified, own: convert(own, { ...ctx, seen: new Map(), trace }), redo, target, trace }) - } - ref = next - } -} - -// Each hop's merged fields are cached like an inlined target, so a chain is -// walked and its hops converted once however many references enter it, as -// long as the copies fit. A merge cuts where the own fields of its hops or -// the chain end cut, and at each hop it skipped as in progress. A skipped hop -// adds nothing, so a later hop's fields fill in for it. -function mergeChain(ref: string, ctx: Context, convert: Convert): unknown { - const cache = cacheOf(ctx.merged, convert) - const walk: Walk = { hops: [] } - let tail: Tail - try { - tail = walkChain(ref, ctx, convert, walk) - } - catch (error) { - if (walk.saved === undefined || error !== OVER_BUDGET) { - throw error - } - restore(walk.saved.state, ctx) - walk.hops.length = walk.saved.hops - tail = walkChain(walk.saved.ref, ctx, convert, walk) - } - let { cuts, parts, skipped, value } = tail - let passed: unknown[] = [] - for (const hop of walk.hops.reverse()) { - if (!('own' in hop)) { - passed.push(hop.target) - skipped = { hop: hop.target, rest: skipped } - continue - } - leave(hop.target, hop.redo, ctx) - cuts = joinCuts(cuts, [...passed, ...hop.trace.cuts], hop.target, ctx) - passed = [] - for (const part of parts) { - hop.trace.parts.add(part) - } - const fields: Record = {} - mergeMissing(fields, hop.own) - mergeMissing(fields, value) - const merged: Cached = { cuts, held: hop.trace.held, parts: hop.trace.parts, skipped, value: fields } - if (ctx.identified.size === hop.identified) { - keep(cache, hop.target, merged, ctx) - } - parts = [merged] - value = fields - } - for (const target of passed) { - cut(target, ctx) - } - record(cuts, parts, ctx) - return value -} - -export function mergeRef(convert: Convert): Finish { - return (out, source, ctx) => { - const ref = getRef(source) - if (!followsPathItem(ref, ctx)) { - return out - } - delete out.$ref - mergeMissing(out, mergeChain(ref, ctx, convert)) - return out - } -} - -export function removedPrefixes(tables: Readonly>): string[] { - return Object.entries(tables).flatMap(([base, fields]) => - [...fields].filter(([, field]) => field === DROP).map(([key]) => `#${base}/${key}/`), - ) -} - -function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | undefined): boolean { - if (tokens === undefined) { - return false - } - let from = source - let to = output - for (const token of tokens) { - if (Array.isArray(from) && !(Array.isArray(to) && to.length === from.length)) { - to = undefined - } - from = child(from, token) - to = child(to, token) - if (from === undefined) { - return false - } - } - return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) -} - -export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown { - const locations = new Map() - const locateRef = (ref: string): Location => { - let location = locations.get(ref) - if (location === undefined) { - location = locate(root, ref) - locations.set(ref, location) - } - return location - } - const resolveRef = (ref: string): unknown => locateRef(ref).target - const findResource = resourceFinder(root) - const ends = new Map() - const aliasEnd = (ref: string): string | undefined => { - const path = new Set() - let hop = ref - let end: string | undefined - for (;;) { - if (ends.has(hop)) { - end = ends.get(hop) - break - } - if (path.has(hop)) { - break - } - path.add(hop) - const { next } = locateRef(hop) - if (next === undefined) { - end = hop - break - } - hop = next - } - for (const visited of path) { - ends.set(visited, end) - } - return end - } - const isInlinable = (ref: string): boolean => { - const target = resolveRef(ref) - return (isRecord(target) || typeof target === 'boolean') && aliasEnd(ref) !== undefined - } - const dangling = new Set() - const isRemovedPart = (ref: string): boolean => removed.some(prefix => ref.startsWith(prefix)) - let previous = root - for (;;) { - const kept = new Set() - const out = convert(root, { - ...freshState(true), - aliasEnd, - base: ROOT, - copies: new Map(), - dangles: (ref) => { - if (!dangling.has(ref) && !kept.has(ref)) { - if ((isRemovedPart(ref) || (previous !== root && danglesIn(previous, root, parsePointer(ref)))) && isInlinable(ref)) { - dangling.add(ref) - } - else { - kept.add(ref) - } - } - return dangling.has(ref) - }, - findResource, - isRemovedPart, - locate: locateRef, - removals: new Map(), - markDangling: ref => dangling.add(ref), - outputBase: ROOT, - resolve: resolveRef, - }) - let stale = false - for (const ref of kept) { - if ((dangling.has(ref) || danglesIn(out, root, parsePointer(ref))) && isInlinable(ref)) { - dangling.add(ref) - stale = true - } - } - if (!stale) { - return out - } - previous = out - } +export function downgrade(root: unknown, convert: Convert): unknown { + return convert(root, { inlining: new Set(), root, seen: new Map(), targets: new Map() }) } diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index c39b4b4..639fcd2 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -1,41 +1,31 @@ import type * as OpenAPIV3_0 from '@openapi-spec/types/v3.0' import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' -import type { Context } from './shared' +import type { Context, Convert } from './shared' import { - allOfItems, - child, clone, - convertMappingRef, convertObject, - convertXml, defineFields, downgrade, DROP, - enterSchema, - getBareRef, getRef, - has, - hasDanglingOperationRef, hasType, HTTP_METHODS, - inlineSchema, + inline, isNotExtension, isPath, isRecord, - keepSchemaRef, list, map, - mergeRef, - placeholder, - rebasedRef, - refOr, - removedPrefixes, - resourceOf, - setOwn, + resolve, + resolvePointer, } from './shared' -const LOOSENING_KEYWORDS = new Set([ +// 3.0 has no place for these, so `$ref`s into them are replaced by their targets. +const REMOVED_PARTS = ['#/webhooks/', '#/components/pathItems/'] + +// Keywords 3.0 lacks that restrict values, so removing one loosens a schema. +const RESTRICTING_KEYWORDS = new Set([ '$dynamicRef', 'contains', 'dependentRequired', @@ -52,7 +42,9 @@ const LOOSENING_KEYWORDS = new Set([ 'unevaluatedProperties', ]) -const ANNOTATION_KEYWORDS = [ +const REMOVED_KEYWORDS = [ + ...RESTRICTING_KEYWORDS, + // Annotations and identifiers 3.0 lacks. '$anchor', '$comment', '$defs', @@ -63,128 +55,142 @@ const ANNOTATION_KEYWORDS = [ 'contentEncoding', 'contentMediaType', 'contentSchema', + // The draft-07 name of `$defs`, which 2020-12 still reads. + 'definitions', + // Rewritten by `finishSchema`. + '$ref', + 'const', 'examples', + 'exclusiveMaximum', + 'exclusiveMinimum', + 'nullable', + 'type', ] -const MULTIPART_MEDIA_TYPE = /^multipart\//i - -const URL_ENCODED_MEDIA_TYPE = /^application\/x-www-form-urlencoded\s*(?:;|$)/i - -const RFC6570_ENCODING_FIELDS = ['allowReserved', 'explode', 'style'] +// The only keywords a 3.0 Schema Object allows, besides extensions. +const V30_SCHEMA_KEYWORDS = new Set([ + 'additionalProperties', + 'allOf', + 'anyOf', + 'default', + 'deprecated', + 'description', + 'discriminator', + 'enum', + 'example', + 'exclusiveMaximum', + 'exclusiveMinimum', + 'externalDocs', + 'format', + 'items', + 'maxItems', + 'maxLength', + 'maxProperties', + 'maximum', + 'minItems', + 'minLength', + 'minProperties', + 'minimum', + 'multipleOf', + 'not', + 'nullable', + 'oneOf', + 'pattern', + 'properties', + 'readOnly', + 'required', + 'title', + 'type', + 'uniqueItems', + 'writeOnly', + 'xml', +]) -const CONTENT_TYPE_OVERRIDES = ['contentType', ...RFC6570_ENCODING_FIELDS] +// Keywords that let `unevaluatedProperties` see more than `properties`. +const IN_PLACE_APPLICATORS = ['$dynamicRef', '$ref', 'additionalProperties', 'allOf', 'anyOf', 'dependentSchemas', 'else', 'if', 'oneOf', 'patternProperties', 'then'] +// Converted schemas that lost a restriction, directly or in a subschema. const LOOSE = new WeakSet() const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) -const convertRequestContent = map(convertRequestMediaType) -const convertRequirements = list(convertRequirement) -const finishPathItem = mergeRef(convertPathItem) -const convertCallbackRef = refOr(convertCallback, reference) -const convertExampleRef = refOr(clone, reference) -const convertLinkRef = refOr(convertLink, reference) -const convertParameterRef = refOr(convertParameter, reference) -const convertRequestBodyRef = refOr(convertRequestBody, reference) -const convertResponseRef = refOr(convertResponse, reference) -const convertSecuritySchemeRef = refOr(convertSecurityScheme, reference) - -const DISCRIMINATOR_FIELDS = defineFields({ - mapping: map(convertMappingRef), -}) +const convertReference = refOr(clone) const SCHEMA_FIELDS = defineFields({ - ...Object.fromEntries([...LOOSENING_KEYWORDS, ...ANNOTATION_KEYWORDS].map(key => [key, DROP])), - $ref: item => (typeof item === 'string' ? DROP : clone(item)), + ...Object.fromEntries(REMOVED_KEYWORDS.map(key => [key, DROP])), + // It would reject the properties that `patternProperties` allowed. additionalProperties: (item, ctx, schema) => { - if (has(schema, 'patternProperties')) { + if (schema.patternProperties !== undefined) { return DROP } return typeof item === 'boolean' ? item : convertSchema(item, ctx) }, allOf: list(convertSchema), anyOf: list(convertSchema), - const: DROP, - discriminator: (item, ctx) => convertObject(item, ctx, DISCRIMINATOR_FIELDS), + // An empty one rejects every value, which `finishSchema` keeps another way. enum: item => (Array.isArray(item) && item.length === 0 ? DROP : clone(item)), - exclusiveMaximum: item => (typeof item === 'number' ? DROP : clone(item)), - exclusiveMinimum: item => (typeof item === 'number' ? DROP : clone(item)), - items: (item, ctx, schema) => (has(schema, 'prefixItems') ? DROP : convertSchema(item, ctx)), + // `finishSchema` builds it from `prefixItems`. + items: (item, ctx, schema) => (schema.prefixItems === undefined ? convertSchema(item, ctx) : DROP), not: convertSchema, - nullable: DROP, oneOf: list(convertSchema), properties: map(convertSchema), - readOnly: (item, _ctx, schema) => (item === true && schema.writeOnly === true ? DROP : clone(item)), - required: (item) => { - if (!Array.isArray(item)) { - return clone(item) - } - return item.length === 0 ? DROP : clone([...new Set(item)]) - }, - type: DROP, - writeOnly: (item, _ctx, schema) => (item === true && schema.readOnly === true ? DROP : clone(item)), - xml: convertXml, + // 3.0 requires at least one entry. + required: item => (Array.isArray(item) && item.length === 0 ? DROP : clone(item)), }) const PARAMETER_FIELDS = defineFields({ content: convertContent, - examples: map(convertExampleRef), + examples: map(convertReference), schema: convertSchema, }) -const MEDIA_TYPE_FIELDS = defineFields({ - encoding: map(convertEncoding), - examples: map(convertExampleRef), - schema: convertSchema, -}) - -const URL_ENCODED_MEDIA_TYPE_FIELDS = new Map(MEDIA_TYPE_FIELDS) - -const MULTIPART_MEDIA_TYPE_FIELDS = new Map(MEDIA_TYPE_FIELDS).set('encoding', map(convertMultipartEncoding)) - const ENCODING_FIELDS = defineFields({ - headers: map(convertParameterRef), + headers: map(refOr(convertParameter)), }) -const MULTIPART_ENCODING_FIELDS = defineFields({ - ...Object.fromEntries(ENCODING_FIELDS), - ...Object.fromEntries(RFC6570_ENCODING_FIELDS.map(key => [key, DROP])), +const MEDIA_TYPE_FIELDS = defineFields({ + encoding: map((item, ctx) => convertObject(item, ctx, ENCODING_FIELDS)), + examples: map(convertReference), + schema: convertSchema, }) const REQUEST_BODY_FIELDS = defineFields({ - content: convertRequestContent, + content: convertContent, }) const RESPONSE_FIELDS = defineFields({ content: convertContent, - headers: map(convertParameterRef), - links: map(convertLinkRef), + headers: map(refOr(convertParameter)), + links: map(convertReference), }) const OPERATION_FIELDS = defineFields({ - callbacks: map(convertCallbackRef), - parameters: list(convertParameterRef), - requestBody: convertRequestBodyRef, - responses: map(convertResponseRef, isNotExtension), + callbacks: map(refOr(convertCallback)), + parameters: list(refOr(convertParameter)), + requestBody: refOr(convertRequestBody), + responses: map(refOr(convertResponse), isNotExtension), security: convertSecurity, }) const PATH_ITEM_FIELDS = defineFields({ ...Object.fromEntries(HTTP_METHODS.map(method => [method, convertOperation])), - parameters: list(convertParameterRef), + parameters: list(refOr(convertParameter)), }) +// For a Path Item whose `$ref` `finishMergedPathItem` replaces. +const MERGED_PATH_ITEM_FIELDS = new Map(PATH_ITEM_FIELDS).set('$ref', DROP) + const COMPONENTS_FIELDS = defineFields({ - callbacks: map(convertCallbackRef), - examples: map(convertExampleRef), - headers: map(convertParameterRef), - links: map(convertLinkRef), - parameters: map(convertParameterRef), + callbacks: map(refOr(convertCallback)), + examples: map(convertReference), + headers: map(refOr(convertParameter)), + links: map(convertReference), + parameters: map(refOr(convertParameter)), pathItems: DROP, - requestBodies: map(convertRequestBodyRef), - responses: map(convertResponseRef), + requestBodies: map(refOr(convertRequestBody)), + responses: map(refOr(convertResponse)), schemas: map(convertSchema), - securitySchemes: map(convertSecuritySchemeRef), + securitySchemes: map((item, ctx) => (schemeType(item, ctx) === 'mutualTLS' ? DROP : convertReference(item, ctx))), }) const LICENSE_FIELDS = defineFields({ @@ -196,76 +202,165 @@ const INFO_FIELDS = defineFields({ summary: DROP, }) +// A summary stands in for the description it lacks. +function finishInfo(out: Record, info: Record): void { + if (out.description === undefined && typeof info.summary === 'string') { + out.description = info.summary + } +} + const DOCUMENT_FIELDS = defineFields({ components: (item, ctx) => convertObject(item, ctx, COMPONENTS_FIELDS), - info: (item, ctx) => convertObject(item, ctx, INFO_FIELDS), + info: (item, ctx) => convertObject(item, ctx, INFO_FIELDS, finishInfo), jsonSchemaDialect: DROP, paths: map(convertPathItem, isPath), security: convertSecurity, webhooks: DROP, }) -const REMOVED = removedPrefixes({ '': DOCUMENT_FIELDS, '/components': COMPONENTS_FIELDS }) - -function reference(value: Record): unknown { - return { $ref: value.$ref } +// A `$ref` into a removed part, `$defs`, or `definitions` is replaced by its +// target, unless it already dangles. +function isInlined(ref: string, ctx: Context): boolean { + return (ref.includes('/$defs/') || ref.includes('/definitions/') || REMOVED_PARTS.some(prefix => ref.startsWith(prefix))) && resolvePointer(ref, ctx) !== undefined } -function isLoose(value: unknown): boolean { - return LOOSE.has(value as object) +/** A 3.0 Reference Object holds only `$ref`. */ +function refOr(convert: Convert): Convert { + const self: Convert = (value, ctx) => { + const ref = getRef(value) + if (ref === undefined) { + return convert(value, ctx) + } + return isInlined(ref, ctx) ? inline(ref, ctx, self) : { $ref: ref } + } + return self } -function hasLoose(value: unknown): boolean { - return typeof value === 'object' && value !== null && Object.values(value).some(isLoose) +function convertSchemaRef(ref: string, ctx: Context): unknown { + if (!isInlined(ref, ctx)) { + return { $ref: ref } + } + const out = inline(ref, ctx, convertSchema) + return out === DROP ? loosened({}) : out } -function loosened(out: object): object { - LOOSE.add(out) - return out +// 3.0 `items` applies one schema to every item, so a tuple becomes an array +// whose items match any of its item schemas, and those of the items after +// them, unless `items: false` or `maxItems` allows none. `items: false` +// becomes `maxItems`. Returns whether the result means exactly the same, +// as when every item schema is the same and none lost a restriction. +function convertTuple(out: Record, prefixItems: unknown[], schema: Record, ctx: Context): boolean { + const { items, maxItems } = schema + if (items === false && !(typeof maxItems === 'number' && maxItems <= prefixItems.length)) { + out.maxItems = prefixItems.length + } + const closed = typeof out.maxItems === 'number' && out.maxItems <= prefixItems.length + if (!closed && (items === undefined || items === true)) { + out.items = {} + return false + } + // Equal item schemas convert alike, which their outputs, compared while a + // cycle is still being converted, need not show. + const variants = new Map() + for (const item of closed ? prefixItems : [...prefixItems, items]) { + const key = jsonKey(item) + if (!variants.has(key)) { + variants.set(key, convertSchema(item, ctx)) + } + } + const [first, ...rest] = variants.values() + out.items = rest.length === 0 ? first : { anyOf: [first, ...rest] } + return closed && rest.length === 0 && !isLoose(first) } -function isLooseSchema(out: Record, schema: Record): boolean { - return Object.keys(schema).some(key => LOOSENING_KEYWORDS.has(key) && has(schema, key)) - || (Array.isArray(schema.enum) && schema.enum.length === 0) - || isLoose(out.items) - || isLoose(out.additionalProperties) - || hasLoose(out.properties) - || hasLoose(out.allOf) - || hasLoose(out.anyOf) +// Equal JSON values get the same key. A cyclic value is only equal to itself. +function jsonKey(value: unknown): unknown { + try { + return JSON.stringify(value) + } + catch { + return value + } } -function addAnyOf(out: Record, variants: unknown): void { +function addAnyOf(out: Record, variants: unknown[]): void { if (out.anyOf === undefined) { out.anyOf = variants } else { - out.allOf = [...allOfItems(out.allOf), { anyOf: variants }] + out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), { anyOf: variants }] } } -function convertSchemaRef(ref: string, ctx: Context): unknown { - const kept = keepSchemaRef(ref, ctx) - if (kept !== undefined) { - return { $ref: kept } +function loosened(out: object): object { + LOOSE.add(out) + return out +} + +function isLoose(value: unknown): boolean { + return typeof value === 'object' && value !== null && LOOSE.has(value) +} + +// With no in-place applicator beside it, it means exactly `additionalProperties`. +function isUnevaluatedAdditional(schema: Record): boolean { + return schema.unevaluatedProperties !== undefined && IN_PLACE_APPLICATORS.every(key => schema[key] === undefined) +} + +// Whether removing `key` from `schema` lets it accept more values. Property +// names are strings anyway, and `unevaluatedProperties` does nothing beside +// `additionalProperties`. +function losesRestriction(key: string, schema: Record, exactTuple: boolean): boolean { + switch (key) { + case 'prefixItems': + return !exactTuple + case 'propertyNames': { + const names = schema.propertyNames + return !(names === true || (isRecord(names) && Object.entries(names).every(([name, item]) => name === 'type' && item === 'string'))) + } + case 'unevaluatedProperties': + return !(schema.additionalProperties !== undefined || isUnevaluatedAdditional(schema)) + default: + return true + } +} + +// A schema that lost a restriction accepts more values. A `not` over it would +// then reject values the original accepts, and so would a `oneOf` whose +// branches may now overlap, so the `not` is removed and the `oneOf` becomes +// an `anyOf`. Returns whether the schema is loosened itself. +function loosen(out: Record, schema: Record, exactTuple: boolean): boolean { + let loose = false + for (const key in schema) { + if (RESTRICTING_KEYWORDS.has(key) && schema[key] !== undefined && losesRestriction(key, schema, exactTuple)) { + loose = true + } } - const out = inlineSchema(ref, ctx, convertSchema) - return out === DROP ? loosened({}) : out + if (isLoose(out.not)) { + delete out.not + loose = true + } + if (Array.isArray(out.oneOf) && out.oneOf.some(isLoose)) { + addAnyOf(out, out.oneOf) + delete out.oneOf + loose = true + } + return loose + || isLoose(out.items) + || isLoose(out.additionalProperties) + || (isRecord(out.properties) && Object.values(out.properties).some(isLoose)) + || (Array.isArray(out.allOf) && out.allOf.some(isLoose)) + || (Array.isArray(out.anyOf) && out.anyOf.some(isLoose)) } -function convertType(out: Record, type: unknown): boolean { +// 3.0 takes one type, and marks null with `nullable` instead. +function convertType(out: Record, type: unknown): void { if (typeof type === 'string' && type !== 'null') { out.type = type - return false + return } const types = (Array.isArray(type) ? type : [type]).filter(item => typeof item === 'string') - if (types.length === 0) { - if (type !== undefined && !(Array.isArray(type) && type.length === 0)) { - out.type = clone(type) - } - return false - } const nullable = types.includes('null') - const rest = [...new Set(types.filter(item => item !== 'null'))] + const rest = types.filter(item => item !== 'null') if (rest.length === 1) { out.type = rest[0] if (nullable) { @@ -278,44 +373,60 @@ function convertType(out: Record, type: unknown): boolean { ...(item === 'array' && { items: out.items ?? {} }), ...(nullable && { nullable: true }), }))) - if (rest.includes('array')) { - delete out.items - } - } - else if (out.enum === undefined) { - out.enum = [null] + delete out.items } - else if (!Array.isArray(out.enum)) { - return true + else if (nullable) { + // 3.0 has no null type, so only null may match, and an `enum` without it matches nothing. + if (out.enum === undefined || (Array.isArray(out.enum) && out.enum.includes(null))) { + out.enum = [null] + } + else { + out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), { not: {} }] + } } - else if (out.enum.includes(null)) { - out.enum = [null] +} + +// 3.0 marks encoded and raw binary strings with `format` instead. A string +// with a `contentSchema` holds structured text, not bytes. +function binaryFormat(schema: Record): string | undefined { + if (schema.contentEncoding === 'base64') { + return 'byte' } - else { - out.not = {} + if (schema.contentEncoding === 'binary') { + return 'binary' } - return false + return schema.contentEncoding === undefined && schema.contentMediaType !== undefined && schema.contentSchema === undefined ? 'binary' : undefined } -function finishSchema(out: Record, schema: Record, ctx: Context): unknown { +function finishSchema(out: Record, schema: Record, ctx: Context): void { + // 3.0 ignores the siblings of a `$ref`, but not the members of an `allOf`. if (typeof schema.$ref === 'string') { - out.allOf = [convertSchemaRef(schema.$ref, ctx), ...allOfItems(out.allOf)] + out.allOf = [convertSchemaRef(schema.$ref, ctx), ...(Array.isArray(out.allOf) ? out.allOf : [])] } - let loose = isLooseSchema(out, schema) - if (isLoose(out.not)) { - delete out.not - loose = true + if (schema.const !== undefined) { + out.enum = [clone(schema.const)] } - if (hasLoose(out.oneOf)) { - addAnyOf(out, out.oneOf) - delete out.oneOf + if (Array.isArray(schema.enum) && schema.enum.length === 0) { + out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), { not: {} }] + } + const exactTuple = Array.isArray(schema.prefixItems) && convertTuple(out, schema.prefixItems, schema, ctx) + if (isUnevaluatedAdditional(schema)) { + const additional = schema.unevaluatedProperties + out.additionalProperties = typeof additional === 'boolean' ? additional : convertSchema(additional, ctx) + } + // Before `convertType` moves `items` into an `anyOf` branch. + // A `const` outside the `enum` beside it matched nothing, and now matches itself. + const constOutsideEnum = schema.const !== undefined && Array.isArray(schema.enum) && !schema.enum.some(item => jsonKey(item) === jsonKey(schema.const)) + let loose = loosen(out, schema, exactTuple) || constOutsideEnum + convertType(out, schema.type) + // Written 3.0-style in a 3.1 document, it can only mean what it means in 3.0. + if (schema.nullable === true && typeof out.type === 'string' && out.nullable !== true) { + out.nullable = true loose = true } - if (has(schema, 'const')) { - loose ||= has(schema, 'enum') && !(Array.isArray(schema.enum) && schema.enum.includes(schema.const)) - out.enum = [clone(schema.const)] + if (out.type === 'array' && out.items === undefined) { + out.items = {} } - loose = convertType(out, schema.type) || loose const { exclusiveMaximum, exclusiveMinimum, maximum, minimum } = schema if (typeof exclusiveMinimum === 'number' && !(typeof minimum === 'number' && minimum > exclusiveMinimum)) { out.minimum = exclusiveMinimum @@ -325,166 +436,56 @@ function finishSchema(out: Record, schema: Record 0 && !has(schema, 'example')) { + if (Array.isArray(schema.examples) && schema.examples.length > 0 && out.example === undefined) { out.example = clone(schema.examples[0]) } - const format = schema.contentEncoding === 'base64' - ? 'byte' - : schema.contentEncoding === undefined && typeof schema.contentMediaType === 'string' ? 'binary' : undefined + const format = binaryFormat(schema) if (format !== undefined && (schema.type === undefined || hasType(schema.type, 'string'))) { out.format ??= format if (schema.type === undefined) { out.type = 'string' } } - if (out.type === 'array' && out.items === undefined) { - out.items = placeholder() - } - return loose ? loosened(out) : out -} - -function convertSchema(value: unknown, ctx: Context): unknown { - if (typeof value === 'boolean') { - return value ? {} : { not: {} } - } - const ref = getBareRef(value) - if (ref !== undefined) { - return convertSchemaRef(ref, ctx) - } - const cyclic = ctx.converting.includes(value) - const out = convertObject(value, enterSchema(value, ctx, false), SCHEMA_FIELDS, finishSchema) - return cyclic ? loosened(out === DROP ? {} : out as object) : out -} - -function finishParameter(out: Record, parameter: Record): unknown { - if (parameter.in === 'path') { - out.required = true - } - return out -} - -function convertParameter(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, PARAMETER_FIELDS, finishParameter) -} - -function convertMediaType(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, MEDIA_TYPE_FIELDS) -} - -// Adds `schema` to `nodes` with the base its own `$ref`s resolve against: its -// own `$id`, or else `base`, the one around it. The first place found wins. -function place(nodes: Map, schema: unknown, base: string, ctx: Context): void { - if (!nodes.has(schema)) { - nodes.set(schema, resourceOf(schema, ctx) ?? base) - } -} - -// Adds to `nodes` each schema they apply through `$ref`, `allOf`, `anyOf`, -// and `oneOf`. -function addSubschemas(nodes: Map, ctx: Context): Map { - for (const [node, base] of nodes) { - if (isRecord(node)) { - const ref = rebasedRef(node, base) - if (ref !== undefined) { - const location = ctx.locate(ref) - place(nodes, location.target, location.base, ctx) - } - for (const key of ['allOf', 'anyOf', 'oneOf']) { - for (const item of Array.isArray(node[key]) ? node[key] : []) { - place(nodes, item, base, ctx) - } + // 3.0 allows no other keywords, so unknown ones become extensions. + for (const key of Object.keys(out)) { + if (!V30_SCHEMA_KEYWORDS.has(key) && !key.startsWith('x-')) { + if (!Object.hasOwn(out, `x-${key}`)) { + out[`x-${key}`] = out[key] } + delete out[key] } } - return nodes -} - -function formParts(schema: unknown, ctx: Context): Map> { - const parts = new Map>() - const body = new Map() - place(body, schema, ctx.base, ctx) - for (const [node, base] of addSubschemas(body, ctx)) { - if (isRecord(node) && isRecord(node.properties)) { - for (const [name, property] of Object.entries(node.properties)) { - if (property === undefined) { - continue - } - let part = parts.get(name) - if (part === undefined) { - part = new Map() - parts.set(name, part) - } - place(part, property, base, ctx) - } - } + if (loose) { + LOOSE.add(out) } - return parts } -function defaultsToOctetStream(schemas: Map, ctx: Context, isItem = false): boolean { - const bases = addSubschemas(schemas, ctx) - const nodes = [...bases.keys()] - if (!nodes.every(node => isRecord(node) || node === true)) { - return false - } - const records = nodes.filter(isRecord) - const types = records.flatMap(node => [node.type ?? []].flat()) - const kinds = new Set(types.filter(type => type !== 'null')) - if (types.length === 0) { - return true - } - if (kinds.size !== 1) { - return false - } - if (kinds.has('string')) { - return records.some(node => node.contentEncoding !== undefined) - } - if (isItem || !kinds.has('array')) { - return false - } - const items = new Map() - for (const [node, base] of bases) { - for (const item of [child(node, 'prefixItems') ?? [], child(node, 'items') ?? []].flat()) { - place(items, item, base, ctx) +function isBareRef(value: Record): boolean { + for (const key in value) { + if (key !== '$ref' && Object.hasOwn(value, key) && value[key] !== undefined) { + return false } } - return items.size === 0 || defaultsToOctetStream(items, ctx, true) + return true } -function finishFormMediaType(out: Record, mediaType: Record, ctx: Context): unknown { - const encoding = out.encoding ?? {} - if (!isRecord(encoding)) { - return out - } - for (const [name, schemas] of formParts(mediaType.schema, ctx)) { - const entry = child(encoding, name) ?? {} - if ( - isRecord(entry) - && !CONTENT_TYPE_OVERRIDES.some(key => Object.hasOwn(entry, key)) - && defaultsToOctetStream(schemas, ctx) - ) { - setOwn(encoding, name, { ...entry, contentType: 'application/octet-stream' }) - out.encoding = encoding - } +function convertSchema(value: unknown, ctx: Context): unknown { + if (typeof value === 'boolean') { + return value ? {} : { not: {} } } - return out -} - -function convertRequestMediaType(value: unknown, ctx: Context, type: string): unknown { - if (MULTIPART_MEDIA_TYPE.test(type)) { - return convertObject(value, ctx, MULTIPART_MEDIA_TYPE_FIELDS, finishFormMediaType) + const ref = getRef(value) + if (ref !== undefined && isBareRef(value as Record)) { + return convertSchemaRef(ref, ctx) } - return URL_ENCODED_MEDIA_TYPE.test(type) - ? convertObject(value, ctx, URL_ENCODED_MEDIA_TYPE_FIELDS, finishFormMediaType) - : convertMediaType(value, ctx) + return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema) } -function convertEncoding(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, ENCODING_FIELDS) +function convertParameter(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, PARAMETER_FIELDS) } -function convertMultipartEncoding(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, MULTIPART_ENCODING_FIELDS) +function convertMediaType(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, MEDIA_TYPE_FIELDS) } function convertRequestBody(value: unknown, ctx: Context): unknown { @@ -495,67 +496,66 @@ function convertResponse(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, RESPONSE_FIELDS) } -function convertLink(value: unknown, ctx: Context): unknown { - return hasDanglingOperationRef(value, ctx) ? DROP : clone(value) -} - -function finishOperation(out: Record): unknown { +// 3.0 requires `responses`. +function finishOperation(out: Record): void { out.responses ??= { default: { description: '' } } - return out } function convertOperation(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, OPERATION_FIELDS, finishOperation) } -function convertPathItem(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, PATH_ITEM_FIELDS, finishPathItem) +// The target's fields fill in for those the referencing Path Item lacks. +function finishMergedPathItem(out: Record, pathItem: Record, ctx: Context): void { + const target = inline(pathItem.$ref as string, ctx, convertPathItem) + if (isRecord(target)) { + for (const [key, item] of Object.entries(target)) { + out[key] ??= item + } + } } -function schemeType(name: string, ctx: Context): unknown { - let scheme = child(ctx.resolve('#/components/securitySchemes'), name) - const ref = getRef(scheme) - if (ref !== undefined) { - const end = ctx.aliasEnd(ref) - scheme = end === undefined ? undefined : ctx.resolve(end) - } - return isRecord(scheme) ? scheme.type : undefined +function convertPathItem(value: unknown, ctx: Context): unknown { + const ref = getRef(value) + return ref !== undefined && isInlined(ref, ctx) + ? convertObject(value, ctx, MERGED_PATH_ITEM_FIELDS, finishMergedPathItem) + : convertObject(value, ctx, PATH_ITEM_FIELDS) } -function convertSecurityScheme(value: unknown): unknown { - return isRecord(value) && value.type === 'mutualTLS' ? DROP : clone(value) +function schemeType(scheme: unknown, ctx: Context): unknown { + const ref = getRef(scheme) + const target = ref === undefined ? scheme : resolve(ref, ctx) + return isRecord(target) ? target.type : undefined } -function convertRequirement(value: unknown, ctx: Context): unknown { - if (!isRecord(value)) { +// 3.0 has no mutual TLS, so security requirements lose the schemes that use +// it. A requirement left empty is removed, and so is a list left empty, +// because an empty one would mean that no security is needed. 3.0 also +// allows scopes only for OAuth2 and OpenID Connect. +function convertSecurity(value: unknown, ctx: Context): unknown { + if (!Array.isArray(value)) { return clone(value) } - const out: Record = {} - let removed = false - for (const [name, scopes] of Object.entries(value)) { - if (scopes === undefined) { + const schemes = resolvePointer('#/components/securitySchemes', ctx) + const typeOf = (name: string): unknown => (isRecord(schemes) && Object.hasOwn(schemes, name) ? schemeType(schemes[name], ctx) : undefined) + const out: unknown[] = [] + for (const requirement of value) { + if (!isRecord(requirement)) { + out.push(clone(requirement)) continue } - const type = schemeType(name, ctx) - if (type === 'mutualTLS') { - removed = true - } - else { - setOwn(out, name, Array.isArray(scopes) && (type === 'apiKey' || type === 'http') ? [] : clone(scopes)) + const entries = Object.keys(requirement).filter(name => requirement[name] !== undefined).map(name => [name, typeOf(name)] as const) + const kept = entries.filter(([, type]) => type !== 'mutualTLS') + if (kept.length > 0 || entries.length === 0) { + out.push(Object.fromEntries(kept.map(([name, type]) => [name, type === 'apiKey' || type === 'http' ? [] : clone(requirement[name])]))) } } - return removed && Object.keys(out).length === 0 ? DROP : out + return out.length === 0 && value.length > 0 ? DROP : out } -function convertSecurity(value: unknown, ctx: Context): unknown { - const out = convertRequirements(value, ctx) - return Array.isArray(value) && value.length > 0 && (out as unknown[]).length === 0 ? DROP : out -} - -function finishDocument(out: Record): unknown { +function finishDocument(out: Record): void { out.openapi = '3.0.4' out.paths ??= {} - return out } function convertDocument(value: unknown, ctx: Context): unknown { @@ -563,7 +563,7 @@ function convertDocument(value: unknown, ctx: Context): unknown { } export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject { - return downgrade(spec, convertDocument, REMOVED) as OpenAPIV3_0.OpenAPIObject + return downgrade(spec, convertDocument) as OpenAPIV3_0.OpenAPIObject } export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index bf13c56..fc60963 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -1,50 +1,49 @@ import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' -import type { Context } from './shared' +import type { Context, Convert } from './shared' import { - allOfItems, clone, - convertMappingRef, convertObject, - convertXml, defineFields, downgrade, DROP, - enterSchema, - getBareRef, getRef, - has, - hasDanglingOperationRef, + hasType, HTTP_METHODS, inline, - inlineSchema, isNotExtension, isPath, isRecord, - keepSchemaRef, list, map, - mergeRef, - refOr, - removedPrefixes, - skipAliases, + resolve, } from './shared' const V32_DIALECT_PREFIX = 'https://spec.openapis.org/oas/3.2/dialect/' const V31_DIALECT = 'https://spec.openapis.org/oas/3.1/dialect/base' +// Both versions use JSON Schema 2020-12, so a schema only changes in +// `discriminator` and `xml`. These keywords hold the subschemas to look into. +const SUBSCHEMA_KEYWORDS = ['additionalProperties', 'contains', 'contentSchema', 'else', 'if', 'items', 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties'] +const SUBSCHEMA_MAP_KEYWORDS = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'] +const SUBSCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] + const convertCallback = map(convertPathItem, isNotExtension) -const convertContent = map(convertContentEntry) +const convertContent = map(convertMediaTypeEntry) const convertServers = list(convertServer) -const finishPathItem = mergeRef(convertPathItem) -const convertCallbackRef = refOr(convertCallback) -const convertExampleRef = refOr(convertExample) -const convertLinkRef = refOr(convertLink) -const convertParameterRef = refOr(convertParameter) -const convertRequestBodyRef = refOr(convertRequestBody) -const convertResponseRef = refOr(convertResponse) -const convertSecuritySchemeRef = refOr(convertSecurityScheme) + +const DISCRIMINATOR_FIELDS = defineFields({ + defaultMapping: DROP, +}) + +const SCHEMA_FIELDS = defineFields({ + ...Object.fromEntries(SUBSCHEMA_KEYWORDS.map(key => [key, convertSchema])), + ...Object.fromEntries(SUBSCHEMA_MAP_KEYWORDS.map(key => [key, map(convertSchema)])), + ...Object.fromEntries(SUBSCHEMA_LIST_KEYWORDS.map(key => [key, list(convertSchema)])), + discriminator: (item, ctx) => convertObject(item, ctx, DISCRIMINATOR_FIELDS), + xml: convertXml, +}) const SERVER_FIELDS = defineFields({ name: DROP, @@ -56,45 +55,15 @@ const TAG_FIELDS = defineFields({ summary: DROP, }) -const DISCRIMINATOR_FIELDS = defineFields({ - defaultMapping: DROP, - mapping: map(convertMappingRef), -}) - -const SCHEMA_FIELDS = defineFields({ - $defs: map(convertSchema), - $ref: (item, ctx) => (typeof item === 'string' ? (keepSchemaRef(item, ctx) ?? DROP) : clone(item)), - additionalProperties: convertSchema, - allOf: list(convertSchema), - anyOf: list(convertSchema), - contains: convertSchema, - contentSchema: convertSchema, - dependentSchemas: map(convertSchema), - discriminator: (item, ctx) => convertObject(item, ctx, DISCRIMINATOR_FIELDS), - else: convertSchema, - if: convertSchema, - items: convertSchema, - not: convertSchema, - oneOf: list(convertSchema), - patternProperties: map(convertSchema), - prefixItems: list(convertSchema), - properties: map(convertSchema), - propertyNames: convertSchema, - then: convertSchema, - unevaluatedItems: convertSchema, - unevaluatedProperties: convertSchema, - xml: convertXml, -}) - const EXAMPLE_FIELDS = defineFields({ dataValue: DROP, serializedValue: DROP, }) const PARAMETER_FIELDS = defineFields({ - allowReserved: (item, _ctx, parameter) => (!has(parameter, 'in') || parameter.in === 'query' ? clone(item) : DROP), + allowReserved: (item, _ctx, parameter) => (parameter.in === 'query' ? clone(item) : DROP), content: convertContent, - examples: map(convertExampleRef), + examples: map(refOr(convertExample)), schema: convertSchema, style: item => (item === 'cookie' ? DROP : clone(item)), }) @@ -109,7 +78,7 @@ const ENCODING_FIELDS = defineFields({ const MEDIA_TYPE_FIELDS = defineFields({ description: DROP, encoding: map(convertEncoding), - examples: map(convertExampleRef), + examples: map(refOr(convertExample)), itemEncoding: DROP, itemSchema: DROP, prefixEncoding: DROP, @@ -123,7 +92,7 @@ const REQUEST_BODY_FIELDS = defineFields({ const RESPONSE_FIELDS = defineFields({ content: convertContent, headers: map(convertParameterRef), - links: map(convertLinkRef), + links: map(refOr(convertLink)), summary: DROP, }) @@ -142,10 +111,10 @@ const SECURITY_SCHEME_FIELDS = defineFields({ }) const OPERATION_FIELDS = defineFields({ - callbacks: map(convertCallbackRef), + callbacks: map(refOr(convertCallback)), parameters: list(convertParameterRef), - requestBody: convertRequestBodyRef, - responses: map(convertResponseRef, isNotExtension), + requestBody: refOr(convertRequestBody), + responses: map(refOr(convertResponse), isNotExtension), servers: convertServers, }) @@ -158,17 +127,17 @@ const PATH_ITEM_FIELDS = defineFields({ }) const COMPONENTS_FIELDS = defineFields({ - callbacks: map(convertCallbackRef), - examples: map(convertExampleRef), + callbacks: map(refOr(convertCallback)), + examples: map(refOr(convertExample)), headers: map(convertParameterRef), - links: map(convertLinkRef), + links: map(refOr(convertLink)), mediaTypes: DROP, parameters: map(convertParameterRef), pathItems: map(convertPathItem), - requestBodies: map(convertRequestBodyRef), - responses: map(convertResponseRef), + requestBodies: map(refOr(convertRequestBody)), + responses: map(refOr(convertResponse)), schemas: map(convertSchema), - securitySchemes: map(convertSecuritySchemeRef), + securitySchemes: map(refOr(convertSecurityScheme)), }) const DOCUMENT_FIELDS = defineFields({ @@ -181,106 +150,114 @@ const DOCUMENT_FIELDS = defineFields({ webhooks: map(convertPathItem), }) -const REMOVED = removedPrefixes({ '': DOCUMENT_FIELDS, '/components': COMPONENTS_FIELDS }) - -function convertServer(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, SERVER_FIELDS) +/** Reference Objects are the same in 3.1, so they are copied as they are. */ +function refOr(convert: Convert): Convert { + return (value, ctx) => (getRef(value) === undefined ? convert(value, ctx) : clone(value)) } -function convertTag(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, TAG_FIELDS) +function convertSchema(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, SCHEMA_FIELDS) } -function finishSchema(out: Record, schema: Record, ctx: Context): unknown { - if (ctx.identified.has(schema)) { - delete out.$id - delete out.$anchor - delete out.$dynamicAnchor +function convertXml(value: unknown, _ctx: Context, schema: Record): unknown { + if (!isRecord(value)) { + return clone(value) } - else if (has(schema, '$id') || has(schema, '$anchor') || has(schema, '$dynamicAnchor')) { - ctx.identified.add(schema) + const { nodeType, ...out } = clone(value) as Record + if (nodeType === 'attribute') { + out.attribute = true } - if (typeof schema.$ref === 'string' && !('$ref' in out)) { - const target = inlineSchema(schema.$ref, ctx, convertSchema) - if (target !== DROP) { - out.allOf = [...allOfItems(out.allOf), target] - } + else if (nodeType === 'element' && hasType(schema.type, 'array')) { + out.wrapped = true } return out } -function convertSchema(value: unknown, ctx: Context): unknown { - const ref = getBareRef(value) - const out = ref !== undefined && keepSchemaRef(ref, ctx) === undefined - ? inlineSchema(ref, ctx, convertSchema) - : convertObject(value, enterSchema(value, ctx, !ctx.identified.has(value)), SCHEMA_FIELDS, finishSchema) - return out === DROP ? {} : out +function convertServer(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, SERVER_FIELDS) } -function finishExample(out: Record, example: Record): unknown { - if (!(has(example, 'value') || has(example, 'externalValue'))) { - if (has(example, 'dataValue')) { - out.value = clone(example.dataValue) - } - else if (has(example, 'serializedValue')) { - out.value = clone(example.serializedValue) +// A summary stands in for the description it lacks. +function finishTag(out: Record, tag: Record): void { + if (out.description === undefined && typeof tag.summary === 'string') { + out.description = tag.summary + } +} + +function convertTag(value: unknown, ctx: Context): unknown { + return convertObject(value, ctx, TAG_FIELDS, finishTag) +} + +function finishExample(out: Record, example: Record): void { + if (example.value === undefined && example.externalValue === undefined) { + const value = example.dataValue !== undefined ? example.dataValue : example.serializedValue + if (value !== undefined) { + out.value = clone(value) } } - return out } function convertExample(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, EXAMPLE_FIELDS, finishExample) } -function finishParameter(out: Record, parameter: Record): unknown { - if (!isRecord(parameter.content)) { - return out - } - if (Object.keys(out.content as object).length === 0) { - return Object.values(parameter.content).some(item => item !== undefined) ? DROP : out +function isQuerystring(parameter: unknown): boolean { + return isRecord(parameter) && parameter.in === 'querystring' +} + +// 3.1 allows these only beside `schema`. The media type in `content` +// carries its own examples and serialization. +function finishParameter(out: Record, parameter: Record): void { + if (parameter.content !== undefined) { + delete out.allowReserved + delete out.example + delete out.examples + delete out.explode + delete out.style } - delete out.example - delete out.examples - return out } function convertParameter(value: unknown, ctx: Context): unknown { - if (isRecord(value) && value.in === 'querystring') { - return DROP + return isQuerystring(value) ? DROP : convertObject(value, ctx, PARAMETER_FIELDS, finishParameter) +} + +function convertParameterRef(value: unknown, ctx: Context): unknown { + const ref = getRef(value) + if (ref === undefined) { + return convertParameter(value, ctx) } - return convertObject(value, ctx, PARAMETER_FIELDS, finishParameter) + return isQuerystring(resolve(ref, ctx)) ? DROP : clone(value) } function convertEncoding(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, ENCODING_FIELDS) } -function finishMediaType(out: Record, mediaType: Record, ctx: Context): unknown { - if (has(mediaType, 'itemSchema') && !has(mediaType, 'schema')) { +function finishMediaType(out: Record, mediaType: Record, ctx: Context): void { + if (mediaType.itemSchema !== undefined && mediaType.schema === undefined) { out.schema = { items: convertSchema(mediaType.itemSchema, ctx), type: 'array' } } - return out } function convertMediaType(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, MEDIA_TYPE_FIELDS, finishMediaType) } -function convertContentEntry(value: unknown, ctx: Context): unknown { +// A 3.1 content map cannot hold a `$ref`, such as one to `components.mediaTypes`. +function convertMediaTypeEntry(value: unknown, ctx: Context): unknown { const ref = getRef(value) - return ref === undefined ? convertMediaType(value, ctx) : inline(skipAliases(ref, ctx, () => true), ctx, convertContentEntry) + return ref === undefined ? convertMediaType(value, ctx) : inline(ref, ctx, convertMediaTypeEntry) } function convertRequestBody(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, REQUEST_BODY_FIELDS) } -function finishResponse(out: Record, response: Record): unknown { - if (!('description' in out)) { +// 3.1 requires a description, which a summary can stand in for. +function finishResponse(out: Record, response: Record): void { + if (out.description === undefined) { out.description = typeof response.summary === 'string' ? response.summary : '' } - return out } function convertResponse(value: unknown, ctx: Context): unknown { @@ -288,7 +265,7 @@ function convertResponse(value: unknown, ctx: Context): unknown { } function convertLink(value: unknown, ctx: Context): unknown { - return hasDanglingOperationRef(value, ctx) ? DROP : convertObject(value, ctx, LINK_FIELDS) + return convertObject(value, ctx, LINK_FIELDS) } function convertSecurityScheme(value: unknown, ctx: Context): unknown { @@ -300,12 +277,11 @@ function convertOperation(value: unknown, ctx: Context): unknown { } function convertPathItem(value: unknown, ctx: Context): unknown { - return convertObject(value, ctx, PATH_ITEM_FIELDS, finishPathItem) + return convertObject(value, ctx, PATH_ITEM_FIELDS) } -function finishDocument(out: Record): unknown { +function finishDocument(out: Record): void { out.openapi = '3.1.2' - return out } function convertDocument(value: unknown, ctx: Context): unknown { @@ -313,7 +289,7 @@ function convertDocument(value: unknown, ctx: Context): unknown { } export function downgradeSpecV32ToV31(spec: OpenAPIV3_2.OpenAPIObject): OpenAPIV3_1.OpenAPIObject { - return downgrade(spec, convertDocument, REMOVED) as OpenAPIV3_1.OpenAPIObject + return downgrade(spec, convertDocument) as OpenAPIV3_1.OpenAPIObject } export function downgradeSchemaV32ToV31(schema: OpenAPIV3_2.SchemaObject): OpenAPIV3_1.SchemaObject { diff --git a/packages/downgrader/tests/__snapshots__/chained.test.ts.snap b/packages/downgrader/tests/__snapshots__/chained.test.ts.snap deleted file mode 100644 index 908a7ef..0000000 --- a/packages/downgrader/tests/__snapshots__/chained.test.ts.snap +++ /dev/null @@ -1,141 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`official examples > converts the mega document 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[`official examples > converts the query example 1`] = ` -{ - "info": { - "title": "Flight API", - "version": "1.0.0", - }, - "openapi": "3.0.4", - "paths": { - "/flights/search": {}, - }, -} -`; - -exports[`official examples > converts the tags example 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", - }, - ], -} -`; diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap b/packages/downgrader/tests/__snapshots__/corpus.test.ts.snap similarity index 62% rename from packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap rename to packages/downgrader/tests/__snapshots__/corpus.test.ts.snap index fded8fc..7a13fb7 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/__snapshots__/corpus.test.ts.snap +++ b/packages/downgrader/tests/__snapshots__/corpus.test.ts.snap @@ -1,85 +1,48 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`official examples > flattens the tag hierarchy of the tags example 1`] = ` +exports[`matches the snapshots of the 3.2 mega document > 3.0 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", - ], + "components": { + "schemas": { + "Foo": { + "properties": { + "type": { + "enum": [ + "foo", + ], + }, + }, + "type": "object", }, }, + "securitySchemes": {}, }, - "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[`official examples > removes the QUERY operation of the query example, leaving an empty path item 1`] = ` -{ "info": { - "title": "Flight API", + "description": "My API's summary", + "license": { + "name": "Apache 2.0", + }, + "title": "My API", "version": "1.0.0", }, - "openapi": "3.1.2", + "openapi": "3.0.4", "paths": { - "/flights/search": {}, + "/": { + "get": { + "parameters": [], + "responses": { + "default": { + "description": "", + }, + }, + }, + }, + "/{pathTest}": {}, }, } `; -exports[`official examples > removes the discriminator defaultMapping of the mega document 1`] = ` +exports[`matches the snapshots of the 3.2 mega document > 3.1 1`] = ` { "components": { "pathItems": { diff --git a/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap b/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap new file mode 100644 index 0000000..407da2c --- /dev/null +++ b/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap @@ -0,0 +1,1051 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`matches the 3.0 snapshot 1`] = ` +{ + "components": { + "schemas": { + "BadRequest": { + "properties": { + "code": { + "enum": [ + "BAD_REQUEST", + ], + }, + "data": { + "additionalProperties": false, + "properties": { + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + }, + "path": { + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "number", + }, + ], + }, + "type": "array", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "issues", + ], + "type": "object", + }, + "defined": { + "enum": [ + true, + ], + }, + "message": { + "type": "string", + }, + }, + "required": [ + "defined", + "code", + "message", + "data", + ], + "title": "BAD_REQUEST", + "type": "object", + }, + "Category": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "$ref": "#/components/schemas/Category", + }, + "type": "array", + }, + "name": { + "type": "string", + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category", + }, + { + "enum": [ + null, + ], + }, + ], + }, + }, + "required": [ + "name", + "parent", + "children", + ], + "type": "object", + }, + "Conflict": { + "properties": { + "code": { + "enum": [ + "CONFLICT", + ], + }, + "data": {}, + "defined": { + "enum": [ + true, + ], + }, + "message": { + "type": "string", + }, + }, + "required": [ + "defined", + "code", + "message", + ], + "title": "CONFLICT", + "type": "object", + }, + "NotFound": { + "properties": { + "code": { + "enum": [ + "NOT_FOUND", + ], + }, + "data": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + "defined": { + "enum": [ + true, + ], + }, + "message": { + "type": "string", + }, + }, + "required": [ + "defined", + "code", + "message", + "data", + ], + "title": "NOT_FOUND", + "type": "object", + }, + "Planet": { + "additionalProperties": false, + "properties": { + "aliases": { + "items": { + "items": { + "type": "string", + }, + "maxItems": 2, + "minItems": 2, + "type": "array", + }, + "type": "array", + "x-native-type": "map", + }, + "attributes": { + "additionalProperties": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "number", + }, + { + "type": "boolean", + }, + ], + }, + "type": "object", + }, + "description": { + "nullable": true, + "type": "string", + }, + "discoveredAt": { + "format": "date-time", + "type": "string", + "x-native-type": "date", + }, + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "kind": { + "enum": [ + "rocky", + "gas", + "ice", + ], + "type": "string", + }, + "mass": { + "example": 5.97e+24, + "exclusiveMinimum": true, + "minimum": 0, + "type": "number", + }, + "moons": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "name": { + "description": "The planet name", + "minLength": 1, + "type": "string", + }, + "population": { + "pattern": "^-?[0-9]+$", + "type": "string", + "x-native-type": "bigint", + }, + "position": { + "items": { + "type": "number", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "tags": { + "items": { + "type": "string", + }, + "type": "array", + "uniqueItems": true, + "x-native-type": "set", + }, + }, + "required": [ + "id", + "name", + "description", + "mass", + "kind", + "discoveredAt", + "position", + "tags", + "aliases", + "attributes", + ], + "type": "object", + }, + "UndefinedError": { + "properties": { + "code": { + "type": "string", + }, + "data": {}, + "defined": { + "enum": [ + false, + ], + }, + "message": { + "type": "string", + }, + }, + "required": [ + "defined", + "code", + "message", + ], + "title": "UndefinedError", + "type": "object", + }, + }, + "securitySchemes": { + "bearer": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http", + }, + }, + }, + "info": { + "description": "Planets and their categories", + "title": "Planets", + "version": "1.0.0", + }, + "openapi": "3.0.4", + "paths": { + "/categories": { + "get": { + "operationId": "categories", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Category", + }, + "type": "array", + }, + }, + }, + "description": "OK", + }, + }, + }, + }, + "/ping": { + "head": { + "operationId": "ping", + "responses": { + "200": { + "content": {}, + "description": "OK", + }, + }, + }, + }, + "/planets": { + "get": { + "operationId": "planets.list", + "parameters": [ + { + "allowEmptyValue": true, + "allowReserved": true, + "in": "query", + "name": "limit", + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "type": "integer", + }, + }, + { + "allowEmptyValue": true, + "allowReserved": true, + "in": "query", + "name": "cursor", + "schema": { + "type": "string", + }, + }, + { + "allowEmptyValue": true, + "allowReserved": true, + "explode": true, + "in": "query", + "name": "filter", + "schema": { + "properties": { + "kind": { + "enum": [ + "rocky", + "gas", + "ice", + ], + "type": "string", + }, + "name": { + "type": "string", + }, + }, + "type": "object", + }, + "style": "deepObject", + }, + { + "allowEmptyValue": true, + "allowReserved": true, + "explode": true, + "in": "query", + "name": "ids", + "schema": { + "items": { + "type": "string", + }, + "type": "array", + }, + "style": "deepObject", + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Planet", + }, + "type": "array", + }, + "next": { + "nullable": true, + "type": "string", + }, + }, + "required": [ + "items", + "next", + ], + "type": "object", + }, + }, + }, + "description": "OK", + }, + }, + "summary": "List planets", + "tags": [ + "planets", + ], + }, + "post": { + "deprecated": true, + "operationId": "planets.create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "aliases": { + "items": { + "items": { + "type": "string", + }, + "maxItems": 2, + "minItems": 2, + "type": "array", + }, + "type": "array", + "x-native-type": "map", + }, + "attributes": { + "additionalProperties": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "number", + }, + { + "type": "boolean", + }, + ], + }, + "type": "object", + }, + "description": { + "nullable": true, + "type": "string", + }, + "discoveredAt": { + "format": "date-time", + "type": "string", + "x-native-type": "date", + }, + "kind": { + "enum": [ + "rocky", + "gas", + "ice", + ], + "type": "string", + }, + "mass": { + "example": 5.97e+24, + "exclusiveMinimum": true, + "minimum": 0, + "type": "number", + }, + "moons": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "name": { + "description": "The planet name", + "minLength": 1, + "type": "string", + }, + "population": { + "pattern": "^-?[0-9]+$", + "type": "string", + "x-native-type": "bigint", + }, + "position": { + "items": { + "type": "number", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "tags": { + "items": { + "type": "string", + }, + "type": "array", + "uniqueItems": true, + "x-native-type": "set", + }, + }, + "required": [ + "name", + "description", + "mass", + "kind", + "discoveredAt", + "position", + "tags", + "aliases", + "attributes", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Planet", + }, + }, + }, + "description": "OK", + }, + "400": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/BadRequest", + }, + { + "$ref": "#/components/schemas/UndefinedError", + }, + ], + }, + }, + }, + "description": "400", + }, + "409": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Conflict", + }, + { + "$ref": "#/components/schemas/UndefinedError", + }, + ], + }, + }, + }, + "description": "409", + }, + }, + "tags": [ + "planets", + ], + }, + }, + "/planets/events": { + "get": { + "operationId": "planets.events", + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { + "oneOf": [ + { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "planet": { + "$ref": "#/components/schemas/Planet", + }, + "type": { + "enum": [ + "created", + ], + "type": "string", + }, + }, + "required": [ + "type", + "planet", + ], + "type": "object", + }, + "event": { + "enum": [ + "message", + ], + }, + "id": { + "type": "string", + }, + "retry": { + "type": "number", + }, + }, + "required": [ + "event", + "data", + ], + "type": "object", + }, + { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "total": { + "type": "number", + }, + }, + "required": [ + "total", + ], + "type": "object", + }, + "event": { + "enum": [ + "close", + ], + }, + "id": { + "type": "string", + }, + "retry": { + "type": "number", + }, + }, + "required": [ + "event", + "data", + ], + "type": "object", + }, + { + "properties": { + "data": {}, + "event": { + "enum": [ + "error", + ], + }, + "id": { + "type": "string", + }, + "retry": { + "type": "number", + }, + }, + "required": [ + "event", + ], + "type": "object", + }, + ], + }, + }, + }, + "description": "OK", + }, + }, + }, + }, + "/planets/{id}": { + "get": { + "operationId": "planets.find", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "allowEmptyValue": true, + "allowReserved": true, + "explode": false, + "in": "query", + "name": "fields", + "schema": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Planet", + }, + { + "enum": [ + null, + ], + }, + ], + }, + }, + }, + "description": "OK", + }, + "404": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/NotFound", + }, + { + "$ref": "#/components/schemas/UndefinedError", + }, + ], + }, + }, + }, + "description": "Planet not found", + }, + }, + "tags": [ + "planets", + ], + }, + "patch": { + "operationId": "planets.update", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "allowEmptyValue": true, + "allowReserved": true, + "in": "query", + "name": "dryRun", + "schema": { + "type": "boolean", + }, + }, + { + "in": "header", + "name": "if-match", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "aliases": { + "items": { + "items": { + "type": "string", + }, + "maxItems": 2, + "minItems": 2, + "type": "array", + }, + "type": "array", + "x-native-type": "map", + }, + "attributes": { + "additionalProperties": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "number", + }, + { + "type": "boolean", + }, + ], + }, + "type": "object", + }, + "description": { + "nullable": true, + "type": "string", + }, + "discoveredAt": { + "format": "date-time", + "type": "string", + "x-native-type": "date", + }, + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string", + }, + "kind": { + "enum": [ + "rocky", + "gas", + "ice", + ], + "type": "string", + }, + "mass": { + "example": 5.97e+24, + "exclusiveMinimum": true, + "minimum": 0, + "type": "number", + }, + "moons": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "name": { + "description": "The planet name", + "minLength": 1, + "type": "string", + }, + "population": { + "pattern": "^-?[0-9]+$", + "type": "string", + "x-native-type": "bigint", + }, + "position": { + "items": { + "type": "number", + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + }, + "tags": { + "items": { + "type": "string", + }, + "type": "array", + "uniqueItems": true, + "x-native-type": "set", + }, + }, + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Planet", + }, + }, + }, + "description": "OK", + "headers": { + "etag": { + "required": true, + "schema": { + "type": "string", + }, + }, + }, + }, + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "jobId": { + "type": "string", + }, + }, + "required": [ + "jobId", + ], + "type": "object", + }, + }, + }, + "description": "Accepted", + }, + }, + }, + }, + "/planets/{id}/image": { + "get": { + "operationId": "planets.image", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "image/jpeg": { + "schema": { + "format": "binary", + "type": "string", + }, + }, + "image/png": { + "schema": { + "format": "binary", + "type": "string", + }, + }, + }, + "description": "OK", + }, + }, + }, + "put": { + "operationId": "planets.upload", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "caption": { + "type": "string", + }, + "image": { + "format": "binary", + "type": "string", + }, + }, + "required": [ + "image", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "url": { + "format": "uri", + "type": "string", + }, + }, + "required": [ + "url", + ], + "type": "object", + }, + }, + }, + "description": "OK", + }, + }, + }, + }, + }, + "security": [ + { + "bearer": [], + }, + ], + "servers": [ + { + "url": "https://api.example.com", + }, + ], + "tags": [ + { + "description": "Planets", + "name": "planets", + }, + ], +} +`; diff --git a/packages/downgrader/tests/chained.test.ts b/packages/downgrader/tests/chained.test.ts deleted file mode 100644 index bddfddb..0000000 --- a/packages/downgrader/tests/chained.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// There is no direct 3.2 → 3.0 converter on purpose: the two steps compose -// (see the package README). Every official 3.2 document (see corpus.ts) must -// come out of both steps as a valid 3.0 document. - -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' - -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 mega } from '../../types/tests/schema-tests-3.2/mega' -import { corpusV32 } from './corpus' -import { expectValidDowngrade } from './validate' - -function downgradeTwice(doc: OpenAPIV3_2.OpenAPIObject) { - return downgradeSpecV31ToV30(downgradeSpecV32ToV31(doc)) -} - -// The 3.2 → 3.1 step is validated by v3.2-to-v3.1/spec/corpus.test.ts. -describe('official corpus', () => { - it.each(corpusV32)('converts %s to a valid 3.0 document', async (_name, doc) => { - await expectValidDowngrade(doc, downgradeTwice, '3.2', '3.0') - }) -}) - -describe('official examples', () => { - it('converts the query example', () => { - expect(downgradeTwice(queryExample)).toMatchSnapshot() - }) - - it('converts the tags example', () => { - expect(downgradeTwice(tagsExample)).toMatchSnapshot() - }) - - it('converts the mega document', () => { - const v30 = downgradeTwice(mega) - expect(v30.components).not.toHaveProperty('pathItems') - expect(v30).not.toHaveProperty('webhooks') - expect(v30).toMatchSnapshot() - }) -}) diff --git a/packages/downgrader/tests/corpus.test.ts b/packages/downgrader/tests/corpus.test.ts new file mode 100644 index 0000000..001a93b --- /dev/null +++ b/packages/downgrader/tests/corpus.test.ts @@ -0,0 +1,31 @@ +// Every official document (see corpus.ts) must come out of each step, and of +// both steps chained, as a valid document of the older version. + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { doc as mega } from '../../types/tests/schema-tests-3.2/mega' +import { corpusV31, corpusV32 } from './corpus' +import { expectValidDowngrade } from './validate' + +function downgradeTwice(doc: OpenAPIV3_2.OpenAPIObject) { + return downgradeSpecV31ToV30(downgradeSpecV32ToV31(doc)) +} + +it.each(corpusV32)('converts the 3.2 %s to valid 3.1', async (_name, doc) => { + await expectValidDowngrade(doc, downgradeSpecV32ToV31, '3.2', '3.1') +}) + +it.each(corpusV31)('converts the 3.1 %s to valid 3.0', async (_name, doc) => { + await expectValidDowngrade(doc, downgradeSpecV31ToV30, '3.1', '3.0') +}) + +it.each(corpusV32)('converts the 3.2 %s to valid 3.0 through 3.1', async (_name, doc) => { + await expectValidDowngrade(doc, downgradeTwice, '3.2', '3.0') +}) + +it('matches the snapshots of the 3.2 mega document', () => { + expect(downgradeSpecV32ToV31(mega)).toMatchSnapshot('3.1') + expect(downgradeTwice(mega)).toMatchSnapshot('3.0') +}) diff --git a/packages/downgrader/tests/helpers.ts b/packages/downgrader/tests/helpers.ts deleted file mode 100644 index de1e5e7..0000000 --- a/packages/downgrader/tests/helpers.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect } from 'vitest' - -/** - * Reads a nested value, one own key per step, so assertions can reach deep - * into a converted document without optional chaining at every level. - */ -export function dig(value: unknown, ...path: string[]): unknown { - let current: unknown = value - for (const key of path) { - current = (current as Record)[key] - } - return current -} - -/** A converted document must stay plain JSON, which cannot hold a cycle. */ -export function expectAcyclic(value: unknown): void { - expect(() => JSON.stringify(value)).not.toThrow() -} - -/** - * Copies `object`, turning its `key` field into a getter that adds one to - * `reads.count` on every read, so a test can bound how many times the - * conversion walks into that field. - */ -export function countReads>(object: T, key: keyof T & string, reads: { count: number }): T { - const value = object[key] - return Object.defineProperty({ ...object }, key, { - enumerable: true, - get: () => { - reads.count++ - return value - }, - }) -} - -/** - * Builds Path Items `base` and `h0` … `h`, keyed by name, where every - * `h` has a `$ref` to `base` and callbacks pointing at every `h`, - * itself included. `pointer` says where they live. `reads` counts reads of - * each `h`'s operation, that is how many times its own fields are converted. - */ -export function cyclicCallbackGraph(k: number, pointer: (name: string) => string, reads: { count: number }): Record { - const responses = { 200: { description: 'ok' } } - const items: Record = { base: { get: { responses } } } - for (let i = 0; i < k; i++) { - const callbacks = Object.fromEntries(Array.from({ length: k }, (_, j) => [`c${j}`, { '{$request.body#/url}': { $ref: pointer(`h${j}`) } }])) - items[`h${i}`] = countReads({ $ref: pointer('base'), post: { callbacks, responses } }, 'post', reads) - } - return items -} - -/** - * Keys whose presence decides how one of the converters treats an object: - * what a schema loses, which example field fills `value`, whether a response - * has a description, and so on. - */ -const PRESENCE_KEYS = [ - '$anchor', - '$dynamicAnchor', - '$dynamicRef', - '$id', - '$ref', - 'allowReserved', - 'const', - 'contains', - 'contentType', - 'dataValue', - 'dependentRequired', - 'dependentSchemas', - 'description', - 'else', - 'enum', - 'example', - 'explode', - 'externalValue', - 'if', - 'in', - 'itemSchema', - 'maxContains', - 'minContains', - 'patternProperties', - 'prefixItems', - 'propertyNames', - 'schema', - 'serializedValue', - 'style', - 'then', - 'unevaluatedItems', - 'unevaluatedProperties', - 'value', -] - -/** - * Copies the JSON-like `value`, adding to every object each of the - * `PRESENCE_KEYS` it lacks, holding `undefined`, as builders that spread options often do. JSON - * drops such keys, so the copy serializes exactly like `value`. - */ -export function withUndefinedKeys(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(withUndefinedKeys) - } - if (typeof value !== 'object' || value === null) { - return value - } - const out = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withUndefinedKeys(item)])) - for (const key of PRESENCE_KEYS) { - if (!Object.hasOwn(out, key)) { - out[key] = undefined - } - } - return out -} diff --git a/packages/downgrader/tests/orpc-document.ts b/packages/downgrader/tests/orpc-document.ts new file mode 100644 index 0000000..c092293 --- /dev/null +++ b/packages/downgrader/tests/orpc-document.ts @@ -0,0 +1,1081 @@ +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +// A document generated by `OpenAPIGenerator` from @orpc/openapi +// 2.0.0-beta.41 with `ZodToJsonSchemaConverter` from @orpc/zod and zod 4, +// before any downgrade. oRPC always generates 3.2 and downgrades when an +// older version is asked for, so this is what both converters see from it. +// +// The router covers what oRPC emits: path, query (deepObject, comma-delimited), +// header, and body inputs; JSON, multipart, file, and event-stream bodies; +// detailed outputs with response headers; typed errors; and schemas with +// nullable fields, tuples, sets, maps, records, dates, bigints, enums, +// numeric exclusive bounds, examples, registry components, and recursion. +export const doc: OpenAPIV3_2.OpenAPIObject = { + info: { + title: 'Planets', + version: '1.0.0', + summary: 'Planets and their categories', + }, + servers: [ + { + url: 'https://api.example.com', + name: 'production', + }, + ], + tags: [ + { + name: 'planets', + summary: 'Planets', + kind: 'nav', + }, + ], + components: { + securitySchemes: { + bearer: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + schemas: { + Planet: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', + }, + name: { + type: 'string', + minLength: 1, + description: 'The planet name', + }, + description: { + type: [ + 'string', + 'null', + ], + }, + mass: { + type: 'number', + exclusiveMinimum: 0, + examples: [ + 5.97e+24, + ], + }, + moons: { + type: 'integer', + minimum: 0, + maximum: 9007199254740991, + }, + kind: { + type: 'string', + enum: [ + 'rocky', + 'gas', + 'ice', + ], + }, + discoveredAt: { + 'type': 'string', + 'format': 'date-time', + 'x-native-type': 'date', + }, + position: { + type: 'array', + prefixItems: [ + { + type: 'number', + }, + { + type: 'number', + }, + { + type: 'number', + }, + ], + items: false, + minItems: 3, + maxItems: 3, + }, + tags: { + 'type': 'array', + 'uniqueItems': true, + 'items': { + type: 'string', + }, + 'x-native-type': 'set', + }, + aliases: { + 'type': 'array', + 'items': { + type: 'array', + prefixItems: [ + { + type: 'string', + }, + { + type: 'string', + }, + ], + maxItems: 2, + minItems: 2, + }, + 'x-native-type': 'map', + }, + attributes: { + type: 'object', + propertyNames: { + type: 'string', + }, + additionalProperties: { + type: [ + 'string', + 'number', + 'boolean', + ], + }, + }, + population: { + 'type': 'string', + 'pattern': '^-?[0-9]+$', + 'x-native-type': 'bigint', + }, + }, + required: [ + 'id', + 'name', + 'description', + 'mass', + 'kind', + 'discoveredAt', + 'position', + 'tags', + 'aliases', + 'attributes', + ], + additionalProperties: false, + }, + UndefinedError: { + title: 'UndefinedError', + type: 'object', + properties: { + defined: { + const: false, + }, + code: { + type: 'string', + }, + message: { + type: 'string', + }, + data: {}, + }, + required: [ + 'defined', + 'code', + 'message', + ], + }, + NotFound: { + title: 'NOT_FOUND', + type: 'object', + properties: { + defined: { + const: true, + }, + code: { + const: 'NOT_FOUND', + }, + message: { + type: 'string', + }, + data: { + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + required: [ + 'id', + ], + additionalProperties: false, + }, + }, + required: [ + 'defined', + 'code', + 'message', + 'data', + ], + }, + Conflict: { + title: 'CONFLICT', + type: 'object', + properties: { + defined: { + const: true, + }, + code: { + const: 'CONFLICT', + }, + message: { + type: 'string', + }, + data: {}, + }, + required: [ + 'defined', + 'code', + 'message', + ], + }, + BadRequest: { + title: 'BAD_REQUEST', + type: 'object', + properties: { + defined: { + const: true, + }, + code: { + const: 'BAD_REQUEST', + }, + message: { + type: 'string', + }, + data: { + type: 'object', + properties: { + issues: { + type: 'array', + items: { + type: 'object', + properties: { + path: { + type: 'array', + items: { + type: [ + 'string', + 'number', + ], + }, + }, + message: { + type: 'string', + }, + }, + required: [ + 'path', + 'message', + ], + additionalProperties: false, + }, + }, + }, + required: [ + 'issues', + ], + additionalProperties: false, + }, + }, + required: [ + 'defined', + 'code', + 'message', + 'data', + ], + }, + Category: { + type: 'object', + properties: { + name: { + type: 'string', + }, + parent: { + anyOf: [ + { + $ref: '#/components/schemas/Category', + }, + { + type: 'null', + }, + ], + }, + children: { + type: 'array', + items: { + $ref: '#/components/schemas/Category', + }, + }, + }, + required: [ + 'name', + 'parent', + 'children', + ], + additionalProperties: false, + }, + }, + }, + security: [ + { + bearer: [], + }, + ], + openapi: '3.2.0', + paths: { + '/planets': { + get: { + operationId: 'planets.list', + summary: 'List planets', + tags: [ + 'planets', + ], + parameters: [ + { + in: 'query', + name: 'limit', + schema: { + default: 20, + type: 'integer', + minimum: 1, + maximum: 100, + }, + allowEmptyValue: true, + allowReserved: true, + }, + { + in: 'query', + name: 'cursor', + schema: { + type: 'string', + }, + allowEmptyValue: true, + allowReserved: true, + }, + { + in: 'query', + name: 'filter', + schema: { + type: 'object', + properties: { + kind: { + type: 'string', + enum: [ + 'rocky', + 'gas', + 'ice', + ], + }, + name: { + type: 'string', + }, + }, + }, + allowEmptyValue: true, + allowReserved: true, + style: 'deepObject', + explode: true, + }, + { + in: 'query', + name: 'ids', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + allowEmptyValue: true, + allowReserved: true, + style: 'deepObject', + explode: true, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Planet', + }, + }, + next: { + type: [ + 'string', + 'null', + ], + }, + }, + required: [ + 'items', + 'next', + ], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + post: { + operationId: 'planets.create', + deprecated: true, + tags: [ + 'planets', + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + description: 'The planet name', + }, + description: { + type: [ + 'string', + 'null', + ], + }, + mass: { + type: 'number', + exclusiveMinimum: 0, + examples: [ + 5.97e+24, + ], + }, + moons: { + type: 'integer', + minimum: 0, + maximum: 9007199254740991, + }, + kind: { + type: 'string', + enum: [ + 'rocky', + 'gas', + 'ice', + ], + }, + discoveredAt: { + 'type': 'string', + 'format': 'date-time', + 'x-native-type': 'date', + }, + position: { + type: 'array', + prefixItems: [ + { + type: 'number', + }, + { + type: 'number', + }, + { + type: 'number', + }, + ], + items: false, + minItems: 3, + maxItems: 3, + }, + tags: { + 'type': 'array', + 'uniqueItems': true, + 'items': { + type: 'string', + }, + 'x-native-type': 'set', + }, + aliases: { + 'type': 'array', + 'items': { + type: 'array', + prefixItems: [ + { + type: 'string', + }, + { + type: 'string', + }, + ], + maxItems: 2, + minItems: 2, + }, + 'x-native-type': 'map', + }, + attributes: { + type: 'object', + propertyNames: { + type: 'string', + }, + additionalProperties: { + type: [ + 'string', + 'number', + 'boolean', + ], + }, + }, + population: { + 'type': 'string', + 'pattern': '^-?[0-9]+$', + 'x-native-type': 'bigint', + }, + }, + required: [ + 'name', + 'description', + 'mass', + 'kind', + 'discoveredAt', + 'position', + 'tags', + 'aliases', + 'attributes', + ], + }, + }, + }, + }, + responses: { + 201: { + description: 'OK', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Planet', + }, + }, + }, + }, + 400: { + description: '400', + content: { + 'application/json': { + schema: { + oneOf: [ + { + $ref: '#/components/schemas/BadRequest', + }, + { + $ref: '#/components/schemas/UndefinedError', + }, + ], + }, + }, + }, + }, + 409: { + description: '409', + content: { + 'application/json': { + schema: { + oneOf: [ + { + $ref: '#/components/schemas/Conflict', + }, + { + $ref: '#/components/schemas/UndefinedError', + }, + ], + }, + }, + }, + }, + }, + }, + }, + '/planets/{id}': { + get: { + operationId: 'planets.find', + tags: [ + 'planets', + ], + parameters: [ + { + in: 'path', + required: true, + name: 'id', + schema: { + type: 'string', + }, + }, + { + in: 'query', + name: 'fields', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + allowEmptyValue: true, + allowReserved: true, + explode: false, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + anyOf: [ + { + $ref: '#/components/schemas/Planet', + }, + { + type: 'null', + }, + ], + }, + }, + }, + }, + 404: { + description: 'Planet not found', + content: { + 'application/json': { + schema: { + oneOf: [ + { + $ref: '#/components/schemas/NotFound', + }, + { + $ref: '#/components/schemas/UndefinedError', + }, + ], + }, + }, + }, + }, + }, + }, + patch: { + operationId: 'planets.update', + parameters: [ + { + in: 'path', + required: true, + name: 'id', + schema: { + type: 'string', + }, + }, + { + in: 'query', + name: 'dryRun', + schema: { + type: 'boolean', + }, + allowEmptyValue: true, + allowReserved: true, + }, + { + in: 'header', + name: 'if-match', + required: true, + schema: { + type: 'string', + }, + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', + }, + name: { + type: 'string', + minLength: 1, + description: 'The planet name', + }, + description: { + type: [ + 'string', + 'null', + ], + }, + mass: { + type: 'number', + exclusiveMinimum: 0, + examples: [ + 5.97e+24, + ], + }, + moons: { + type: 'integer', + minimum: 0, + maximum: 9007199254740991, + }, + kind: { + type: 'string', + enum: [ + 'rocky', + 'gas', + 'ice', + ], + }, + discoveredAt: { + 'type': 'string', + 'format': 'date-time', + 'x-native-type': 'date', + }, + position: { + type: 'array', + prefixItems: [ + { + type: 'number', + }, + { + type: 'number', + }, + { + type: 'number', + }, + ], + items: false, + minItems: 3, + maxItems: 3, + }, + tags: { + 'type': 'array', + 'uniqueItems': true, + 'items': { + type: 'string', + }, + 'x-native-type': 'set', + }, + aliases: { + 'type': 'array', + 'items': { + type: 'array', + prefixItems: [ + { + type: 'string', + }, + { + type: 'string', + }, + ], + maxItems: 2, + minItems: 2, + }, + 'x-native-type': 'map', + }, + attributes: { + type: 'object', + propertyNames: { + type: 'string', + }, + additionalProperties: { + type: [ + 'string', + 'number', + 'boolean', + ], + }, + }, + population: { + 'type': 'string', + 'pattern': '^-?[0-9]+$', + 'x-native-type': 'bigint', + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Planet', + }, + }, + }, + headers: { + etag: { + required: true, + schema: { + type: 'string', + }, + }, + }, + }, + 202: { + description: 'Accepted', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + jobId: { + type: 'string', + }, + }, + required: [ + 'jobId', + ], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + }, + '/planets/{id}/image': { + put: { + operationId: 'planets.upload', + parameters: [ + { + in: 'path', + required: true, + name: 'id', + schema: { + type: 'string', + }, + }, + ], + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + image: { + type: 'string', + format: 'binary', + contentEncoding: 'binary', + contentMediaType: 'image/png', + }, + caption: { + type: 'string', + }, + }, + required: [ + 'image', + ], + }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + url: { + type: 'string', + format: 'uri', + }, + }, + required: [ + 'url', + ], + additionalProperties: false, + }, + }, + }, + }, + }, + }, + get: { + operationId: 'planets.image', + parameters: [ + { + in: 'path', + required: true, + name: 'id', + schema: { + type: 'string', + }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'image/png': { + schema: { + contentMediaType: 'image/png', + type: 'string', + format: 'binary', + contentEncoding: 'binary', + }, + }, + 'image/jpeg': { + schema: { + contentMediaType: 'image/jpeg', + type: 'string', + format: 'binary', + contentEncoding: 'binary', + }, + }, + }, + }, + }, + }, + }, + '/planets/events': { + get: { + operationId: 'planets.events', + responses: { + 200: { + description: 'OK', + content: { + 'text/event-stream': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + event: { + const: 'message', + }, + data: { + type: 'object', + properties: { + type: { + type: 'string', + const: 'created', + }, + planet: { + $ref: '#/components/schemas/Planet', + }, + }, + required: [ + 'type', + 'planet', + ], + additionalProperties: false, + }, + id: { + type: 'string', + }, + retry: { + type: 'number', + }, + }, + required: [ + 'event', + 'data', + ], + }, + { + type: 'object', + properties: { + event: { + const: 'close', + }, + data: { + type: 'object', + properties: { + total: { + type: 'number', + }, + }, + required: [ + 'total', + ], + additionalProperties: false, + }, + id: { + type: 'string', + }, + retry: { + type: 'number', + }, + }, + required: [ + 'event', + 'data', + ], + }, + { + type: 'object', + properties: { + event: { + const: 'error', + }, + data: {}, + id: { + type: 'string', + }, + retry: { + type: 'number', + }, + }, + required: [ + 'event', + ], + }, + ], + }, + }, + }, + }, + }, + }, + }, + '/categories': { + get: { + operationId: 'categories', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/Category', + }, + }, + }, + }, + }, + }, + }, + }, + '/ping': { + head: { + operationId: 'ping', + responses: { + 200: { + description: 'OK', + content: {}, + }, + }, + }, + }, + }, +} diff --git a/packages/downgrader/tests/orpc.test.ts b/packages/downgrader/tests/orpc.test.ts new file mode 100644 index 0000000..dfb02cd --- /dev/null +++ b/packages/downgrader/tests/orpc.test.ts @@ -0,0 +1,48 @@ +// oRPC generates 3.2 and downgrades it for older versions. This checks both +// steps on a document it generated (see orpc-document.ts). + +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { downgradeSpecV31ToV30, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { doc } from './orpc-document' +import { expectValidDowngrade } from './validate' + +function downgradeTwice(spec: OpenAPIV3_2.OpenAPIObject) { + return downgradeSpecV31ToV30(downgradeSpecV32ToV31(spec)) +} + +it('converts the oRPC document into a valid 3.1 document', async () => { + const v31 = await expectValidDowngrade(doc, downgradeSpecV32ToV31, '3.2', '3.1') + expect(v31.servers).toEqual([{ url: 'https://api.example.com' }]) + expect(v31.tags).toEqual([{ name: 'planets', description: 'Planets' }]) +}) + +it('converts the oRPC document into a valid 3.0 document', async () => { + const v30 = await expectValidDowngrade(doc, downgradeTwice, '3.2', '3.0') + const { Category, NotFound, Planet } = v30.components?.schemas ?? {} + + expect(Planet).toMatchObject({ + properties: { + description: { type: 'string', nullable: true }, + mass: { type: 'number', minimum: 0, exclusiveMinimum: true, example: 5.97e24 }, + position: { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 }, + aliases: { type: 'array', items: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 2 } }, + attributes: { type: 'object', additionalProperties: { anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] } }, + discoveredAt: { 'type': 'string', 'format': 'date-time', 'x-native-type': 'date' }, + }, + }) + expect(Planet).not.toHaveProperty('properties.attributes.propertyNames') + expect(Category).toMatchObject({ properties: { parent: { anyOf: [{ $ref: '#/components/schemas/Category' }, { enum: [null] }] } } }) + expect(NotFound).toMatchObject({ properties: { defined: { enum: [true] }, code: { enum: ['NOT_FOUND'] } } }) + + const upload = v30.paths['/planets/{id}/image']?.put?.requestBody + expect(upload).toMatchObject({ content: { 'multipart/form-data': { schema: { properties: { image: { type: 'string', format: 'binary' } } } } } }) + expect(v30.paths['/planets/events']?.get?.responses['200']).toMatchObject({ + content: { 'text/event-stream': { schema: { oneOf: [{ properties: { event: { enum: ['message'] } } }, {}, {}] } } }, + }) +}) + +it('matches the 3.0 snapshot', () => { + expect(downgradeTwice(doc)).toMatchSnapshot() +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts new file mode 100644 index 0000000..9cf8e6d --- /dev/null +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -0,0 +1,538 @@ +import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' + +import { downgradeSchemaV31ToV30, downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' + +import { expectValidDowngrade } from './validate' + +const info = { title: 'API', version: '1.0.0' } +const responses = { 200: { description: 'ok' } } + +function convert(fields: Omit) { + return downgradeSpecV31ToV30({ openapi: '3.1.2', info, ...fields }) +} + +describe('downgradeSpecV31ToV30', () => { + it('converts a document using every 3.1 feature into a valid 3.0 document', async () => { + const doc: OpenAPIV3_1.OpenAPIObject = { + openapi: '3.1.2', + info: { ...info, summary: 'An API', license: { name: 'MIT', identifier: 'MIT' } }, + jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', + security: [{ mtls: [] }, { bearer: [] }], + paths: { + '/pets/{id}': { $ref: '#/components/pathItems/Pet', summary: 'A pet' }, + '/pets': { + post: { + security: [{ mtls: [] }], + requestBody: { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + photo: { type: 'string', contentMediaType: 'image/png' }, + tags: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'integer' }], items: false }, + }, + }, + }, + }, + }, + callbacks: { onCreated: { '{$request.body#/url}': { $ref: '#/webhooks/created' } } }, + }, + }, + }, + webhooks: { created: { post: { requestBody: { $ref: '#/components/requestBodies/Pet', summary: 'A pet' }, responses } } }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['id', 'kind'], + properties: { + id: { type: 'string', examples: ['p1'] }, + kind: { const: 'pet' }, + name: { type: ['string', 'null'] }, + age: { type: 'integer', exclusiveMinimum: 0 }, + owner: { $ref: '#/components/schemas/Owner', description: 'Who owns it' }, + parent: { anyOf: [{ $ref: '#/components/schemas/Pet/$defs/Self' }, { type: 'null' }] }, + labels: { type: 'object', propertyNames: { pattern: '^[a-z]+$' }, additionalProperties: { type: 'string' } }, + }, + $defs: { Self: { type: 'object' } }, + }, + Owner: { type: 'object', properties: { name: { type: 'string' } } }, + }, + parameters: { Id: { in: 'path', name: 'id', required: true, schema: { type: 'string' } } }, + requestBodies: { Pet: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } }, + pathItems: { Pet: { parameters: [{ $ref: '#/components/parameters/Id', description: 'The pet id' }], get: { responses } } }, + securitySchemes: { mtls: { type: 'mutualTLS' }, bearer: { type: 'http', scheme: 'bearer' } }, + }, + } + await expectValidDowngrade(doc, downgradeSpecV31ToV30, '3.1', '3.0') + }) + + it('sets the version, drops jsonSchemaDialect and webhooks, and adds the paths 3.0 requires', () => { + expect(convert({ jsonSchemaDialect: 'https://example.com/dialect', webhooks: { hook: { post: { responses } } } })) + .toEqual({ openapi: '3.0.4', info, paths: {} }) + }) + + it('drops Info summary and License identifier, and lets the summary stand in for a missing description', () => { + expect(downgradeSpecV31ToV30({ openapi: '3.1.2', info: { ...info, summary: 's', description: 'd', license: { name: 'MIT', identifier: 'MIT' } }, paths: {} }).info) + .toEqual({ ...info, description: 'd', license: { name: 'MIT' } }) + expect(downgradeSpecV31ToV30({ openapi: '3.1.2', info: { ...info, summary: 's' }, paths: {} }).info).toEqual({ ...info, description: 's' }) + }) + + it('strips Reference Objects down to $ref', () => { + const out = convert({ + paths: { + '/a': { + get: { + parameters: [{ $ref: '#/components/parameters/P', summary: 's', description: 'd' }], + responses: { 200: { $ref: '#/components/responses/R', description: 'd' } }, + }, + }, + }, + }) + expect(out.paths['/a']?.get).toEqual({ + parameters: [{ $ref: '#/components/parameters/P' }], + responses: { 200: { $ref: '#/components/responses/R' } }, + }) + }) + + it('gives an operation without responses a default one', () => { + expect(convert({ paths: { '/a': { get: {} } } }).paths['/a']?.get).toEqual({ responses: { default: { description: '' } } }) + }) + + describe('path items', () => { + it('merges a $ref to components.pathItems into the referencing Path Item, whose own fields win', () => { + const out = convert({ + paths: { '/a': { $ref: '#/components/pathItems/A', summary: 'own', get: { operationId: 'own', responses } } }, + components: { pathItems: { A: { summary: 'target', get: { operationId: 'target', responses }, post: { responses } } } }, + }) + expect(out.paths['/a']).toEqual({ summary: 'own', get: { operationId: 'own', responses }, post: { responses } }) + expect(out.components).toEqual({}) + }) + + it('follows chains of Path Item $refs, including into webhooks and from callbacks', () => { + const out = convert({ + paths: { + '/a': { + post: { callbacks: { cb: { '{$request.body#/url}': { $ref: '#/components/pathItems/Alias' } } }, responses }, + }, + }, + webhooks: { hook: { put: { responses } } }, + components: { pathItems: { Alias: { $ref: '#/webhooks/hook', get: { responses } } } }, + }) + expect(out.paths['/a']?.post?.callbacks).toEqual({ cb: { '{$request.body#/url}': { get: { responses }, put: { responses } } } }) + }) + + it('stops where a Path Item refers back to itself', () => { + const out = convert({ + paths: { '/a': { $ref: '#/components/pathItems/Loop' } }, + components: { + pathItems: { + Loop: { post: { callbacks: { cb: { '{$url}': { $ref: '#/components/pathItems/Loop' } } }, responses } }, + }, + }, + }) + expect(out.paths['/a']).toEqual({ post: { callbacks: { cb: { '{$url}': {} } }, responses } }) + }) + + it('keeps $refs to Path Items that 3.0 keeps, and those that dangle', () => { + const out = convert({ paths: { '/a': { get: { responses } }, '/b': { $ref: '#/paths/~1a' }, '/c': { $ref: '#/components/pathItems/Missing' } } }) + expect(out.paths['/b']).toEqual({ $ref: '#/paths/~1a' }) + expect(out.paths['/c']).toEqual({ $ref: '#/components/pathItems/Missing' }) + }) + + it('inlines other $refs into removed parts', () => { + const out = convert({ + paths: { '/a': { get: { parameters: [{ $ref: '#/webhooks/hook/post/parameters/0' }], responses } } }, + webhooks: { hook: { post: { parameters: [{ in: 'query', name: 'q', schema: { const: 1 } }], responses } } }, + }) + expect(out.paths['/a']?.get?.parameters).toEqual([{ in: 'query', name: 'q', schema: { enum: [1] } }]) + }) + }) + + describe('mutual TLS', () => { + it('drops mutualTLS schemes, and the $refs that resolve to them', () => { + const out = convert({ + components: { + securitySchemes: { + mtls: { type: 'mutualTLS' }, + alias: { $ref: '#/components/securitySchemes/mtls' }, + key: { type: 'apiKey', name: 'k', in: 'header' }, + }, + }, + }) + expect(out.components?.securitySchemes).toEqual({ key: { type: 'apiKey', name: 'k', in: 'header' } }) + }) + + it('drops their names from security requirements, then emptied requirements and lists', () => { + const out = convert({ + security: [{ mtls: [] }], + paths: { + '/a': { + get: { security: [{ mtls: [], key: [] }, { alias: [] }, {}], responses }, + put: { security: [{ mtls: [] }], responses }, + post: { security: [], responses }, + }, + }, + components: { + securitySchemes: { + mtls: { type: 'mutualTLS' }, + alias: { $ref: '#/components/securitySchemes/mtls' }, + key: { type: 'apiKey', name: 'k', in: 'header' }, + }, + }, + }) + expect(out.security).toBeUndefined() + expect(out.paths['/a']?.get?.security).toEqual([{ key: [] }, {}]) + expect(out.paths['/a']?.put).toEqual({ responses }) + expect(out.paths['/a']?.post?.security).toEqual([]) + }) + }) + + it('empties the scopes of apiKey and http requirements, which 3.0 allows only for OAuth2 and OpenID Connect', () => { + const out = convert({ + security: [{ key: ['tasks.get'], oidc: ['openid'] }], + paths: { '/a': { get: { security: [{ bearer: ['read:users'] }, { alias: ['x'] }, { oauth: ['write'] }], responses } } }, + components: { + securitySchemes: { + key: { type: 'apiKey', name: 'k', in: 'header' }, + bearer: { type: 'http', scheme: 'bearer' }, + alias: { $ref: '#/components/securitySchemes/key' }, + oidc: { type: 'openIdConnect', openIdConnectUrl: 'https://example.com/.well-known/openid-configuration' }, + oauth: { type: 'oauth2', flows: { clientCredentials: { tokenUrl: 'https://example.com/token', scopes: { write: 'w' } } } }, + }, + }, + }) + expect(out.security).toEqual([{ key: [], oidc: ['openid'] }]) + expect(out.paths['/a']?.get?.security).toEqual([{ bearer: [] }, { alias: [] }, { oauth: ['write'] }]) + }) + + it('converts schemas everywhere in the document', () => { + const schema: OpenAPIV3_1.SchemaObject = { type: ['string', 'null'] } + const out = convert({ + paths: { + '/a': { + parameters: [{ in: 'query', name: 'q', schema }], + get: { + requestBody: { content: { 'multipart/form-data': { schema, encoding: { a: { headers: { H: { schema } } } } } } }, + responses: { 200: { description: 'ok', headers: { H: { schema } }, content: { 'application/json': { schema } } } }, + }, + }, + }, + components: { schemas: { S: schema }, headers: { H: { schema } }, parameters: { P: { in: 'query', name: 'p', content: { 'application/json': { schema } } } } }, + }) + const expected = { type: 'string', nullable: true } + const get = out.paths['/a']?.get + expect(out.paths['/a']?.parameters?.[0]).toMatchObject({ schema: expected }) + expect(get?.requestBody).toMatchObject({ content: { 'multipart/form-data': { schema: expected, encoding: { a: { headers: { H: { schema: expected } } } } } } }) + expect(get?.responses['200']).toMatchObject({ headers: { H: { schema: expected } }, content: { 'application/json': { schema: expected } } }) + expect(out.components).toMatchObject({ schemas: { S: expected }, headers: { H: { schema: expected } }, parameters: { P: { content: { 'application/json': { schema: expected } } } } }) + }) + + it('leaves the input untouched', () => { + const doc: OpenAPIV3_1.OpenAPIObject = { openapi: '3.1.2', info, paths: { '/a': { $ref: '#/components/pathItems/A' } }, components: { pathItems: { A: { get: { responses } } } } } + const before = structuredClone(doc) + downgradeSpecV31ToV30(doc) + expect(doc).toEqual(before) + }) +}) + +describe('downgradeSchemaV31ToV30', () => { + describe('type', () => { + it('marks null with nullable', () => { + expect(downgradeSchemaV31ToV30({ type: ['string', 'null'] })).toEqual({ type: 'string', nullable: true }) + expect(downgradeSchemaV31ToV30({ type: 'integer' })).toEqual({ type: 'integer' }) + }) + + it('keeps a 3.0-style nullable written beside a type, which only 3.0 gives meaning', () => { + const schema = (fields: Record) => downgradeSchemaV31ToV30(fields as OpenAPIV3_1.SchemaObject) + expect(schema({ type: 'string', nullable: true })).toEqual({ type: 'string', nullable: true }) + expect(schema({ nullable: true })).toEqual({}) + expect(schema({ type: 'string', nullable: false })).toEqual({ type: 'string' }) + // It admits null, which the 3.1 schema did not, so the branches can overlap. + expect(schema({ oneOf: [{ type: 'string', nullable: true }, { type: 'null' }] })).toEqual({ anyOf: [{ type: 'string', nullable: true }, { enum: [null] }] }) + }) + + it('turns several types into anyOf, moving items into the array branch', () => { + expect(downgradeSchemaV31ToV30({ type: ['array', 'string', 'null'], items: { type: 'number' }, maxLength: 3 })).toEqual({ + maxLength: 3, + anyOf: [{ type: 'array', items: { type: 'number' }, nullable: true }, { type: 'string', nullable: true }], + }) + }) + + it('nests the type anyOf in allOf when the schema has its own anyOf', () => { + expect(downgradeSchemaV31ToV30({ type: ['string', 'number'], anyOf: [{ minLength: 1 }, { minimum: 1 }] })).toEqual({ + anyOf: [{ minLength: 1 }, { minimum: 1 }], + allOf: [{ anyOf: [{ type: 'string' }, { type: 'number' }] }], + }) + }) + + it('turns type null into enum: [null]', () => { + expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }, { type: 'null' }] })).toEqual({ anyOf: [{ type: 'string' }, { enum: [null] }] }) + expect(downgradeSchemaV31ToV30({ type: 'null', enum: [null, 'a'] })).toEqual({ enum: [null] }) + // Only null may match the type, and the enum rules it out. + expect(downgradeSchemaV31ToV30({ type: 'null', enum: ['a'] })).toEqual({ enum: ['a'], allOf: [{ not: {} }] }) + }) + + it('adds the items 3.0 requires on arrays', () => { + expect(downgradeSchemaV31ToV30({ type: 'array' })).toEqual({ type: 'array', items: {} }) + }) + }) + + it('turns const into a single-value enum', () => { + expect(downgradeSchemaV31ToV30({ type: 'string', const: 'a' })).toEqual({ type: 'string', enum: ['a'] }) + expect(downgradeSchemaV31ToV30({ const: null })).toEqual({ enum: [null] }) + }) + + it('turns numeric exclusive bounds into boolean ones, keeping the stricter bound', () => { + expect(downgradeSchemaV31ToV30({ exclusiveMinimum: 0, exclusiveMaximum: 10 })).toEqual({ minimum: 0, exclusiveMinimum: true, maximum: 10, exclusiveMaximum: true }) + expect(downgradeSchemaV31ToV30({ minimum: 5, exclusiveMinimum: 0 })).toEqual({ minimum: 5 }) + expect(downgradeSchemaV31ToV30({ minimum: 0, exclusiveMinimum: 5 })).toEqual({ minimum: 5, exclusiveMinimum: true }) + }) + + it('turns examples into example', () => { + expect(downgradeSchemaV31ToV30({ examples: ['a', 'b'] })).toEqual({ example: 'a' }) + expect(downgradeSchemaV31ToV30({ example: 'x', examples: ['a'] })).toEqual({ example: 'x' }) + }) + + it('turns tuples into arrays whose items match any item schema', () => { + const prefixItems = [{ type: 'string' }, { type: 'integer' }] as const + expect(downgradeSchemaV31ToV30({ type: 'array', prefixItems: [...prefixItems], items: false, minItems: 2, maxItems: 2 })) + .toEqual({ type: 'array', items: { anyOf: prefixItems }, minItems: 2, maxItems: 2 }) + expect(downgradeSchemaV31ToV30({ type: 'array', prefixItems: [{ type: 'string' }], items: { type: 'number' } })) + .toEqual({ type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }) + expect(downgradeSchemaV31ToV30({ type: 'array', prefixItems: [{ type: 'string' }], items: false })).toEqual({ type: 'array', items: { type: 'string' }, maxItems: 1 }) + expect(downgradeSchemaV31ToV30({ type: 'array', prefixItems: [{ type: 'string' }] })).toEqual({ type: 'array', items: {} }) + // As zod emits a Map entry, and a tuple of one type. + expect(downgradeSchemaV31ToV30({ type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }], minItems: 2, maxItems: 2 })) + .toEqual({ type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] }, minItems: 2, maxItems: 2 }) + expect(downgradeSchemaV31ToV30({ type: 'array', prefixItems: [{ type: 'number' }, { type: 'number' }], items: false })) + .toEqual({ type: 'array', items: { type: 'number' }, maxItems: 2 }) + }) + + it('marks binary strings with format', () => { + expect(downgradeSchemaV31ToV30({ type: 'string', contentEncoding: 'base64' })).toEqual({ type: 'string', format: 'byte' }) + expect(downgradeSchemaV31ToV30({ type: 'string', contentMediaType: 'image/png' })).toEqual({ type: 'string', format: 'binary' }) + expect(downgradeSchemaV31ToV30({ contentMediaType: 'image/png' })).toEqual({ type: 'string', format: 'binary' }) + expect(downgradeSchemaV31ToV30({ type: 'string', format: 'binary', contentEncoding: 'binary', contentMediaType: 'image/png' })).toEqual({ type: 'string', format: 'binary' }) + // A string with a contentSchema holds structured text, such as JSON in a server-sent event. + expect(downgradeSchemaV31ToV30({ type: 'string', contentMediaType: 'application/json', contentSchema: { type: 'object' } })).toEqual({ type: 'string' }) + }) + + describe('$ref', () => { + it('wraps a $ref with siblings in allOf, which 3.0 does not ignore', () => { + expect(downgradeSchemaV31ToV30({ $ref: '#/components/schemas/User', description: 'The owner' })) + .toEqual({ description: 'The owner', allOf: [{ $ref: '#/components/schemas/User' }] }) + expect(downgradeSchemaV31ToV30({ $ref: '#/components/schemas/User' })).toEqual({ $ref: '#/components/schemas/User' }) + }) + + it('inlines $refs into $defs, cutting recursion with {}', () => { + expect(downgradeSchemaV31ToV30({ + type: 'object', + properties: { tree: { $ref: '#/$defs/Tree' }, root: { $ref: '#', description: 'kept' } }, + $defs: { Tree: { type: 'object', properties: { children: { type: 'array', items: { $ref: '#/$defs/Tree' } } } } }, + })).toEqual({ + type: 'object', + properties: { + tree: { type: 'object', properties: { children: { type: 'array', items: {} } } }, + root: { description: 'kept', allOf: [{ $ref: '#' }] }, + }, + }) + }) + + it('reads definitions, the draft-07 name of $defs, as $defs', () => { + expect(downgradeSchemaV31ToV30({ type: 'object', properties: { b: { $ref: '#/definitions/B' } }, definitions: { B: { type: ['string', 'null'] } } } as OpenAPIV3_1.SchemaObject)) + .toEqual({ type: 'object', properties: { b: { type: 'string', nullable: true } } }) + }) + + it('leaves a $ref into $defs that does not resolve as written', () => { + expect(downgradeSchemaV31ToV30({ $ref: '#/$defs/Missing' })).toEqual({ $ref: '#/$defs/Missing' }) + }) + }) + + it('drops keywords that 3.0 lacks', () => { + expect(downgradeSchemaV31ToV30({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://example.com/pet', + $anchor: 'pet', + $comment: 'c', + $dynamicAnchor: 'node', + type: 'object', + if: { required: ['a'] }, + then: { required: ['b'] }, + else: { required: ['c'] }, + dependentRequired: { a: ['b'] }, + dependentSchemas: { a: { required: ['b'] } }, + propertyNames: { pattern: '^[a-z]+$' }, + unevaluatedProperties: false, + additionalProperties: { type: 'string' }, + contentSchema: { type: 'object' }, + })).toEqual({ type: 'object', additionalProperties: { type: 'string' } }) + expect(downgradeSchemaV31ToV30({ type: 'array', contains: { type: 'string' }, minContains: 1, maxContains: 2, unevaluatedItems: false })) + .toEqual({ type: 'array', items: {} }) + }) + + it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { + const properties = { name: { type: 'string' } } as const + expect(downgradeSchemaV31ToV30({ type: 'object', properties, unevaluatedProperties: false })).toEqual({ type: 'object', properties, additionalProperties: false }) + expect(downgradeSchemaV31ToV30({ properties, unevaluatedProperties: { type: ['integer', 'null'] } })).toEqual({ properties, additionalProperties: { type: 'integer', nullable: true } }) + // The variants of a closed union stay exclusive. + expect(downgradeSchemaV31ToV30({ oneOf: [{ properties, unevaluatedProperties: false }, { properties: { id: { type: 'integer' } }, unevaluatedProperties: false }] })) + .toEqual({ oneOf: [{ properties, additionalProperties: false }, { properties: { id: { type: 'integer' } }, additionalProperties: false }] }) + // Beside allOf it also sees the properties allOf evaluates, which 3.0 cannot express. + expect(downgradeSchemaV31ToV30({ allOf: [{ $ref: '#/components/schemas/Base' }], properties, unevaluatedProperties: false })) + .toEqual({ allOf: [{ $ref: '#/components/schemas/Base' }], properties }) + }) + + it('keeps meaning an empty enum, which 3.0 forbids, as a schema that rejects every value', () => { + expect(downgradeSchemaV31ToV30({ type: 'string', enum: [] })).toEqual({ type: 'string', allOf: [{ not: {} }] }) + }) + + it('drops additionalProperties beside patternProperties, which it would contradict', () => { + expect(downgradeSchemaV31ToV30({ type: 'object', patternProperties: { '^x-': { type: 'string' } }, additionalProperties: false })).toEqual({ type: 'object' }) + }) + + describe('a schema that lost a restriction', () => { + it('is no longer negated by not', () => { + expect(downgradeSchemaV31ToV30({ not: { contains: { type: 'string' } } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ type: 'object', not: { patternProperties: { '^x-': { type: 'string' } } } })).toEqual({ type: 'object' }) + expect(downgradeSchemaV31ToV30({ not: { properties: { a: { if: { minimum: 1 }, then: { maximum: 2 } } } } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ not: { type: 'string', minLength: 3 } })).toEqual({ not: { type: 'string', minLength: 3 } }) + }) + + it('turns an enclosing oneOf into anyOf, since branches may now overlap', () => { + const branches = [{ type: 'object', propertyNames: { pattern: '^a' } }, { type: 'object', propertyNames: { pattern: '^b' } }] as const + expect(downgradeSchemaV31ToV30({ oneOf: [...branches] })).toEqual({ anyOf: [{ type: 'object' }, { type: 'object' }] }) + expect(downgradeSchemaV31ToV30({ oneOf: [{ type: 'string' }, { type: 'number' }] })).toEqual({ oneOf: [{ type: 'string' }, { type: 'number' }] }) + expect(downgradeSchemaV31ToV30({ anyOf: [{ minimum: 0 }], oneOf: [{ prefixItems: [true] }, {}] })) + .toEqual({ anyOf: [{ minimum: 0 }], allOf: [{ anyOf: [{ items: {} }, {}] }] }) + }) + + it('excludes keywords that restrict nothing, and tuples converted exactly', () => { + // As zod emits a discriminated union whose member holds a record or a tuple. + const record: OpenAPIV3_1.SchemaObject = { type: 'object', propertyNames: { type: 'string' }, additionalProperties: { type: 'string' } } + const tuple: OpenAPIV3_1.SchemaObject = { type: 'array', prefixItems: [{ type: 'number' }, { type: 'number' }], items: false, minItems: 2, maxItems: 2 } + expect(downgradeSchemaV31ToV30({ oneOf: [{ properties: { meta: record } }, { properties: { point: tuple } }] })).toEqual({ + oneOf: [ + { properties: { meta: { type: 'object', additionalProperties: { type: 'string' } } } }, + { properties: { point: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 } } }, + ], + }) + }) + + it('includes a tuple with an item that lost a restriction, even beside an equal item', () => { + const tuple: OpenAPIV3_1.SchemaObject = { type: 'array', prefixItems: [{ type: 'object', propertyNames: { pattern: '^a' } }, { type: 'object' }], items: false } + expect(downgradeSchemaV31ToV30({ not: tuple })).toEqual({}) + expect(downgradeSchemaV31ToV30({ oneOf: [tuple, { type: 'array', items: { type: 'object' } }] })) + .toEqual({ anyOf: [{ type: 'array', items: { anyOf: [{ type: 'object' }, { type: 'object' }] }, maxItems: 2 }, { type: 'array', items: { type: 'object' } }] }) + expect(downgradeSchemaV31ToV30({ $defs: { P: { type: 'array', prefixItems: [{ $ref: '#/$defs/P' }, {}], items: false } }, not: { $ref: '#/$defs/P' } })).toEqual({}) + }) + + it('includes a const outside the enum beside it, which matched nothing', () => { + expect(downgradeSchemaV31ToV30({ not: { const: 'a', enum: ['b'] } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ not: { const: 'a', enum: ['a', 'b'] } })).toEqual({ not: { enum: ['a'] } }) + }) + + it('includes a $defs target cut at recursion', () => { + expect(downgradeSchemaV31ToV30({ + not: { $ref: '#/$defs/Tree' }, + $defs: { Tree: { type: 'object', properties: { child: { $ref: '#/$defs/Tree' } } } }, + })).toEqual({}) + }) + }) + + it('drops an empty required, which 3.0 forbids', () => { + expect(downgradeSchemaV31ToV30({ type: 'object', required: [] })).toEqual({ type: 'object' }) + }) + + it('turns boolean subschemas into objects, except additionalProperties', () => { + expect(downgradeSchemaV31ToV30({ properties: { any: true, none: false }, additionalProperties: false, not: true })) + .toEqual({ properties: { any: {}, none: { not: {} } }, additionalProperties: false, not: {} }) + }) + + it('keeps extensions, and turns unknown keywords, which 3.0 forbids, into extensions', () => { + expect(downgradeSchemaV31ToV30({ 'type': 'string', 'x-native-type': 'date', 'format': 'date-time' } as OpenAPIV3_1.SchemaObject)) + .toEqual({ 'type': 'string', 'x-native-type': 'date', 'format': 'date-time' }) + // As zod `.meta()` keys and generators' custom keywords arrive. + expect(downgradeSchemaV31ToV30({ 'type': 'string', 'label': 'Name', 'placeholder': 'Jane', 'x-label': 'kept' } as OpenAPIV3_1.SchemaObject)) + .toEqual({ 'type': 'string', 'x-label': 'kept', 'x-placeholder': 'Jane' }) + expect(downgradeSchemaV31ToV30({ 'type': 'string', 'label': 'Name', 'x-label': null } as OpenAPIV3_1.SchemaObject)).toEqual({ 'type': 'string', 'x-label': null }) + }) + + it('converts a shared object once, and keeps a cycle as a cycle', () => { + const shared = { type: ['string', 'null'] } as OpenAPIV3_1.SchemaObject + const node: Record = { type: 'object', properties: { a: shared, b: shared } } + node.properties.self = node + const out = downgradeSchemaV31ToV30(node) as Record + expect(out.properties.a).toBe(out.properties.b) + expect(out.properties.self).toBe(out) + }) + + it('keeps a cycle in a copied value as a cycle', () => { + const example: Record = { name: 'loop' } + example.self = example + const out = downgradeSchemaV31ToV30({ type: 'object', example }) as Record + expect(out.example).not.toBe(example) + expect(out.example.self).toBe(out.example) + }) +}) + +describe('unusual input', () => { + it('converts valid schemas that combine keywords unusually', () => { + expect(downgradeSchemaV31ToV30({ $ref: '#/components/schemas/A', allOf: [{ minLength: 1 }], type: ['string', 'number'], anyOf: [{ maxLength: 3 }, { maximum: 3 }] })).toEqual({ + allOf: [{ $ref: '#/components/schemas/A' }, { minLength: 1 }, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + anyOf: [{ maxLength: 3 }, { maximum: 3 }], + }) + expect(downgradeSchemaV31ToV30({ type: 'null', enum: ['a'], allOf: [{ minLength: 1 }] })).toEqual({ enum: ['a'], allOf: [{ minLength: 1 }, { not: {} }] }) + expect(downgradeSchemaV31ToV30({ enum: [], allOf: [{ minimum: 0 }] })).toEqual({ allOf: [{ minimum: 0 }, { not: {} }] }) + expect(downgradeSchemaV31ToV30({ type: ['array', 'string'] })).toEqual({ anyOf: [{ type: 'array', items: {} }, { type: 'string' }] }) + expect(downgradeSchemaV31ToV30({ type: ['string'] })).toEqual({ type: 'string' }) + expect(downgradeSchemaV31ToV30({ maximum: 5, exclusiveMaximum: 10 })).toEqual({ maximum: 5 }) + }) + + it('keeps own __proto__ keys as keys', () => { + const out = downgradeSchemaV31ToV30(JSON.parse('{"type":"object","properties":{"__proto__":{"type":["string","null"]}},"example":{"__proto__":1}}')) as Record + expect(Object.getOwnPropertyDescriptor(out.properties, '__proto__')?.value).toEqual({ type: 'string', nullable: true }) + expect(Object.getOwnPropertyDescriptor(out.example, '__proto__')?.value).toBe(1) + expect(Object.getPrototypeOf(out.example)).toBe(Object.prototype) + }) + + it('merges a cyclic tuple item schema with itself', () => { + const item: Record = { type: 'object', properties: {} } + item.properties.self = item + const out = downgradeSchemaV31ToV30({ type: 'array', prefixItems: [item, item], items: false }) as Record + expect(out.items.properties.self).toBe(out.items) + }) + + it('resolves only local JSON Pointers, leaving other $refs as written', () => { + for (const ref of ['./other.json#/$defs/A', '#/$defs/%E0%A4%A']) { + expect(downgradeSchemaV31ToV30({ $ref: ref })).toEqual({ $ref: ref }) + } + const out = convert({ + security: [{ anchor: ['a'], loop: ['b'], missing: ['c'] }], + components: { + securitySchemes: { + anchor: { $ref: '#mtls' }, + loop: { $ref: '#/components/securitySchemes/loop' }, + missing: { $ref: '#/components/securitySchemes/none' }, + }, + }, + }) + expect(out.security).toEqual([{ anchor: ['a'], loop: ['b'], missing: ['c'] }]) + expect(Object.keys(out.components?.securitySchemes ?? {})).toEqual(['anchor', 'loop', 'missing']) + }) + + it('tolerates malformed input without throwing', () => { + const doc = { + openapi: '3.1.2', + info, + security: 'all', + paths: { '/a': { get: { security: [null, 'x'], parameters: 'none', responses: [] } } }, + components: { schemas: { S: { properties: 'none', allOf: {}, example: { a: 1, b: undefined } } } }, + } + const out = downgradeSpecV31ToV30(doc as any) + expect(out.security).toBe('all') + expect(out.paths['/a']?.get).toEqual({ security: [null, 'x'], parameters: 'none', responses: [] }) + expect(out.components?.schemas?.S).toEqual({ properties: 'none', allOf: {}, example: { a: 1 } }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts deleted file mode 100644 index 6366478..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/annotations.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { convertSchema } from './helpers' - -describe('examples', () => { - // 3.1 uses the JSON Schema `examples` list, and deprecates the singular - // OpenAPI `example`: https://spec.openapis.org/oas/v3.1.2.html#schema-example - // 3.0 only has the singular one: https://spec.openapis.org/oas/v3.0.4.html#schema-example - // https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#change-schema-example-to-examples - it.each([ - ['promotes the first entry to example', { examples: ['a', 'b'] }, { example: 'a' }], - ['keeps an explicit example over the entries', { example: 'e', examples: ['a'] }, { example: 'e' }], - ['keeps a falsy first entry', { examples: [0] }, { example: 0 }], - ['drops an empty list', { examples: [] }, {}], - ['drops a malformed value', { examples: 'junk' }, {}], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('readOnly and writeOnly', () => { - // 3.1 takes both from JSON Schema, where they are annotations that may both - // be true: https://json-schema.org/draft/2020-12/json-schema-validation#name-readonly-and-writeonly - // 3.0 forbids marking a property with both: - // https://spec.openapis.org/oas/v3.0.4.html#schema-read-only - // Both are dropped, since keeping either one would claim a direction the - // original does not. - it.each([ - ['drops both when both are true', { readOnly: true, type: 'string', writeOnly: true }, { type: 'string' }], - ['keeps readOnly alone', { readOnly: true }, { readOnly: true }], - ['keeps writeOnly alone', { writeOnly: true }, { writeOnly: true }], - ['keeps both when only one is true', { readOnly: true, writeOnly: false }, { readOnly: true, writeOnly: false }], - ['keeps both when neither is true', { readOnly: false, writeOnly: false }, { readOnly: false, writeOnly: false }], - ['passes malformed values through', { readOnly: 'yes', writeOnly: true }, { readOnly: 'yes', writeOnly: true }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('binary content', () => { - // 3.1 describes binary strings with `contentEncoding` and - // `contentMediaType`, where 3.0 used `format: byte` and `format: binary`: - // https://spec.openapis.org/oas/v3.1.2.html#migrating-binary-descriptions-from-oas-3-0 - // https://spec.openapis.org/oas/v3.0.4.html#working-with-binary-data - // - encoded binary (`contentEncoding: base64`) is `format: byte` - // - raw binary (`contentMediaType` without an encoding) is `format: binary` - // Raw binary has no `type` in 3.1 because it is not a JSON value, but in 3.0 - // it is a `string`. - it.each([ - ['turns base64 into format: byte', { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' }, { format: 'byte', type: 'string' }], - ['turns raw binary into type: string with format: binary', { contentMediaType: 'image/png' }, { format: 'binary', type: 'string' }], - ['adds type: string beside base64 when type is missing', { contentEncoding: 'base64' }, { format: 'byte', type: 'string' }], - ['keeps nullable on binary strings', { contentMediaType: 'image/png', type: ['string', 'null'] }, { format: 'binary', nullable: true, type: 'string' }], - [ - 'keeps format beside a type union that includes string', - { contentMediaType: 'image/png', type: ['string', 'integer'] }, - { anyOf: [{ type: 'string' }, { type: 'integer' }], format: 'binary' }, - ], - ['keeps an existing format', { contentEncoding: 'base64', format: 'custom' }, { format: 'custom', type: 'string' }], - // `format: byte` is base64 as in RFC 4648 section 4, so it cannot describe - // the URL-safe alphabet of section 5, or any other encoding: - // https://spec.openapis.org/oas/v3.0.4.html#data-type-format - // Content keywords on a type that is not a string have nothing to map to. - ['drops base64url, which format: byte does not cover', { contentEncoding: 'base64url', contentMediaType: 'image/png', type: 'string' }, { type: 'string' }], - ['drops content keywords on non-string types', { contentMediaType: 'image/png', type: 'object' }, { type: 'object' }], - ['drops a malformed contentMediaType', { contentMediaType: 42 }, {}], - ['drops contentSchema', { contentSchema: { type: 'string' } }, {}], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('xml.nodeType', () => { - // `nodeType` is a 3.2 field (https://spec.openapis.org/oas/v3.2.0.html#xml-node-type) - // that can reach a 3.1 document written by hand or by a lenient tool. The - // 3.2 → 3.1 converter maps it the same way. - it.each([ - ['maps attribute to attribute: true', { type: 'string', xml: { name: 'n', nodeType: 'attribute' } }, { type: 'string', xml: { attribute: true, name: 'n' } }], - ['maps element on an array to wrapped: true', { items: {}, type: 'array', xml: { nodeType: 'element' } }, { items: {}, type: 'array', xml: { wrapped: true } }], - ['maps element on a nullable array to wrapped: true', { type: ['array', 'null'], xml: { nodeType: 'element' } }, { items: {}, nullable: true, type: 'array', xml: { wrapped: true } }], - ['removes element on other schemas', { type: 'string', xml: { nodeType: 'element' } }, { type: 'string', xml: {} }], - ['removes values 3.0 cannot express', { type: 'string', xml: { name: 'n', nodeType: 'text' } }, { type: 'string', xml: { name: 'n' } }], - ['keeps an xml object without nodeType', { type: 'string', xml: { attribute: true, name: 'n' } }, { type: 'string', xml: { attribute: true, name: 'n' } }], - ['passes a malformed xml value through', { type: 'string', xml: 'junk' }, { type: 'string', xml: 'junk' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('discriminator', () => { - it('keeps the discriminator and its mapping', () => { - const schema = { - discriminator: { mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }, - oneOf: [{ $ref: '#/components/schemas/Cat' }], - } - expect(convertSchema(schema)).toEqual(schema) - }) - - it('passes a malformed discriminator or mapping value through', () => { - expect(convertSchema({ discriminator: 'junk' })).toEqual({ discriminator: 'junk' }) - const mapping = { discriminator: { mapping: { a: 1, b: { c: 'd' } }, propertyName: 'kind' } } - expect(convertSchema(mapping)).toEqual(mapping) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts deleted file mode 100644 index f7e9976..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/enum-const-required.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { convertSchema } from './helpers' - -describe('const', () => { - // `const` arrived in JSON Schema draft 06, after the draft Wright-00 (05) - // that 3.0 builds on. A single-value `enum` means the same: - // https://json-schema.org/draft/2020-12/json-schema-validation#section-6.1.3 - it.each([ - ['turns const into a single-value enum', { const: 'a' }, { enum: ['a'] }], - ['keeps a zero const', { const: 0 }, { enum: [0] }], - ['keeps a false const', { const: false }, { enum: [false] }], - ['keeps an empty-string const', { const: '' }, { enum: [''] }], - ['keeps a null const', { const: null }, { enum: [null] }], - ['keeps a null const beside a null-only type', { const: null, type: ['null'] }, { enum: [null] }], - [ - 'keeps the nullable branches of a multi-type null const', - { const: null, type: ['string', 'integer', 'null'] }, - { anyOf: [{ nullable: true, type: 'string' }, { nullable: true, type: 'integer' }], enum: [null] }, - ], - // Accepts nothing in both versions: null is not a string, and 3.0 does - // not add null to a type without `nullable: true`. - ['keeps a null const that contradicts its type', { const: null, type: 'string' }, { enum: [null], type: 'string' }], - ['matches nothing when a non-null const contradicts a null-only type', { const: 7, type: ['null'] }, { enum: [7], not: {} }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - - // A value must satisfy both `const` and `enum`. When the const value is in - // the enum, the const alone says it all. When it is not, nothing matches - // in 3.1, and the single enum is looser (see loosening.test.ts). - it('replaces an existing enum with the const value', () => { - expect(convertSchema({ const: 5, enum: [1, 2, 5] })).toEqual({ enum: [5] }) - expect(convertSchema({ const: 5, enum: [1, 2] })).toEqual({ enum: [5] }) - }) -}) - -describe('enum', () => { - // The official 3.0 schema requires at least one entry (`minItems: 1`): - // https://spec.openapis.org/oas/3.0/schema/2021-09-28 - // In 3.1 an empty enum matches nothing; without it the 3.0 schema is - // looser (see loosening.test.ts for what that means under `not`). - it.each([ - ['removes an empty enum', { enum: [], type: 'string' }, { type: 'string' }], - ['keeps a non-empty enum', { enum: ['a'], type: 'string' }, { enum: ['a'], type: 'string' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('required', () => { - // The official 3.0 schema requires a non-empty list of unique names - // (`minItems: 1`, `uniqueItems: true`): https://spec.openapis.org/oas/3.0/schema/2021-09-28 - // An empty list requires nothing, and a repeated name requires nothing - // more, so both fixes keep the meaning. - it.each([ - ['removes an empty required list', { required: [] }, {}], - ['keeps a non-empty required list', { required: ['a'] }, { required: ['a'] }], - ['deduplicates required names', { required: ['a', 'b', 'a'], type: 'object' }, { required: ['a', 'b'], type: 'object' }], - ['passes a malformed required value through', { required: 'junk' }, { required: 'junk' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts deleted file mode 100644 index 9658bd7..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/helpers.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -/** - * Converts `schema`. The input is typed loosely on purpose: many tests feed - * malformed schemas to check that the conversion tolerates them. - */ -export function convertSchema(schema: unknown): unknown { - return downgradeSchemaV31ToV30(schema as any) -} diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts deleted file mode 100644 index ccd6cc4..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/input.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' - -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' - -import { dig } from '../../helpers' -import { convertSchema } from './helpers' - -describe('input shapes', () => { - it('clones non-schema 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) - }) - - it('treats keywords named like Object.prototype members as unknown keywords', () => { - const input = JSON.parse('{"constructor":1,"hasOwnProperty":2,"toString":3,"__proto__":{"type":["string","null"]},"type":"string"}') - const result = convertSchema(input) as object - expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) - expect(Object.getOwnPropertyDescriptor(result, 'hasOwnProperty')?.value).toBe(2) - expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(3) - expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toEqual({ type: ['string', 'null'] }) - expect(Object.getPrototypeOf(result)).toBe(Object.prototype) - }) - - // JSON.parse creates a real own `__proto__` key, here a property name. - // It is converted like any other property. - it('converts a property named __proto__ without polluting prototypes', () => { - const properties = dig(convertSchema(JSON.parse('{"properties":{"__proto__":{"type":["string","null"]}}}')), 'properties') as object - expect(Object.getOwnPropertyDescriptor(properties, '__proto__')?.value).toEqual({ nullable: true, type: 'string' }) - expect(Object.getPrototypeOf(properties)).toBe(Object.prototype) - expect('nullable' in {}).toBe(false) - }) -}) - -describe('copies', () => { - it('never mutates the input schema', () => { - const input: OpenAPIV3_1.SchemaObject = { - $ref: '#/c/s', - allOf: [{ type: 'string' }], - const: null, - examples: ['a'], - exclusiveMinimum: 5, - minimum: 3, - prefixItems: [{ type: 'string' }], - properties: { a: { type: ['string', 'null'] } }, - type: ['object', 'null'], - } - const before = structuredClone(input) - downgradeSchemaV31ToV30(input) - expect(input).toEqual(before) - }) - - it('returns a fresh copy on every call', () => { - const schema: OpenAPIV3_1.SchemaObject = { properties: { a: { type: 'string' } }, type: 'object' } - expect(downgradeSchemaV31ToV30(schema)).not.toBe(downgradeSchemaV31ToV30(schema)) - }) - - it('converts deeply nested schemas without overflowing the stack', () => { - let deep: OpenAPIV3_1.SchemaObject = { type: 'string' } - for (let index = 0; index < 1000; index += 1) { - deep = { items: deep, type: 'array' } - } - expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow() - }) -}) - -describe('object graphs', () => { - // A dereferenced schema can contain itself. The output keeps the same - // shape: the cycle points at the converted ancestor. - it('converts a cyclic schema, pointing the cycle at the converted ancestor', () => { - const properties: Record = {} - const node: Record = { properties, type: ['object', 'null'] } - properties.self = node - properties.children = { items: node, type: 'array' } - const result = convertSchema(node) as Record - expect(result.type).toBe('object') - expect(result.nullable).toBe(true) - expect(dig(result, 'properties', 'self')).toBe(result) - expect(dig(result, 'properties', 'children', 'items')).toBe(result) - expect(node.type).toEqual(['object', 'null']) - }) - - it('converts a cycle that closes several levels down', () => { - const grandchild: Record = { type: ['string', 'null'] } - const child = { properties: { grandchild }, type: 'object' } - grandchild.items = child - const result = convertSchema({ properties: { child }, type: 'object' }) - const convertedChild = dig(result, 'properties', 'child') - expect(dig(convertedChild, 'properties', 'grandchild', 'items')).toBe(convertedChild) - }) - - // The `items` of a multi-type schema moves into the array branch, and the - // cycle it closes points at the converted schema that holds that branch. - it('points the array branch of a cyclic multi-type schema at the converted schema', () => { - const node: Record = { type: ['array', 'object'] } - node.items = node - const result = convertSchema(node) as Record - expect(result).not.toHaveProperty('items') - expect(dig(result, 'anyOf', '0', 'items')).toBe(result) - expect(dig(result, 'anyOf', '1')).toEqual({ type: 'object' }) - expect(node.items).toBe(node) - }) - - // A diamond (two properties sharing one subschema) doubles the number of - // paths per level: 2^64 here. Converting each shared object once keeps the - // work linear. - it('converts a schema reached along many paths once', () => { - let node: OpenAPIV3_1.SchemaObject = { type: ['string', 'null'] } - for (let index = 0; index < 64; index += 1) { - node = { properties: { left: node, right: node }, type: 'object' } - } - const result = convertSchema(node) - expect(dig(result, 'properties', 'left')).toBe(dig(result, 'properties', 'right')) - let leaf = result - for (let index = 0; index < 64; index += 1) { - leaf = dig(leaf, 'properties', 'left') - } - expect(leaf).toEqual({ nullable: true, type: 'string' }) - }) -}) - -describe('keys holding undefined', () => { - it.each([ - ['ignores an undefined const', { const: undefined, type: 'string' }, { type: 'string' }], - ['ignores an undefined const beside a null-only type', { const: undefined, type: ['null'] }, { enum: [null] }], - ['keeps items beside an undefined prefixItems', { items: { type: 'string' }, prefixItems: undefined, type: 'array' }, { items: { type: 'string' }, type: 'array' }], - ['keeps additionalProperties beside an undefined patternProperties', { additionalProperties: false, patternProperties: undefined }, { additionalProperties: false }], - ['promotes examples beside an undefined example', { example: undefined, examples: ['a'] }, { example: 'a' }], - ['keeps a reference with only undefined siblings bare', { $ref: '#/components/schemas/A', description: undefined }, { $ref: '#/components/schemas/A' }], - ['drops undefined values everywhere in the output', { 'default': { a: undefined, b: 1 }, 'properties': { a: undefined }, 'x-a': undefined }, { default: { b: 1 }, properties: {} }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toStrictEqual(expected) - }) - - // An undefined removed keyword or enum loosens nothing, so the `not` stays - // (see loosening.test.ts). - it.each([ - ['an undefined removed keyword', { if: undefined, type: 'string' }, { type: 'string' }], - ['a const beside an undefined enum', { const: 1, enum: undefined }, { enum: [1] }], - ])('keeps a not whose operand has %s', (_name, operand, expected) => { - expect(convertSchema({ not: operand })).toStrictEqual({ not: expected }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts deleted file mode 100644 index 20f2d80..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/loosening.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Removing a keyword with no 3.0 form (see removed-keywords.test.ts) makes a -// schema accept more values: it is "loosened". A looser schema never rejects -// a value the original accepted, which is the safe direction for a -// conversion. Two applicators flip that direction: -// - `not` rejects what its operand accepts, so a looser operand rejects -// more: https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.4 -// Such a `not` is removed instead, which loosens the enclosing schema. -// - `oneOf` rejects a value that more than one branch accepts, so a looser -// branch can start to overlap another and reject valid values: -// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.1.3 -// Such a `oneOf` becomes `anyOf`, which accepts any overlap. -// Loosening propagates up through `properties`, `items`, -// `additionalProperties`, `allOf`, and `anyOf`, and a cut recursion or an -// object cycle counts as loosened too. - -import { dig } from '../../helpers' -import { convertSchema } from './helpers' - -describe('not', () => { - it.each([ - ['removes a not whose operand lost a keyword', { not: { patternProperties: { a: {} } } }, {}], - ['removes a not whose operand is loosened deeper down', { not: { properties: { a: { if: {} } } } }, {}], - ['removes a not whose operand lost an empty enum', { not: { enum: [] } }, {}], - ['removes a not whose operand is a cut recursion', { $defs: { a: { not: { $ref: '#/$defs/a' } } }, $ref: '#/$defs/a' }, { allOf: [{}] }], - ['removes a not whose null-only type has a malformed enum', { not: { enum: 'junk', type: 'null' } }, {}], - // `const: 1` with `enum: [2]` accepts nothing, but `enum: [1]` accepts 1. - ['removes a not whose const falls outside its enum', { not: { const: 1, enum: [2] } }, {}], - ['removes both nots of a loosened double negation', { not: { not: { prefixItems: [] } } }, {}], - [ - 'follows loosening through items, additionalProperties, allOf, and anyOf', - { not: { allOf: [{ anyOf: [{ additionalProperties: { items: { contains: {} } } }] }] } }, - {}, - ], - ['keeps a not whose const lies inside its enum', { not: { const: 1, enum: [1, 2] } }, { not: { enum: [1] } }], - ['keeps a not whose operand converts exactly', { not: { type: ['string', 'null'] } }, { not: { nullable: true, type: 'string' } }], - ['keeps a not whose null-only operand matches nothing exactly', { not: { const: 'a', type: 'null' } }, { not: { enum: ['a'], not: {} } }], - // 3.1 ignores `nullable`, so this accepts null. Dropping `nullable` is - // exact, so the not stays and keeps accepting null. - ['keeps a not whose operand had a 3.0 nullable', { not: { nullable: true, type: 'string' } }, { not: { type: 'string' } }], - ['keeps a not over a boolean schema', { not: false }, { not: { not: {} } }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('oneOf', () => { - it.each([ - ['turns a oneOf with a loosened branch into anyOf', { oneOf: [{ prefixItems: [] }, { type: 'string' }] }, { anyOf: [{}, { type: 'string' }] }], - [ - 'nests that anyOf in allOf beside an existing anyOf', - { anyOf: [{ type: 'string' }], oneOf: [{ unevaluatedProperties: false }] }, - { allOf: [{ anyOf: [{}] }], anyOf: [{ type: 'string' }] }, - ], - ['keeps a oneOf whose branches convert exactly', { oneOf: [{ type: ['integer', 'null'] }, { type: 'string' }] }, { oneOf: [{ nullable: true, type: 'integer' }, { type: 'string' }] }], - // 3.1 ignores `nullable`, so only the second branch accepts null. Kept, it - // would make both accept null, and oneOf would reject it. - [ - 'keeps a oneOf whose branch had a 3.0 nullable', - { oneOf: [{ nullable: true, type: 'string' }, { type: 'null' }] }, - { oneOf: [{ type: 'string' }, { enum: [null] }] }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('object cycles', () => { - // A dereferenced schema can contain itself. The cycle is kept as a cycle, - // but it cannot be proven exact, so it counts as loosened. - it('treats a cycle of the input graph as loosened under not and oneOf', () => { - const negated: any = { not: { properties: {} }, patternProperties: { '^x': { type: 'string' } } } - negated.not.properties.p = negated - expect(convertSchema(negated)).toEqual({}) - - const tree: any = { oneOf: [{ required: ['value'], type: 'object' }], unevaluatedProperties: false } - tree.oneOf.push({ properties: { children: { items: tree, type: 'array' } }, type: 'object' }) - const out = convertSchema(tree) - expect(out).not.toHaveProperty('oneOf') - expect(dig(out, 'anyOf', '1', 'properties', 'children', 'items')).toBe(out) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts deleted file mode 100644 index c45c92b..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/numeric-bounds.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// JSON Schema 2020-12 `exclusiveMinimum` / `exclusiveMaximum` are numbers, -// bounds of their own: -// https://json-schema.org/draft/2020-12/json-schema-validation#section-6.2.5 -// In 3.0 (draft Wright-00) they are booleans that make `minimum` / `maximum` -// exclusive: https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords -// https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#update-exclusiveminimum-and-exclusivemaximum -// 3.1 allows both an inclusive and an exclusive bound at once, while 3.0 has -// one bound per side, so the tighter of the two is kept. At a tie the -// exclusive one is tighter. - -import { convertSchema } from './helpers' - -it.each([ - ['turns a numeric exclusiveMinimum into minimum plus the flag', { exclusiveMinimum: 3 }, { exclusiveMinimum: true, minimum: 3 }], - ['keeps a tighter inclusive minimum', { exclusiveMinimum: 3, minimum: 5 }, { minimum: 5 }], - ['replaces a looser inclusive minimum', { exclusiveMinimum: 5, minimum: 3 }, { exclusiveMinimum: true, minimum: 5 }], - ['prefers the exclusive form for equal minimums', { exclusiveMinimum: 3, minimum: 3 }, { exclusiveMinimum: true, minimum: 3 }], - ['turns a numeric exclusiveMaximum into maximum plus the flag', { exclusiveMaximum: 10 }, { exclusiveMaximum: true, maximum: 10 }], - ['keeps a tighter inclusive maximum', { exclusiveMaximum: 10, maximum: 5 }, { maximum: 5 }], - ['replaces a looser inclusive maximum', { exclusiveMaximum: 5, maximum: 10 }, { exclusiveMaximum: true, maximum: 5 }], - ['prefers the exclusive form for equal maximums', { exclusiveMaximum: 5, maximum: 5 }, { exclusiveMaximum: true, maximum: 5 }], - ['converts both sides at once', { exclusiveMaximum: 9, exclusiveMinimum: 1 }, { exclusiveMaximum: true, exclusiveMinimum: true, maximum: 9, minimum: 1 }], -])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) -}) - -it.each([ - ['a 3.0-style boolean exclusiveMinimum', { exclusiveMinimum: true, minimum: 3 }], - ['a 3.0-style boolean exclusiveMaximum', { exclusiveMaximum: false, maximum: 3 }], -])('passes %s through', (_name, input) => { - expect(convertSchema(input)).toEqual(input) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts deleted file mode 100644 index f8c45a2..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { convertSchema } from './helpers' - -describe('$ref with sibling keywords', () => { - // A 3.0 Reference Object "cannot be extended with additional properties, - // and any properties added SHALL be ignored": - // https://spec.openapis.org/oas/v3.0.4.html#reference-object - // In 3.1 a `$ref` applies beside its siblings, like one more `allOf` entry: - // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1 - // Moving the `$ref` into `allOf` keeps both applying in 3.0. It goes - // first, so existing `allOf` entries keep their relative order. - it.each([ - ['moves the $ref into allOf', { $ref: '#/c/s', minLength: 1 }, { allOf: [{ $ref: '#/c/s' }], minLength: 1 }], - ['prepends the $ref to an existing allOf', { $ref: '#/c/s', allOf: [{ type: 'string' }] }, { allOf: [{ $ref: '#/c/s' }, { type: 'string' }] }], - ['nests a malformed allOf instead of discarding it', { $ref: '#/c/s', allOf: 'junk' }, { allOf: [{ $ref: '#/c/s' }, { allOf: 'junk' }] }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - - it('keeps a lone $ref as a bare Reference Object, wherever it points', () => { - const input = { $ref: '#/components/schemas/Pet' } - const result = convertSchema(input) - expect(result).toEqual(input) - expect(result).not.toBe(input) - expect(convertSchema({ $ref: 'https://example.com/pet.json' })).toEqual({ $ref: 'https://example.com/pet.json' }) - }) - - it('passes a non-string $ref through', () => { - expect(convertSchema({ $ref: 123, type: 'string' })).toEqual({ $ref: 123, type: 'string' }) - expect(convertSchema({ $ref: 123 })).toEqual({ $ref: 123 }) - }) -}) - -describe('references into removed keywords', () => { - // `$defs` has no 3.0 form, and 3.0 schemas are reused through - // `components.schemas` instead. A standalone schema has no components, so - // each `$ref` into `$defs` is replaced by the converted definition. - // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.4 - it('inlines $refs into $defs', () => { - expect(convertSchema({ $defs: { a: { type: ['string', 'null'] } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ - items: { nullable: true, type: 'string' }, - type: 'array', - }) - }) - - // Inlining a recursive definition would never end. The recursion is cut - // at its first repeat with `{}`, the schema that accepts anything, so the - // result can only be looser than the original, never stricter. - it('cuts recursion into {}', () => { - expect(convertSchema({ - $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, - $ref: '#/$defs/node', - })).toEqual({ allOf: [{ properties: { next: {} }, type: 'object' }] }) - }) - - it('inlines a definition that is itself an external reference', () => { - expect(convertSchema({ - $defs: { pet: { $ref: './schemas/pet.yaml' } }, - properties: { pet: { $ref: '#/$defs/pet' } }, - })).toEqual({ properties: { pet: { $ref: './schemas/pet.yaml' } } }) - }) - - // The `items` beside `prefixItems` is removed, and an `items: {}` - // placeholder takes its place so the array stays valid 3.0. A `$ref` to - // the original `items` must get the original schema, not the placeholder. - it('inlines a $ref to items removed beside prefixItems instead of the placeholder that replaced them', () => { - expect(convertSchema({ - properties: { - cell: { $ref: '#/properties/row/items' }, - notCell: { not: { $ref: '#/properties/row/items' } }, - row: { items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' }, - }, - })).toEqual({ - properties: { - cell: { type: 'integer' }, - notCell: { not: { type: 'integer' } }, - row: { items: {}, type: 'array' }, - }, - }) - }) - - // A standalone schema has no document around it, so pointers into - // `components` or `webhooks` cannot be checked and stay as written. - it('leaves references and mapping entries that point outside the schema as written', () => { - const schema = { - discriminator: { mapping: { a: '#/webhooks/newPet/post/requestBody/content/application~1json/schema' }, propertyName: 'kind' }, - properties: { a: { $ref: '#/webhooks/newPet/post/requestBody/content/application~1json/schema' } }, - } - expect(convertSchema(schema)).toEqual(schema) - }) -}) - -describe('references inside a schema with an $id', () => { - // An `$id` starts a new schema resource, and a `$ref` inside it resolves - // against that resource rather than the document root: - // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.1 - // 3.0 has no `$id`, so the output resolves every `$ref` against the root. - // A JSON pointer inside such a schema is rebased onto the root, and its - // target inlined where the rebased pointer dangles. - it('inlines the definition of the enclosing resource, not the root definition of the same name', () => { - expect(convertSchema({ - $defs: { A: { type: 'string' } }, - properties: { - x: { $defs: { A: { type: 'number' } }, $id: 'https://example.com/x', properties: { y: { $ref: '#/$defs/A' } } }, - }, - })).toEqual({ properties: { x: { properties: { y: { type: 'number' } } } } }) - }) - - it('reads # as the enclosing resource and cuts its recursion into {}', () => { - expect(convertSchema({ - $defs: { - Tree: { $id: 'https://example.com/tree', properties: { kids: { items: { $ref: '#' }, type: 'array' } }, type: 'object' }, - }, - properties: { t: { $ref: '#/$defs/Tree' } }, - required: ['must'], - })).toEqual({ - properties: { t: { properties: { kids: { items: {}, type: 'array' } }, type: 'object' } }, - required: ['must'], - }) - }) - - it.each([ - ['x', '#/properties/x/properties/a'], - ['a b/c~%', '#/properties/a%20b~1c~0%25/properties/a'], - ])('rebases a pointer whose target survives onto the root, under the key %j', (key, pointer) => { - expect(convertSchema({ - properties: { [key]: { $id: 'https://example.com/x', properties: { a: { type: 'string' }, b: { $ref: '#/properties/a' } } } }, - })).toEqual({ - properties: { [key]: { properties: { a: { type: 'string' }, b: { $ref: pointer } } } }, - }) - }) - - it('resolves a $ref beside an $id against that $id', () => { - expect(convertSchema({ - $defs: { A: { type: 'string' } }, - properties: { x: { $defs: { A: { type: 'number' } }, $id: 'https://example.com/x', $ref: '#/$defs/A', minimum: 1 } }, - })).toEqual({ properties: { x: { allOf: [{ type: 'number' }], minimum: 1 } } }) - }) - - it('resolves the references inside an inlined target and along an alias chain against the resource holding them', () => { - expect(convertSchema({ - $defs: { - A: { type: 'string' }, - X: { - $defs: { A: { type: 'number' }, B: { $ref: '#/$defs/A' } }, - $id: 'https://example.com/x', - properties: { p: { $ref: '#/$defs/A' } }, - }, - }, - properties: { alias: { $ref: '#/$defs/X/$defs/B' }, nested: { $ref: '#/$defs/X/properties/p' } }, - })).toEqual({ properties: { alias: { type: 'number' }, nested: { type: 'number' } } }) - }) - - // A pointer that dangles inside its resource dangles in the source too. - // Rebased, it keeps dangling rather than reaching the root target of the - // same name. - it('rebases a pointer that dangles inside its resource instead of binding it to the root', () => { - expect(convertSchema({ - $defs: { A: { type: 'string' } }, - properties: { x: { $id: 'https://example.com/x', properties: { y: { $ref: '#/$defs/A' } } } }, - })).toEqual({ properties: { x: { properties: { y: { $ref: '#/properties/x/$defs/A' } } } } }) - }) - - // A `mapping` value that is a URI reference resolves against the nearest - // `$id` too: https://spec.openapis.org/oas/v3.1.2.html#relative-references-in-api-description-uris - // It cannot be inlined, so one whose target is removed is dropped. - it('rebases discriminator mapping pointers onto the root, dropping those whose target is removed', () => { - expect(convertSchema({ - properties: { - x: { - $defs: { Dog: { type: 'object' } }, - $id: 'https://example.com/x', - discriminator: { mapping: { cat: '#/properties/cat', dog: '#/$defs/Dog', fish: 'Fish' }, propertyName: 'kind' }, - properties: { cat: { type: 'object' } }, - }, - }, - })).toEqual({ - properties: { - x: { - discriminator: { mapping: { cat: '#/properties/x/properties/cat', fish: 'Fish' }, propertyName: 'kind' }, - properties: { cat: { type: 'object' } }, - }, - }, - }) - }) - - // Only JSON pointers can be rebased. `$anchor` and `$id` are removed, so - // references through them are left as written and dangle. - it('leaves references to an $anchor or a URI as written', () => { - expect(convertSchema({ - properties: { - x: { $id: 'https://example.com/x', properties: { a: { $ref: '#node' }, b: { $ref: 'node.json' }, c: { $ref: 'https://example.com/x' } } }, - }, - })).toEqual({ - properties: { x: { properties: { a: { $ref: '#node' }, b: { $ref: 'node.json' }, c: { $ref: 'https://example.com/x' } } } }, - }) - }) - - // `$id: "#name"` is a draft-07 plain-name fragment, and an empty `$id` - // repeats the current base. Neither starts a new resource. - it('resolves against the root through an $id that starts no new resource', () => { - expect(convertSchema({ - $defs: { A: { type: 'string' } }, - properties: { x: { $id: '#x', properties: { y: { $ref: '#/$defs/A' } } }, z: { $id: '', $ref: '#/$defs/A' } }, - })).toEqual({ properties: { x: { properties: { y: { type: 'string' } } }, z: { allOf: [{ type: 'string' }] } } }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts deleted file mode 100644 index 9a16a71..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/removed-keywords.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// 3.0 supports a fixed subset of JSON Schema, and "additional keywords -// defined by the JSON Schema specification that are not mentioned here are -// strictly unsupported": https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords -// The official 3.0 schema rejects them (`additionalProperties: false`), so -// JSON Schema 2020-12 keywords without a 3.0 form are removed. - -import { convertSchema } from './helpers' - -it('removes every keyword with no 3.0 equivalent', () => { - expect(convertSchema({ - $anchor: 'a', - $comment: 'c', - $defs: { D: { type: 'string' } }, - $dynamicAnchor: 'da', - $dynamicRef: '#dr', - $id: 'https://example.com/s', - $schema: 'https://json-schema.org/draft/2020-12/schema', - $vocabulary: { 'https://example.com/v': true }, - contains: { type: 'string' }, - contentSchema: { type: 'string' }, - dependentRequired: { a: ['b'] }, - dependentSchemas: { a: { type: 'object' } }, - else: { title: 'e' }, - if: { title: 'i' }, - maxContains: 2, - minContains: 1, - patternProperties: { '^x': { type: 'string' } }, - prefixItems: [{ type: 'string' }], - propertyNames: { pattern: '^a' }, - then: { title: 't' }, - type: 'string', - unevaluatedItems: false, - unevaluatedProperties: false, - })).toEqual({ type: 'string' }) -}) - -// Some keywords only mean something together with a removed neighbor: -// - Beside `prefixItems`, `items` applies to the items after the prefix -// only: https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1.2 -// Kept alone, it would wrongly constrain the prefix items too. -// - Beside `patternProperties`, `additionalProperties` skips the -// properties the patterns match: https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.3 -// Kept alone, it would wrongly constrain those properties too. -it.each([ - ['removes items together with prefixItems', { items: { type: 'integer' }, prefixItems: [{ type: 'string' }] }, {}], - [ - 'removes a boolean additionalProperties together with patternProperties', - { additionalProperties: false, patternProperties: { '^x-': {} }, properties: { name: { type: 'string' } }, type: 'object' }, - { properties: { name: { type: 'string' } }, type: 'object' }, - ], - [ - 'removes a schema additionalProperties together with patternProperties', - { additionalProperties: { type: 'integer' }, patternProperties: { '^x-': {} }, type: 'object' }, - { type: 'object' }, - ], - // An array keeps its `type`, so it needs an `items` again once the removed - // `prefixItems` took the original one with it. - ['gives an array that lost its items an empty one', { items: { type: 'integer' }, prefixItems: [{ type: 'string' }], type: 'array' }, { items: {}, type: 'array' }], -])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) -}) - -it('keeps extensions and unknown keywords', () => { - const input = { 'customKeyword': 'v', 'title': 't', 'x-foo': { a: 1 } } - expect(convertSchema(input)).toEqual(input) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts deleted file mode 100644 index 80db7e7..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/subschemas.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { downgradeSchemaV31ToV30 } from '@openapi-spec/downgrader' -import { convertSchema } from './helpers' - -describe('boolean schemas', () => { - // `true` and `false` are schemas in JSON Schema 2020-12 that accept - // everything and nothing: https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 - // 3.0 needs Schema Objects, so they become `{}` and `{ not: {} }`. - it('converts the boolean schemas', () => { - expect(downgradeSchemaV31ToV30(true)).toEqual({}) - expect(downgradeSchemaV31ToV30(false)).toEqual({ not: {} }) - }) - - // `additionalProperties` is the one place 3.0 still takes a boolean: - // https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords - it.each([ - ['keeps a boolean additionalProperties', { additionalProperties: false }, { additionalProperties: false }], - ['converts a boolean property schema', { properties: { a: true } }, { properties: { a: {} } }], - ['converts a true items schema', { items: true }, { items: {} }], - ['converts a false items schema', { items: false }, { items: { not: {} } }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('nested schemas', () => { - it.each([ - [ - 'converts property schemas', - { properties: { a: { type: ['string', 'null'] }, b: true }, type: 'object' }, - { properties: { a: { nullable: true, type: 'string' }, b: {} }, type: 'object' }, - ], - ['converts a schema additionalProperties', { additionalProperties: { type: ['string', 'null'] } }, { additionalProperties: { nullable: true, type: 'string' } }], - [ - 'converts allOf, anyOf, oneOf, and not', - { allOf: [true], anyOf: [{ const: 1 }], not: false, oneOf: [{ type: ['integer', 'null'] }] }, - { allOf: [{}], anyOf: [{ enum: [1] }], not: { not: {} }, oneOf: [{ nullable: true, type: 'integer' }] }, - ], - ['converts items', { items: { type: ['string', 'null'] } }, { items: { nullable: true, type: 'string' } }], - ['passes a malformed allOf through', { allOf: 'junk' }, { allOf: 'junk' }], - ['passes malformed properties through', { properties: 5 }, { properties: 5 }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - - // `const`, `default`, and `enum` hold instance data: a value there that - // looks like a schema is copied as is. - it('does not convert schema-like values outside subschema positions', () => { - const data = { type: ['string', 'null'] } - expect(convertSchema({ 'default': data, 'enum': [data], 'x-data': data })).toEqual({ 'default': data, 'enum': [data], 'x-data': data }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts deleted file mode 100644 index 7ec22dd..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/type.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// In 3.0, `type` MUST be a single string and `"null"` is not a type: -// https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords -// https://spec.openapis.org/oas/v3.0.4.html#data-types -// Null is allowed with `nullable: true` instead, which only takes effect -// beside an explicit `type`: https://spec.openapis.org/oas/v3.0.4.html#schema-nullable -// The official guide shows the same mapping in the other direction: -// https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#replace-nullable-with-type-arrays - -import { convertSchema } from './helpers' - -describe('a single type', () => { - it.each([ - ['keeps a single type', { type: 'string' }, { type: 'string' }], - ['turns a type and null into nullable', { type: ['string', 'null'] }, { nullable: true, type: 'string' }], - ['deduplicates entries', { type: ['string', 'string'] }, { type: 'string' }], - ['ignores non-string entries beside valid ones', { type: ['string', 42] }, { type: 'string' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('only null', () => { - // `nullable` does nothing without a `type` beside it, so a schema that - // accepts only null becomes an enum of the single value null. - it.each([ - ['turns type: "null" into a null enum', { type: 'null' }, { enum: [null] }], - ['turns type: ["null"] into a null enum', { type: ['null'] }, { enum: [null] }], - ['narrows an existing enum that allows null', { enum: ['a', null], type: ['null'] }, { enum: [null] }], - ['converts a null-only anyOf branch', { anyOf: [{ type: 'string' }, { type: 'null' }] }, { anyOf: [{ type: 'string' }, { enum: [null] }] }], - // Here the 3.1 schema accepts nothing at all: the value must be null and - // also one of the enum values, none of which is null. `not: {}` keeps - // that meaning, since `{}` accepts everything. - ['matches nothing when the enum of a null-only type excludes null', { enum: ['a'], type: ['null'] }, { enum: ['a'], not: {} }], - ['drops the type beside a malformed enum', { enum: 'junk', type: ['null'] }, { enum: 'junk' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('a 3.0 nullable in the input', () => { - // 3.1 removed `nullable`, so in a 3.1 schema it is an unknown keyword that - // admits nothing: https://learn.openapis.org/upgrading/v3.0-to-v3.1.html#replace-nullable-with-type-arrays - // Kept, it would admit null in 3.0. Only a `"null"` entry in `type` makes - // the output nullable, so `nullable` never appears without a `type`. - it.each([ - ['drops it beside a single type', { nullable: true, type: 'string' }, { type: 'string' }], - ['drops it beside a null-only type', { nullable: true, type: 'null' }, { enum: [null] }], - ['drops it beside several types', { nullable: true, type: ['string', 'integer'] }, { anyOf: [{ type: 'string' }, { type: 'integer' }] }], - ['drops it without a type', { nullable: true }, {}], - ['lets null in type override nullable: false', { nullable: false, type: ['string', 'null'] }, { nullable: true, type: 'string' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('several types', () => { - // A union of types has no 3.0 `type` form, so each type becomes its own - // `anyOf` branch. When null was listed, every branch is nullable. - it.each([ - ['turns several types into anyOf branches', { type: ['string', 'integer'] }, { anyOf: [{ type: 'string' }, { type: 'integer' }] }], - [ - 'makes every branch nullable when null was listed', - { type: ['string', 'integer', 'null'] }, - { anyOf: [{ nullable: true, type: 'string' }, { nullable: true, type: 'integer' }] }, - ], - // 3.0 requires `items` wherever `type` is `"array"`, so the array branch - // takes the sibling `items`, or an empty one when there is none. Other - // types ignore `items`, so it moves rather than being copied. - ['gives the array branch an empty items', { type: ['array', 'string'] }, { anyOf: [{ items: {}, type: 'array' }, { type: 'string' }] }], - [ - 'moves a sibling items into the array branch', - { items: { type: 'integer' }, type: ['array', 'string', 'null'] }, - { anyOf: [{ items: { type: 'integer' }, nullable: true, type: 'array' }, { nullable: true, type: 'string' }] }, - ], - [ - 'leaves items in place when no branch is an array', - { items: { type: 'integer' }, type: ['object', 'string'] }, - { anyOf: [{ type: 'object' }, { type: 'string' }], items: { type: 'integer' } }, - ], - // An existing `anyOf` must keep applying too, so the type union joins - // `allOf` rather than replacing or merging into it. - [ - 'wraps the union into allOf when anyOf already exists', - { anyOf: [{ minLength: 1 }], type: ['string', 'integer'] }, - { allOf: [{ anyOf: [{ type: 'string' }, { type: 'integer' }] }], anyOf: [{ minLength: 1 }] }, - ], - [ - 'appends the union to an existing allOf', - { allOf: [{ title: 't' }], anyOf: [{ minLength: 1 }], type: ['string', 'integer'] }, - { allOf: [{ title: 't' }, { anyOf: [{ type: 'string' }, { type: 'integer' }] }], anyOf: [{ minLength: 1 }] }, - ], - [ - 'nests a malformed allOf beside the union', - { allOf: 'junk', anyOf: [{ type: 'string' }], items: { type: 'integer' }, type: ['array', 'string'] }, - { - allOf: [{ allOf: 'junk' }, { anyOf: [{ items: { type: 'integer' }, type: 'array' }, { type: 'string' }] }], - anyOf: [{ type: 'string' }], - }, - ], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) - - // Each level moves `items` into one branch instead of copying it into - // several, so nested unions grow linearly rather than doubling per level. - it('keeps nested multi-type arrays linear instead of doubling per level', () => { - let input: unknown = { type: 'string' } - let expected: unknown = { type: 'string' } - for (let index = 0; index < 10; index += 1) { - input = { items: input, type: ['array', 'object'] } - expected = { anyOf: [{ items: expected, type: 'array' }, { type: 'object' }] } - } - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('arrays', () => { - // "`items` MUST be present if `type` is `"array"`": - // https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords - // `{}` accepts every item, which is what an absent `items` means in 3.1. - it.each([ - ['adds an empty items to an array without one', { type: 'array' }, { items: {}, type: 'array' }], - ['adds an empty items to a nullable array without one', { type: ['array', 'null'] }, { items: {}, nullable: true, type: 'array' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('malformed type values', () => { - it.each([ - ['passes an array of non-string entries through', { type: [42] }, { type: [42] }], - ['passes a number through', { type: 42 }, { type: 42 }], - ['passes an object through', { type: { a: 1 } }, { type: { a: 1 } }], - ['drops an empty array', { type: [] }, {}], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap b/packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap deleted file mode 100644 index 6ca311d..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/__snapshots__/corpus.test.ts.snap +++ /dev/null @@ -1,398 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`official examples > converts the tictactoe example 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[`official examples > empties the roles on the non-OAuth scheme of the non-OAuth-scopes example 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[`official examples > removes the 3.1-only constructs and the mutualTLS scheme of the mega document 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[`official examples > removes the webhooks of the webhook example, leaving 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": {}, -} -`; diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts deleted file mode 100644 index 5c2799f..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { dig } from '../../helpers' -import { convertComponent, convertSpec } from './helpers' - -it('removes pathItems and keeps the other component maps', () => { - const result = convertSpec({ - components: { - pathItems: { Reusable: { get: { summary: 's' } } }, - schemas: { S: { type: 'string' } }, - }, - }) - expect(result.components).toEqual({ schemas: { S: { type: 'string' } } }) -}) - -it('converts component callbacks and schemas, including boolean schemas', () => { - expect(convertSpec({ - components: { - 'callbacks': { - junkCallback: 42, - realCallback: { - 'x-note': { '{$expr}': { get: {} } }, - '{$request.body#/url}': { post: { summary: 's' } }, - }, - }, - 'schemas': { S: { type: ['string', 'null'] }, T: true }, - 'x-extra': { keep: true }, - }, - }).components).toEqual({ - 'callbacks': { - junkCallback: 42, - realCallback: { - 'x-note': { '{$expr}': { get: {} } }, - '{$request.body#/url}': { post: { responses: { default: { description: '' } }, summary: 's' } }, - }, - }, - 'schemas': { S: { nullable: true, type: 'string' }, T: {} }, - 'x-extra': { keep: true }, - }) -}) - -it('strips the overrides from a callback reference to a missing path item', () => { - expect(convertComponent('callbacks', { $ref: '#/components/pathItems/Reusable', summary: 's' })).toEqual({ - $ref: '#/components/pathItems/Reusable', - }) -}) - -it('keeps examples as they are, since 3.0 examples have the same fields', () => { - expect(convertComponent('examples', { description: 'd', summary: 's', value: { a: 1 } })).toEqual({ - description: 'd', - summary: 's', - value: { a: 1 }, - }) -}) - -it('clones a malformed components value unchanged', () => { - expect(convertSpec({ components: 'junk' }).components).toBe('junk') -}) - -// A single object can sit in positions of different kinds, for example as -// both an Operation and a Schema. Each position gets its own conversion, -// whichever it meets first. -it('converts an object shared between an operation and a schema as each', () => { - const shared = {} - for (const fields of [ - { components: { schemas: { S: shared } }, paths: { '/a': { get: shared } } }, - { paths: { '/a': { get: shared } }, components: { schemas: { S: shared } } }, - ]) { - const result = convertSpec(fields) - expect(dig(result, 'paths', '/a', 'get')).toEqual({ responses: { default: { description: '' } } }) - expect(dig(result, 'components', 'schemas', 'S')).toEqual({}) - } -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts deleted file mode 100644 index 83c9ace..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/corpus.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -// Each official 3.1 document (see tests/corpus.ts) must downgrade to a -// document the official 3.0 JSON Schema accepts, without leaving a reference -// dangling that resolved before. - -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' - -import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' - -import { doc as nonOauthScopesExample } 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 mega } from '../../../../types/tests/schema-tests-3.1/mega' -import { corpusV31 } from '../../corpus' -import { expectValidAs, expectValidDowngrade } from '../../validate' - -describe('official corpus', () => { - it.each(corpusV31)('converts %s to a valid 3.0 document', async (_name, doc) => { - await expectValidDowngrade(doc, downgradeSpecV31ToV30, '3.1', '3.0') - }) -}) - -describe('official examples', () => { - it('converts the tictactoe example', () => { - expect(downgradeSpecV31ToV30(tictactoe)).toMatchSnapshot() - }) - - it('removes the webhooks of the webhook example, leaving empty paths', () => { - const v30 = downgradeSpecV31ToV30(webhookExample) - expect(v30).not.toHaveProperty('webhooks') - expect(v30.paths).toEqual({}) - expect(v30.components).toHaveProperty(['schemas', 'Pet']) - expect(v30).toMatchSnapshot() - }) - - it('empties the roles on the non-OAuth scheme of the non-OAuth-scopes example', () => { - const v30 = downgradeSpecV31ToV30(nonOauthScopesExample) - expect(v30.paths['/users']?.get?.security).toEqual([{ bearerAuth: [] }]) - expect(v30.paths['/users']?.get?.responses).toEqual({ default: { description: '' } }) - expect(v30).toMatchSnapshot() - }) - - it('removes the 3.1-only constructs and the mutualTLS scheme of the mega document', () => { - const v30 = downgradeSpecV31ToV30(mega) - expect(v30.info).toEqual({ license: { name: 'Apache 2.0' }, title: 'My API', version: '1.0.0' }) - expect(v30.components).not.toHaveProperty('pathItems') - expect(v30.components?.securitySchemes).toEqual({}) - expect(JSON.stringify(v30)).not.toContain('#/components/pathItems/') - expect(v30).toMatchSnapshot() - }) - - // A document that only uses what 3.0 already had comes out unchanged, - // apart from the version. - it('passes the 3.0 petstore example through apart from the version', () => { - expect(downgradeSpecV31ToV30(petstore as any)).toEqual({ ...structuredClone(petstore), openapi: '3.0.4' }) - }) -}) - -describe('hand-written documents', () => { - it('inlines references into webhooks and components.pathItems into a valid 3.0 document', async () => { - const petSchema = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' - const doc: OpenAPIV3_1.OpenAPIObject = { - components: { - pathItems: { - item: { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], - }, - }, - schemas: { Pet: { $ref: petSchema } }, - }, - info: { title: 'Webhook references', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/items': { $ref: '#/components/pathItems/item' }, - '/pets': { - get: { - parameters: [ - { $ref: '#/webhooks/newPet/post/parameters/0' }, - { $ref: '#/components/pathItems/item/parameters/0', description: 'Filter' }, - ], - responses: { - 200: { - content: { 'application/json': { schema: { items: { $ref: petSchema }, type: 'array' } } }, - description: 'ok', - links: { - hook: { operationRef: '#/webhooks/newPet/post' }, - item: { operationRef: '#/components/pathItems/item/get' }, - }, - }, - 201: { $ref: '#/webhooks/newPet/post/responses/200' }, - }, - }, - }, - }, - webhooks: { - newPet: { - post: { - operationId: 'newPetHook', - parameters: [{ in: 'header', name: 'X-Signature', schema: { type: 'string' } }], - requestBody: { - content: { - 'application/json': { - schema: { properties: { name: { type: 'string' }, parent: { $ref: petSchema } }, type: 'object' }, - }, - }, - }, - responses: { 200: { description: 'received' } }, - }, - }, - }, - } - await expectValidAs(doc, '3.1') - const v30 = downgradeSpecV31ToV30(doc) - const pet = { properties: { name: { type: 'string' }, parent: {} }, type: 'object' } - expect(v30.components).toEqual({ schemas: { Pet: pet } }) - expect(v30.paths).toEqual({ - '/items': { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], - }, - '/pets': { - get: { - parameters: [ - { in: 'header', name: 'X-Signature', schema: { type: 'string' } }, - { in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }, - ], - responses: { - 200: { content: { 'application/json': { schema: { items: pet, type: 'array' } } }, description: 'ok', links: {} }, - 201: { description: 'received' }, - }, - }, - }, - }) - await expectValidAs(v30, '3.0') - }) - - it('converts raw and encoded binary bodies into a valid 3.0 document', async () => { - const doc: OpenAPIV3_1.OpenAPIObject = { - info: { title: 'Uploads', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/avatar': { - put: { - requestBody: { - content: { - 'image/png': { schema: { contentMediaType: 'image/png' } }, - 'text/plain': { schema: { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' } }, - }, - }, - responses: { 204: { description: 'saved' } }, - }, - }, - }, - } - const v30 = downgradeSpecV31ToV30(doc) - expect(v30.paths['/avatar']?.put?.requestBody).toEqual({ - content: { - 'image/png': { schema: { format: 'binary', type: 'string' } }, - 'text/plain': { schema: { format: 'byte', type: 'string' } }, - }, - }) - await expectValidAs(v30, '3.0') - }) - - it('keeps untyped multipart parts sent as application/octet-stream in a valid 3.0 document', async () => { - const schema: OpenAPIV3_1.SchemaObject = { - properties: { - addresses: { items: { type: 'object' }, type: 'array' }, - file: { items: {}, type: 'array' }, - id: { format: 'uuid', type: 'string' }, - profileImage: {}, - }, - type: 'object', - } - const headers = { 'X-Rate-Limit-Limit': { schema: { type: 'integer' } } } as const - const doc: OpenAPIV3_1.OpenAPIObject = { - info: { title: 'Uploads', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/profile': { - post: { - requestBody: { content: { 'multipart/form-data': { encoding: { profileImage: { headers } }, schema } } }, - responses: { 204: { description: 'saved' } }, - }, - }, - }, - } - const v30 = downgradeSpecV31ToV30(doc) - expect(v30.paths['/profile']?.post?.requestBody).toEqual({ - content: { - 'multipart/form-data': { - encoding: { - file: { contentType: 'application/octet-stream' }, - profileImage: { contentType: 'application/octet-stream', headers }, - }, - schema, - }, - }, - }) - await expectValidAs(v30, '3.0') - }) - - // `defaultMapping` is a 3.2 field that can reach a 3.1 document written by - // hand or by a lenient tool. The 3.0 schema tolerates unknown - // discriminator fields, so it is kept. - it('keeps a discriminator defaultMapping, which the 3.0 schema tolerates', async () => { - const doc = { - 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 v30 = downgradeSpecV31ToV30(doc as any) - expect(v30).toHaveProperty(['components', 'schemas', 'Pet', 'discriminator'], doc.components.schemas.Pet.discriminator) - await expectValidAs(v30, '3.0') - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts deleted file mode 100644 index ae0782d..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/document.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' - -import { convertSpec, info } from './helpers' - -/** The converted form of a document holding nothing but `info`. */ -const empty = { info, openapi: '3.0.4', paths: {} } - -describe('openapi and paths', () => { - it('stamps 3.0.4, the latest 3.0 patch release', () => { - expect(downgradeSpecV31ToV30({ info, openapi: '3.1.1', paths: {} })).toEqual(empty) - }) - - // 3.1 made `paths` optional (a document may hold only webhooks or - // components): https://spec.openapis.org/oas/v3.1.2.html#oas-paths - // In 3.0 it is REQUIRED: https://spec.openapis.org/oas/v3.0.4.html#oas-paths - // An empty Paths Object is valid and describes no operations. - it('adds the version and an empty paths object when they are missing', () => { - expect(downgradeSpecV31ToV30({ info } as any)).toEqual(empty) - }) - - it('clones non-object input unchanged', () => { - expect(downgradeSpecV31ToV30(null as any)).toBeNull() - expect(downgradeSpecV31ToV30(42 as any)).toBe(42) - expect(downgradeSpecV31ToV30('spec' as any)).toBe('spec') - const list = [1, { a: 1 }] - const result = downgradeSpecV31ToV30(list as any) - expect(result).toEqual(list) - expect(result).not.toBe(list) - }) - - it('keeps unknown top-level keys and extensions', () => { - expect(convertSpec({ 'future': { a: 1 }, 'x-root': true })).toEqual({ ...empty, 'future': { a: 1 }, 'x-root': true }) - }) -}) - -describe('3.1-only root fields', () => { - // `jsonSchemaDialect` and `webhooks` are new in 3.1: - // https://spec.openapis.org/oas/v3.1.2.html#oas-json-schema-dialect - // https://spec.openapis.org/oas/v3.1.2.html#oas-webhooks - // 3.0 can only describe requests the API sends as callbacks of one of its - // operations, not as standalone webhooks, and an `x-` extension would - // only hide them from tools, so webhooks are removed. - // References into them are inlined (see removed-parts.test.ts). - it('removes jsonSchemaDialect and webhooks without leaving an extension behind', () => { - const result = convertSpec({ - jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', - webhooks: { newPet: { post: { summary: 's' } } }, - }) - expect(result).toEqual(empty) - }) -}) - -describe('info', () => { - // `info.summary` and `license.identifier` (an SPDX expression) are new in - // 3.1: https://spec.openapis.org/oas/v3.1.2.html#info-summary - // https://spec.openapis.org/oas/v3.1.2.html#license-identifier - it('removes summary and license.identifier and keeps the other fields', () => { - expect(convertSpec({ - info: { - license: { identifier: 'MIT', name: 'MIT', url: 'https://opensource.org/license/mit' }, - summary: 'short', - title: 't', - version: '1', - }, - }).info).toEqual({ - license: { name: 'MIT', url: 'https://opensource.org/license/mit' }, - title: 't', - version: '1', - }) - }) - - it('clones malformed info and license values unchanged', () => { - expect(convertSpec({ info: 42 }).info).toBe(42) - expect(convertSpec({ info: { license: 'MIT', title: 't', version: '1' } }).info).toEqual({ license: 'MIT', title: 't', version: '1' }) - }) -}) - -describe('paths', () => { - // Paths Object keys are templates that start with a slash; any other key - // can only be a specification extension: https://spec.openapis.org/oas/v3.0.4.html#paths-object - it('converts path items and clones non-path keys', () => { - expect(convertSpec({ - paths: { - '/a': { get: { summary: 's' } }, - 'x-note': { get: { summary: 's' } }, - }, - }).paths).toEqual({ - '/a': { get: { responses: { default: { description: '' } }, summary: 's' } }, - 'x-note': { get: { 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', 201: { description: 'ok', links: 'junk' } } }, - parameters: [42], - }, - '/b': { - post: { - requestBody: { content: { 'application/json': 'junk', 'multipart/form-data': { encoding: { field: 'junk' } } } }, - responses: {}, - }, - }, - '/c': { get: 'junk' }, - '/junk': 'junk', - } - expect(convertSpec({ paths }).paths).toEqual(paths) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts deleted file mode 100644 index bd6e26b..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -// In `multipart` and `application/x-www-form-urlencoded` bodies, a part -// without an Encoding Object `contentType` gets a default that depends on -// its schema, and the two versions derive it differently. -// -// 3.1 (https://spec.openapis.org/oas/v3.1.2.html#encoding-content-type): -// no `type` → application/octet-stream -// `string` with `contentEncoding` → application/octet-stream -// `string` without it → text/plain, `object` → application/json, -// `array` → the default of its `items` -// 3.0 (https://spec.openapis.org/oas/v3.0.4.html#encoding-content-type): -// `string` with `format: binary` or `byte` → application/octet-stream -// other strings → text/plain, `object` → application/json, -// `array` → the default of its `items`, and nothing for a missing `type` -// -// The schema conversion keeps most of these aligned, but not an untyped -// part (`{}` has no 3.0 default) or a `contentEncoding` that `format: byte` -// cannot express (such as base64url, which becomes a plain string and so -// text/plain). For those parts the 3.1 default, application/octet-stream, -// is written into the Encoding Object so the wire format stays the same. -// -// An Encoding Object that sets `style`, `explode`, or `allowReserved` -// switches the part to RFC6570-style serialization, where `contentType` -// does not apply: https://spec.openapis.org/oas/v3.1.2.html#fixed-fields-for-rfc6570-style-serialization -// 3.1 does this in `application/x-www-form-urlencoded` and -// `multipart/form-data` bodies, but 3.0 only in URL-encoded ones -// (https://spec.openapis.org/oas/v3.0.4.html#encoding-style). URL-encoded -// entries that set these fields, and entries that already set -// `contentType`, are left alone. In multipart bodies 3.0 ignores the three -// fields, so they are removed and the part gets the default it would get -// without them. RFC6570-style serialization of multipart parts is lost. - -import { dig } from '../../helpers' -import { convertComponent, convertSpec } from './helpers' - -const octetStream = { contentType: 'application/octet-stream' } - -const schemas = { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} } - -function convertForm(mediaType: unknown, type = 'multipart/form-data'): unknown { - return dig(convertComponent('requestBodies', { content: { [type]: mediaType } }, { schemas }), 'content', type) -} - -describe('parts that need the 3.1 default written out', () => { - it.each([ - ['a schema without type', {}], - ['a true schema', true], - ['raw binary', { contentMediaType: 'image/png' }], - ['a base64 string', { contentEncoding: 'base64', type: 'string' }], - ['a string with a contentEncoding that no 3.0 format expresses', { contentEncoding: 'base64url', type: 'string' }], - ['a nullable string with a contentEncoding', { contentEncoding: 'base64url', type: ['string', 'null'] }], - ['an array of untyped items', { items: {}, type: 'array' }], - ['an array of raw binary', { items: { contentMediaType: 'image/png' }, type: 'array' }], - ['an array without items', { type: 'array' }], - ['untyped anyOf branches', { anyOf: [{ contentMediaType: 'image/png' }, { contentMediaType: 'image/jpeg' }] }], - ['a reference to an untyped schema', { $ref: '#/components/schemas/Raw' }], - ['an untyped schema reached twice', { anyOf: [{ $ref: '#/components/schemas/Raw' }, { $ref: '#/components/schemas/Raw' }] }], - ])('sets contentType: application/octet-stream on %s', (_name, part) => { - expect(convertForm({ schema: { properties: { part } } })).toEqual({ - encoding: { part: octetStream }, - schema: { properties: { part: expect.anything() } }, - }) - }) - - it('writes the same Encoding Object whether the body schema is inline or a reference', () => { - const result = convertSpec({ - components: { - requestBodies: { - Inline: { content: { 'multipart/form-data': { schema: { properties: { img: { contentMediaType: 'image/png' } } } } } }, - Referenced: { content: { 'multipart/form-data': { schema: { $ref: '#/components/schemas/Upload' } } } }, - }, - schemas: { Upload: { properties: { img: { contentMediaType: 'image/png' } } } }, - }, - }) - for (const name of ['Inline', 'Referenced']) { - expect(dig(result, 'components', 'requestBodies', name, 'content', 'multipart/form-data', 'encoding')).toEqual({ img: octetStream }) - } - }) - - // The parts of a form are the properties of its schema, including ones - // reached through `allOf`, `anyOf`, `oneOf`, and local `$ref`s. - it('finds parts through references and allOf in the body schema', () => { - expect(convertForm({ schema: { $ref: '#/components/schemas/Form' } })).toEqual({ - encoding: { a: octetStream, b: octetStream }, - schema: { $ref: '#/components/schemas/Form' }, - }) - }) - - it('finds a part through a $ref inside a body schema with an $id', () => { - const schema = { - $defs: { File: { contentEncoding: 'base64url', type: 'string' } }, - $id: 'https://example.com/upload', - properties: { file: { $ref: '#/$defs/File' } }, - } - expect(dig(convertForm({ schema }), 'encoding')).toEqual({ file: octetStream }) - }) - - // A part declared in several subschemas takes all its declarations: here - // the string type from one and the contentEncoding from the other. - it('combines a part declared in several subschemas', () => { - expect(convertForm({ - schema: { allOf: [{ properties: { part: { contentEncoding: 'base64url' } } }], properties: { part: { type: 'string' } } }, - })).toEqual({ - encoding: { part: octetStream }, - schema: { allOf: [{ properties: { part: {} } }], properties: { part: { type: 'string' } } }, - }) - }) - - // A property holding `undefined` is missing, as in JSON, so only the other - // branch describes the part. - it('skips a part that one allOf branch holds as undefined', () => { - const schema = { allOf: [{ properties: { file: undefined } }, { properties: { file: { contentEncoding: 'base64', type: 'string' } } }] } - expect(dig(convertForm({ schema }), 'encoding')).toStrictEqual({ file: octetStream }) - }) - - it('writes a part named like an Object.prototype member as an own key', () => { - const encoding = dig(convertForm({ schema: { properties: JSON.parse('{"__proto__":{}}') } }), 'encoding') as object - expect(Object.getPrototypeOf(encoding)).toBe(Object.prototype) - expect(Object.getOwnPropertyDescriptor(encoding, '__proto__')?.value).toEqual(octetStream) - }) -}) - -describe('parts whose 3.0 default already matches', () => { - it.each([ - ['a string', { format: 'uuid', type: 'string' }], - ['an object', { type: 'object' }], - ['a type found through allOf', { allOf: [{ $ref: '#/components/schemas/Pet' }] }], - ['a null type', { type: 'null' }], - ['several types', { type: ['string', 'integer'] }], - ['branches of different types', { anyOf: [{ type: 'string' }, { type: 'integer' }] }], - ['typed prefixItems', { prefixItems: [{ type: 'string' }], type: 'array' }], - ['nested arrays, which have no multipart form', { items: { items: {}, type: 'array' }, type: 'array' }], - ['an external reference', { $ref: 'other.yaml#/File' }], - ['a missing reference', { $ref: '#/components/schemas/Missing' }], - ['a false schema', false], - ])('adds no Encoding Object for %s', (_name, part) => { - expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') - }) - - it('adds no Encoding Object for an array whose items loop back to it', () => { - const part: Record = { type: 'array' } - part.items = part - expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') - }) -}) - -describe('existing Encoding Objects', () => { - it('keeps entries that set contentType, and adds contentType beside headers', () => { - const headers = { 'X-Id': { schema: { type: 'string' } } } - const schema = { properties: { explicit: {}, headed: {}, junk: {} } } - expect(convertForm({ - encoding: { - explicit: { contentType: 'image/png' }, - headed: { headers }, - junk: 'junk', - }, - schema, - })).toEqual({ - encoding: { - explicit: { contentType: 'image/png' }, - headed: { ...octetStream, headers }, - junk: 'junk', - }, - schema, - }) - }) - - it('keeps URL-encoded entries that set RFC6570-style fields', () => { - const encoding = { exploded: { explode: true }, reserved: { allowReserved: true }, styled: { style: 'form' } } - const schema = { properties: { exploded: {}, reserved: {}, styled: {} } } - expect(convertForm({ encoding, schema }, 'application/x-www-form-urlencoded')).toEqual({ encoding, schema }) - }) - - it('leaves an Encoding Object shared with another part unchanged', () => { - const entry = { headers: { 'X-Id': { schema: { type: 'string' } } } } - const result = dig(convertComponent('requestBodies', { - content: { - 'multipart/form-data': { encoding: { part: entry }, schema: { properties: { part: {} } } }, - 'multipart/mixed': { encoding: { part: entry }, schema: { properties: { part: { type: 'string' } } } }, - }, - }), 'content') - expect(dig(result, 'multipart/form-data', 'encoding', 'part')).toEqual({ ...entry, ...octetStream }) - expect(dig(result, 'multipart/mixed', 'encoding', 'part')).toEqual(entry) - }) - - it('writes contentType on an entry whose contentType holds undefined', () => { - const mediaType = { encoding: { part: { contentType: undefined } }, schema: { properties: { part: {} } } } - expect(dig(convertForm(mediaType), 'encoding')).toStrictEqual({ part: octetStream }) - }) - - it('leaves a malformed encoding value alone', () => { - expect(convertForm({ encoding: 'junk', schema: { properties: { file: {} } } })).toEqual({ - encoding: 'junk', - schema: { properties: { file: {} } }, - }) - }) -}) - -describe('style, explode, and allowReserved in multipart bodies', () => { - const untyped = { properties: { part: {} } } - - it.each([ - ['allowReserved', true], - ['explode', true], - ['style', 'form'], - ])('removes %s and writes the default the part gets without it', (key, value) => { - expect(convertForm({ encoding: { part: { [key]: value } }, schema: untyped })).toEqual({ - encoding: { part: octetStream }, - schema: untyped, - }) - }) - - // 3.1 sends each property of an exploded object as its own part, which - // 3.0 cannot describe. 3.0 sends the object as one application/json part. - it('removes them from a typed part, which keeps its 3.0 default', () => { - const schema = { properties: { part: { properties: { a: { type: 'string' } }, type: 'object' } } } - expect(convertForm({ encoding: { part: { explode: true, style: 'form' } }, schema })).toEqual({ - encoding: { part: {} }, - schema, - }) - }) - - it('keeps contentType, headers, and extensions beside them', () => { - const headers = { 'X-Id': { schema: { type: 'string' } } } - expect(convertForm({ - encoding: { part: { 'allowReserved': true, 'contentType': 'image/png', headers, 'x-note': 'n' } }, - schema: untyped, - })).toEqual({ - encoding: { part: { 'contentType': 'image/png', headers, 'x-note': 'n' } }, - schema: untyped, - }) - }) - - it('removes them from entries that name no part', () => { - expect(convertForm({ encoding: { ghost: { style: 'form' } }, schema: untyped })).toEqual({ - encoding: { ghost: {}, part: octetStream }, - schema: untyped, - }) - }) - - // 3.1 applies them only to multipart/form-data, so both versions already - // ignore them in other multipart types. - it('removes them in every multipart media type', () => { - for (const type of ['multipart/mixed', 'Multipart/Form-Data; boundary=x']) { - expect(convertForm({ encoding: { part: { explode: true } }, schema: untyped }, type)).toEqual({ - encoding: { part: octetStream }, - schema: untyped, - }) - } - }) - - it('converts a media type shared by a multipart and a URL-encoded body as each', () => { - const mediaType = { encoding: { part: { explode: true } }, schema: untyped } - const content = dig(convertComponent('requestBodies', { - content: { 'application/x-www-form-urlencoded': mediaType, 'multipart/form-data': mediaType }, - }), 'content') - expect(dig(content, 'multipart/form-data')).toEqual({ ...mediaType, encoding: { part: octetStream } }) - expect(dig(content, 'application/x-www-form-urlencoded')).toEqual(mediaType) - }) - - it('leaves them in responses, where both versions ignore encoding', () => { - const content = { 'multipart/form-data': { encoding: { part: { explode: true } }, schema: untyped } } - expect(convertComponent('responses', { content, description: 'd' })).toEqual({ content, description: 'd' }) - }) -}) - -describe('where it applies', () => { - // Encoding Objects only apply to request bodies of these media types - // (https://spec.openapis.org/oas/v3.1.2.html#media-type-encoding); media - // type names are case-insensitive and may carry parameters. - it('applies to multipart and URL-encoded request bodies only', () => { - const mediaType = { schema: { properties: { file: {} } } } - for (const type of ['multipart/mixed', 'Application/X-WWW-Form-Urlencoded; charset=utf-8']) { - expect(convertForm(mediaType, type)).toEqual({ ...mediaType, encoding: { file: octetStream } }) - } - for (const type of ['application/json', 'application/x-www-form-urlencoded-v2']) { - expect(convertForm(mediaType, type)).toEqual(mediaType) - } - const content = { 'multipart/form-data': mediaType } - expect(convertComponent('responses', { content, description: 'd' })).toEqual({ content, description: 'd' }) - expect(convertComponent('parameters', { content, in: 'query', name: 'q' })).toEqual({ content, in: 'query', name: 'q' }) - }) - - it('converts a media type shared between a form body and a response as each', () => { - const mediaType = { schema: { properties: { file: {} } } } - const result = convertSpec({ - components: { - requestBodies: { B: { content: { 'multipart/form-data': mediaType } } }, - responses: { R: { content: { 'multipart/form-data': mediaType }, description: 'd' } }, - }, - }) - expect(dig(result, 'components', 'requestBodies', 'B', 'content', 'multipart/form-data')).toEqual({ ...mediaType, encoding: { file: octetStream } }) - expect(dig(result, 'components', 'responses', 'R', 'content', 'multipart/form-data')).toEqual(mediaType) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts deleted file mode 100644 index 49af9a7..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/helpers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type * as OpenAPIV3_0 from '@openapi-spec/types/v3.0' - -import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' - -import { dig } from '../../helpers' - -export const info = { title: 't', version: '1' } - -/** Matches a pointer into `webhooks` or `components.pathItems`, which 3.0 removes. */ -export const removedPointer = /#\/(?:webhooks|components\/pathItems)/ - -/** The request body schema of the `newPet` webhook. */ -export const webhookSchemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' - -/** A Path Item to put in `components.pathItems`. */ -export const item = { - get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, - parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], -} - -/** - * Converts a 3.1 document built from `fields`. The input is typed loosely on - * purpose: many tests feed partial or malformed documents to check that the - * conversion tolerates them. - */ -export function convertSpec(fields: Record): OpenAPIV3_0.OpenAPIObject { - return downgradeSpecV31ToV30({ info, openapi: '3.1.0', paths: {}, ...fields } as any) -} - -/** Converts `pathItem` as the only entry of `paths` and returns it. */ -export function convertPathItem(pathItem: unknown): unknown { - return dig(convertSpec({ paths: { '/a': pathItem } }), 'paths', '/a') -} - -/** Converts `value` as the entry `X` of the `kind` component map and returns it. */ -export function convertComponent(kind: string, value: unknown, components: Record = {}): unknown { - return dig(convertSpec({ components: { ...components, [kind]: { X: value } } }), 'components', kind, 'X') -} diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts deleted file mode 100644 index 6cbe11f..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/input-graph.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -// A document is usually parsed JSON or YAML, but it can also come from a -// dereferencing tool that turns every `$ref` into a shared JavaScript object, -// possibly with cycles. These tests pin down how the conversion treats the -// input as an object graph rather than as text. - -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' - -import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' - -import { corpusV31 } from '../../corpus' -import { dig, withUndefinedKeys } from '../../helpers' -import { convertPathItem, convertSpec, info } from './helpers' - -describe('the input document', () => { - it('is never mutated', () => { - const input: OpenAPIV3_1.OpenAPIObject = { - 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'] }], - }, - }, - '/b': { $ref: '#/components/pathItems/Reusable' }, - }, - security: [{ mtls: [] }], - webhooks: { newPet: { post: { summary: 's' } } }, - } - const before = structuredClone(input) - downgradeSpecV31ToV30(input) - expect(input).toEqual(before) - }) - - it('returns a fresh copy on every call', () => { - const spec: OpenAPIV3_1.OpenAPIObject = { info, openapi: '3.1.0', paths: {} } - const first = downgradeSpecV31ToV30(spec) - expect(first).toEqual(downgradeSpecV31ToV30(spec)) - expect(first).not.toBe(downgradeSpecV31ToV30(spec)) - expect(first.info).not.toBe(spec.info) - }) - - // Some parsers build objects without a prototype so that keys such as - // `__proto__` or `constructor` cannot collide with Object.prototype. - it('accepts objects with a null prototype, as some parsers produce', () => { - const schema = Object.assign(Object.create(null), { type: ['string', 'null'] }) - const operation = Object.assign(Object.create(null), { parameters: [{ in: 'path', name: 'id', schema }] }) - expect(convertPathItem({ get: operation })).toEqual({ - get: { - parameters: [{ in: 'path', name: 'id', required: true, schema: { nullable: true, type: 'string' } }], - responses: { default: { description: '' } }, - }, - }) - }) - - // Values such as a Date or a Map cannot come from JSON or YAML. They are - // not walked into, and are kept as the same instance. - it('keeps values that are not plain objects or arrays by reference', () => { - const date = new Date(0) - const result = convertSpec({ 'components': { schemas: { S: { default: date } } }, 'x-date': date }) - expect(dig(result, 'x-date')).toBe(date) - expect(dig(result, 'components', 'schemas', 'S', 'default')).toBe(date) - }) -}) - -describe('keys', () => { - it('keeps the key order of the input', () => { - const result = downgradeSpecV31ToV30({ 'paths': {}, 'x-first': 1, 'info': { version: '1', title: 't' }, 'openapi': '3.1.0' } as any) - expect(Object.keys(result)).toEqual(['paths', 'x-first', 'info', 'openapi']) - expect(Object.keys(result.info)).toEqual(['version', 'title']) - }) - - // JSON.parse creates a real own `__proto__` key. Assigning it with `=` - // would instead replace the prototype of the output object. - it('copies a __proto__ key as a plain own property without polluting prototypes', () => { - const spec = JSON.parse('{"openapi":"3.1.0","paths":{},"x-data":{"__proto__":{"polluted":true}},"components":{"schemas":{"__proto__":{"type":["string","null"]}}}}') - const result = downgradeSpecV31ToV30(spec) - const data = dig(result, 'x-data') as object - const schemas = dig(result, 'components', 'schemas') as object - expect(Object.getPrototypeOf(data)).toBe(Object.prototype) - expect(Object.getOwnPropertyDescriptor(data, '__proto__')?.value).toEqual({ polluted: true }) - expect(Object.getOwnPropertyDescriptor(schemas, '__proto__')?.value).toEqual({ nullable: true, type: 'string' }) - expect('polluted' in {}).toBe(false) - }) -}) - -// Builders that spread options often leave a key holding `undefined`. JSON -// drops such a key, so the conversion treats it as missing, and the output -// never holds one. -describe('keys holding undefined', () => { - it.each(corpusV31)('converts %s as if the undefined keys were missing', (_name, doc) => { - const sprinkled = withUndefinedKeys(doc) as OpenAPIV3_1.OpenAPIObject - expect(downgradeSpecV31ToV30(sprinkled)).toStrictEqual(downgradeSpecV31ToV30(doc)) - }) - - // `{ mtls: undefined }` is the empty requirement `{}`, which needs no - // security (see security.test.ts), rather than a mutualTLS requirement. - it('keeps a requirement whose scheme names hold undefined as the empty requirement', () => { - const result = convertSpec({ components: { securitySchemes: { mtls: { type: 'mutualTLS' } } }, security: [{ mtls: undefined }] }) - expect(result.security).toStrictEqual([{}]) - }) -}) - -describe('shared objects and cycles', () => { - it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { - const callback: Record = {} - const pathItem: Record = { get: { callbacks: { cb: callback } } } - callback.expr = pathItem - const result = convertPathItem(pathItem) - expect(dig(result, 'get', 'responses')).toEqual({ default: { description: '' } }) - expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) - }) - - it('converts a dereferenced schema shared across the document once', () => { - const pet = { properties: { name: { type: ['string', 'null'] } }, type: 'object' } - const result = convertSpec({ - components: { schemas: { Pet: pet } }, - paths: { '/pets': { get: { responses: { 200: { content: { 'application/json': { schema: pet } }, description: 'ok' } } } } }, - }) - const schema = dig(result, 'components', 'schemas', 'Pet') - expect(schema).toEqual({ properties: { name: { nullable: true, type: 'string' } }, type: 'object' }) - expect(dig(result, 'paths', '/pets', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toBe(schema) - }) - - it('keeps cycles and sharing inside values it only copies', () => { - const node: Record = { name: 'root' } - node.self = node - const list: unknown[] = [1] - list.push(list) - const result = convertSpec({ 'x-list': list, 'x-node': node, 'x-same': node }) - expect(dig(result, 'x-node', 'self')).toBe(dig(result, 'x-node')) - expect(dig(result, 'x-same')).toBe(dig(result, 'x-node')) - expect(dig(result, 'x-list', '1')).toBe(dig(result, 'x-list')) - }) - - // `#/webhooks/%68ook` percent-decodes to `#/webhooks/hook`, so both name - // the same target and share one converted copy. - it('converts a target inlined from several places once, however its pointer is spelled', () => { - const result = convertSpec({ - paths: { '/a': { $ref: '#/webhooks/hook' }, '/b': { $ref: '#/webhooks/%68ook' } }, - webhooks: { hook: { get: { parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }] } } }, - }) - expect(dig(result, 'paths', '/a', 'get')).toEqual({ - parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], - responses: { default: { description: '' } }, - }) - expect(dig(result, 'paths', '/b', 'get')).toBe(dig(result, 'paths', '/a', 'get')) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts deleted file mode 100644 index 4a9d84d..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/links-and-mappings.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -// A Link's `operationRef` and a discriminator `mapping` value are references -// too: https://spec.openapis.org/oas/v3.0.4.html#link-operation-ref -// https://spec.openapis.org/oas/v3.0.4.html#discriminator-mapping -// One that points into `webhooks` or `components.pathItems` cannot be kept, -// since its target is removed, and cannot be inlined either, since both -// fields must hold a pointer. So it is removed, along with any Reference -// Object that resolves to such a Link. - -import { dig } from '../../helpers' -import { convertSpec, item, removedPointer } from './helpers' - -describe('links', () => { - it('removes links whose operationRef points into the removed parts, together with references to them', () => { - const result = convertSpec({ - components: { - callbacks: { Hook: { '{$url}': { $ref: '#/webhooks/callbackHook' } } }, - links: { - ByComponentCallback: { operationRef: '#/webhooks/callbackHook/post' }, - Gone: { operationRef: '#/webhooks/orphan/post' }, - Kept: { description: 'kept', operationRef: '#/webhooks/newPet/post' }, - }, - pathItems: { Item: item, NoId: { get: { responses: {} } } }, - }, - paths: { - '/a': { - get: { - callbacks: { cb: { '{$request.body#/url}': { $ref: '#/components/pathItems/Item' } } }, - responses: { - 200: { - description: 'ok', - links: { - both: { operationId: 'stale', operationRef: '#/webhooks/newPet/post' }, - byCallback: { operationRef: '#/components/pathItems/Item/get', parameters: { id: '$response.body#/id' } }, - byId: { operationId: 'orphanHook' }, - byPath: { operationRef: '#/paths/~1b/post' }, - external: { $ref: 'https://example.com/links.json#/Kept' }, - inlined: { $ref: '#/webhooks/newPet/post/responses/200/links/self' }, - missing: { operationRef: '#/webhooks/missing/post' }, - noId: { operationRef: '#/components/pathItems/NoId/get' }, - refGone: { $ref: '#/components/links/Gone' }, - refKept: { $ref: '#/components/links/Kept' }, - refUnknown: { $ref: '#/components/links/Unknown' }, - }, - }, - }, - }, - }, - '/b': { $ref: '#/webhooks/newPet' }, - '/c': { $ref: '#/components/pathItems/NoId' }, - '/d': { $ref: '#/webhooks/newPet' }, - '/junk': 'junk', - 'x-orphan': { post: { operationId: 'orphanHook' } }, - }, - webhooks: { - callbackHook: { post: { operationId: 'callbackHookOp', responses: {} } }, - newPet: { - post: { - operationId: 'newPetHook', - responses: { 200: { description: 'ok', links: { self: { operationRef: '#/webhooks/newPet/post' } } } }, - }, - }, - orphan: { post: { operationId: 'orphanHook', responses: {} } }, - }, - }) - expect(result.components).toEqual({ - callbacks: { Hook: { '{$url}': { post: { operationId: 'callbackHookOp', responses: {} } } } }, - links: {}, - }) - // `byId` names its operation by `operationId`, which is not a pointer, - // and is kept even though that operation is gone (a known limitation). - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'links')).toEqual({ - byId: { operationId: 'orphanHook' }, - byPath: { operationRef: '#/paths/~1b/post' }, - external: { $ref: 'https://example.com/links.json#/Kept' }, - refUnknown: { $ref: '#/components/links/Unknown' }, - }) - expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({}) - expect(JSON.stringify(result)).not.toMatch(removedPointer) - }) - - // `/a` inlines the webhook but defines its own `post`, which wins, so the - // webhook's `post` does not survive anywhere in the output. - it('removes a link to an operation that an own field of the referencing path item replaces', () => { - expect(convertSpec({ - components: { links: { L: { operationRef: '#/webhooks/w/post' } } }, - paths: { '/a': { $ref: '#/webhooks/w', post: { responses: {} } } }, - webhooks: { w: { post: { operationId: 'hidden', responses: {} } } }, - }).components).toEqual({ links: {} }) - }) - - it('removes a link to a removed operation in a document without components', () => { - expect(convertSpec({ - paths: { - '/a': { - get: { - callbacks: { junk: 42 }, - responses: { 200: { description: 'ok', links: { l: { operationRef: '#/webhooks/w/post' } } } }, - }, - }, - }, - webhooks: { w: { post: { operationId: 'hook', responses: {} } } }, - }).paths).toEqual({ - '/a': { get: { callbacks: { junk: 42 }, responses: { 200: { description: 'ok', links: {} } } } }, - }) - }) -}) - -describe('discriminator mappings', () => { - // A mapping value is either a schema name or a reference. Names and - // references that stay valid are kept. - it('removes mapping entries that point into the removed parts', () => { - expect(convertSpec({ - components: { - schemas: { - Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, - Pet: { - discriminator: { - mapping: { - cat: '#/components/schemas/Cat', - dog: '#/webhooks/newPet/post/requestBody/content/application~1json/schema', - fish: 'Fish', - hamster: '#/components/pathItems/Item', - }, - propertyName: 'kind', - }, - }, - }, - }, - }).components).toEqual({ - schemas: { - Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, - Pet: { discriminator: { mapping: { cat: '#/components/schemas/Cat', fish: 'Fish' }, propertyName: 'kind' } }, - }, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts deleted file mode 100644 index 86ec813..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/operations.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { convertComponent, convertPathItem } from './helpers' - -describe('responses', () => { - // 3.1 made `responses` optional: https://spec.openapis.org/oas/v3.1.2.html#operation-responses - // In 3.0 it is REQUIRED: https://spec.openapis.org/oas/v3.0.4.html#operation-responses - // A `default` response with an empty description says nothing about the - // responses, just as the missing field did. - it('adds a minimal default response when an operation has none', () => { - expect(convertPathItem({ get: { operationId: 'getA' } })).toEqual({ - get: { operationId: 'getA', responses: { default: { description: '' } } }, - }) - }) - - it('keeps x- entries of a responses map unconverted', () => { - const responses = { '200': { description: 'ok' }, 'x-note': { $ref: '#/c/r', summary: 's' } } - expect(convertPathItem({ get: { responses } })).toEqual({ get: { responses } }) - }) -}) - -describe('parameters', () => { - it('converts parameter schemas, content, and examples', () => { - expect(convertPathItem({ - get: { - parameters: [ - { examples: { e: { $ref: '#/c/e', summary: 's' } }, in: 'query', name: 'p', schema: { type: ['string', 'null'] } }, - { content: { 'text/plain': { schema: { type: ['integer', 'null'] } } }, in: 'query', name: 'q' }, - ], - responses: {}, - }, - })).toEqual({ - get: { - parameters: [ - { examples: { e: { $ref: '#/c/e' } }, in: 'query', name: 'p', schema: { nullable: true, type: 'string' } }, - { content: { 'text/plain': { schema: { nullable: true, type: 'integer' } } }, in: 'query', name: 'q' }, - ], - responses: {}, - }, - }) - }) - - // Both versions say a path parameter's `required` "is REQUIRED and its - // value MUST be true": https://spec.openapis.org/oas/v3.1.2.html#parameter-required - // The official 3.1 JSON Schema only checks it beside `schema`, so a valid - // 3.1 document can lack it on a `content` parameter. The official 3.0 - // schema always checks it, so it is added. - 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: {}, - }, - }) - }) -}) - -describe('request bodies', () => { - it('converts request body content, media type encoding, and encoding headers', () => { - expect(convertComponent('requestBodies', { - content: { - 'multipart/form-data': { - encoding: { - field: { - contentType: 'text/plain', - headers: { H: { $ref: '#/c/h', summary: 's' }, H2: { schema: { type: ['string', 'null'] } } }, - }, - }, - example: { field: 'v' }, - schema: { type: 'object' }, - }, - }, - description: 'body', - required: true, - })).toEqual({ - content: { - 'multipart/form-data': { - encoding: { - field: { - contentType: 'text/plain', - headers: { H: { $ref: '#/c/h' }, H2: { schema: { nullable: true, type: 'string' } } }, - }, - }, - example: { field: 'v' }, - schema: { type: 'object' }, - }, - }, - description: 'body', - required: true, - }) - }) -}) - -describe('callbacks', () => { - // Callback Object keys are runtime expressions; only `x-` keys are - // extensions: https://spec.openapis.org/oas/v3.0.4.html#callback-object - it('converts inline callbacks, cloning x- keys and malformed entries', () => { - expect(convertPathItem({ - get: { - callbacks: { inline: { 'expr': { get: {} }, 'x-k': { expr: { get: {} } } }, junk: 7 }, - responses: {}, - }, - })).toEqual({ - get: { - callbacks: { - inline: { 'expr': { get: { responses: { default: { description: '' } } } }, 'x-k': { expr: { get: {} } } }, - junk: 7, - }, - responses: {}, - }, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts deleted file mode 100644 index bf6a473..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/path-items.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -// `components.pathItems` is new in 3.1: https://spec.openapis.org/oas/v3.1.2.html#components-path-items -// 3.0 components cannot hold Path Items, so the map is removed and every -// Path Item `$ref` into it is replaced by the converted Path Item. -// -// The spec leaves a field defined both beside the `$ref` and in its target -// undefined, but says `$ref` will move toward Reference Object behavior, where -// the referencing side's fields override the target's: -// https://spec.openapis.org/oas/v3.1.2.html#path-item-ref -// So when a Path Item `$ref` is inlined, its own fields win. - -import { countReads, cyclicCallbackGraph, dig } from '../../helpers' -import { expectValidAs } from '../../validate' -import { convertPathItem, convertSpec } from './helpers' - -const reusable = { - get: { responses: { 200: { description: 'ok' } } }, - parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], - summary: 'Reusable', -} -const inlined = { - get: { responses: { 200: { description: 'ok' } } }, - parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], - summary: 'Reusable', -} -const responses = { 200: { description: 'ok' } } - -describe('inlining', () => { - it('inlines the converted entry and lets the referencing fields win', () => { - const result = convertSpec({ - components: { pathItems: { Reusable: reusable } }, - paths: { - '/a': { $ref: '#/components/pathItems/Reusable' }, - '/b': { $ref: '#/components/pathItems/Reusable', description: 'own', summary: 'Own summary' }, - }, - }) - expect(result.components).toEqual({}) - expect(result.paths).toEqual({ - '/a': inlined, - '/b': { ...inlined, description: 'own', summary: 'Own summary' }, - }) - }) - - // Each hop of a chain adds the fields the hops before it did not set. - it('follows chains of path item references, merging the fields of every hop', () => { - expect(convertSpec({ - components: { - pathItems: { - Alias: { $ref: '#/components/pathItems/Reusable', description: 'alias' }, - Reusable: reusable, - }, - }, - paths: { '/a': { $ref: '#/components/pathItems/Alias', summary: 'Own' } }, - }).paths).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) - }) - - // The target is an external reference, which stays valid, so the result is - // that reference with the referencing Path Item's fields beside it. - it('inlines an entry that references an external file', () => { - expect(convertSpec({ - components: { pathItems: { External: { $ref: './paths/a.yaml' } } }, - paths: { '/a': { $ref: '#/components/pathItems/External', summary: 'Own' } }, - }).paths).toEqual({ '/a': { $ref: './paths/a.yaml', summary: 'Own' } }) - }) - - it('inlines references inside callbacks', () => { - expect(convertSpec({ - components: { pathItems: { Reusable: reusable } }, - paths: { - '/a': { - post: { - callbacks: { onEvent: { '{$request.body#/url}': { $ref: '#/components/pathItems/Reusable' } } }, - responses: {}, - }, - }, - }, - }).paths).toEqual({ - '/a': { post: { callbacks: { onEvent: { '{$request.body#/url}': inlined } }, responses: {} } }, - }) - }) - - it('converts the inlined path item like any other, removing mutualTLS requirements', () => { - expect(convertSpec({ - components: { - pathItems: { Secured: { get: { responses: {}, security: [{ mtls: [] }, { api: ['r'] }] } } }, - securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } }, - }, - paths: { '/a': { $ref: '#/components/pathItems/Secured' } }, - }).paths).toEqual({ '/a': { get: { responses: {}, security: [{ api: [] }] } } }) - }) -}) - -describe('references left as written', () => { - // Only a pointer to an existing Path Item is inlined. A pointer that - // resolves to nothing, or to something that is not an object, dangles - // already, and there is nothing to inline. - it.each([ - ['an unknown entry', '#/components/pathItems/Missing', { Reusable: reusable }], - ['an empty name', '#/components/pathItems/', { Reusable: reusable }], - ['a malformed entry', '#/components/pathItems/Junk', { Junk: 42 }], - ['a prototype member', '#/components/pathItems/hasOwnProperty', {}], - ['a malformed pathItems map', '#/components/pathItems/Reusable', 'junk'], - ])('leaves a reference to %s untouched', (_name, ref, pathItems) => { - expect(convertSpec({ components: { pathItems }, paths: { '/a': { $ref: ref, summary: 's' } } }).paths).toEqual({ - '/a': { $ref: ref, summary: 's' }, - }) - }) - - // `#/components/pathItems/Reusable/get` resolves to an Operation. A Path - // Item `$ref` must point at a Path Item, so this one is not merged; it is - // left as written, like a reference to any other invalid target. - it('leaves a reference to something that is not a path item untouched', () => { - expect(convertSpec({ - components: { pathItems: { Reusable: reusable } }, - paths: { '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }, - }).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Reusable/get', summary: 's' } }) - }) - - it('leaves a reference untouched when components.pathItems is missing', () => { - expect(convertPathItem({ $ref: '#/components/pathItems/Reusable' })).toEqual({ $ref: '#/components/pathItems/Reusable' }) - }) - - it('leaves a chain that loops without reaching a path item as written', () => { - expect(convertSpec({ - components: { - pathItems: { - Ping: { $ref: '#/components/pathItems/Pong', description: 'ping' }, - Pong: { $ref: '#/components/pathItems/Ping' }, - }, - }, - paths: { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, - }).paths).toEqual({ '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }) - }) -}) - -describe('recursion', () => { - // A Path Item that reaches itself through its callbacks would inline - // forever. The inner reference keeps only its own fields instead, since a - // Path Item has no "accept anything" form like the `{}` schema. - it('cuts a path item that reaches itself through its callbacks down to its own fields', () => { - expect(convertSpec({ - components: { - pathItems: { - Self: { - post: { - callbacks: { - loop: { - bare: { $ref: '#/components/pathItems/Self' }, - own: { $ref: '#/components/pathItems/Self', summary: 'own' }, - }, - }, - responses: {}, - }, - }, - }, - }, - paths: { '/a': { $ref: '#/components/pathItems/Self' } }, - }).paths).toEqual({ - '/a': { post: { callbacks: { loop: { bare: {}, own: { summary: 'own' } } }, responses: {} } }, - }) - }) - - it('keeps the fields of every hop when it cuts a recursive chain', () => { - const result = convertSpec({ - components: { - pathItems: { - A: { post: { callbacks: { cb: { expr: { $ref: '#/components/pathItems/Alias', summary: 'outer' } } }, responses: {} } }, - Alias: { $ref: '#/components/pathItems/A', description: 'alias' }, - }, - }, - paths: { '/a': { $ref: '#/components/pathItems/A' } }, - }) - expect(result.paths['/a']).toEqual({ - post: { callbacks: { cb: { expr: { description: 'alias', summary: 'outer' } } }, responses: {} }, - }) - }) - - // `A` is both a hop of the chain and the Path Item being inlined. Where the - // inner reference re-enters it, `A` contributes nothing, neither its - // operations nor its plain fields, and only the later hop `T` is merged. - it('cuts a hop that the chain re-enters, merging only the hops after it', () => { - const result = convertSpec({ - components: { - pathItems: { - A: { - $ref: '#/components/pathItems/T', - description: 'a', - post: { callbacks: { c: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses }, - }, - T: { summary: 't' }, - }, - }, - paths: { '/p': { $ref: '#/components/pathItems/A' } }, - }) - expect(result.paths).toEqual({ - '/p': { description: 'a', post: { callbacks: { c: { '{$url}': { summary: 't' } } }, responses }, summary: 't' }, - }) - }) -}) - -// A hop is a Path Item with its own fields and a `$ref` to the next one. Like -// any inlined target, each hop is converted once and shared wherever that copy -// comes out the same, so the work grows with the number of references, not -// with the number of paths through them. Converting again where it would not -// is limited to a few times the rest of the work. -// Each test counts reads of the field the conversion walks into: a hop's -// operation each time its own fields are converted, or its `$ref` at each step -// along a chain. -describe('hops converted once', () => { - const pointer = (name: string): string => `#/components/pathItems/${name}` - const url = '{$request.body#/url}' - const callbacks = (result: unknown): unknown => dig(result, 'paths', '/a', 'post', 'callbacks') - - it('shares a hop that many paths point at', () => { - const reads = { count: 0 } - const length = 50 - const result = convertSpec({ - components: { - pathItems: { - Base: { summary: 'base' }, - Hop: countReads({ $ref: pointer('Base'), get: { parameters: reusable.parameters, responses } }, 'get', reads), - }, - }, - paths: Object.fromEntries(Array.from({ length }, (_, index) => [`/p${index}`, { $ref: pointer('Hop') }])), - }) - expect(reads.count).toBe(1) - const expected = { get: { parameters: inlined.parameters, responses }, summary: 'base' } - expect(Object.values(result.paths)).toEqual(Array.from({ length }).fill(expected)) - for (const item of Object.values(result.paths)) { - expect(item.get).toBe(result.paths['/p0']?.get) - } - }) - - it('walks a long alias chain once, however many paths enter it', () => { - const reads = { count: 0 } - const length = 200 - const pathItems: Record = { [`P${length}`]: { get: { responses } } } - const paths: Record = {} - for (let index = 0; index < length; index++) { - pathItems[`P${index}`] = countReads({ $ref: pointer(`P${index + 1}`) }, '$ref', reads) - paths[`/p${index}`] = { $ref: pointer(`P${index}`) } - } - const result = convertSpec({ components: { pathItems }, paths }) - expect(reads.count).toBeLessThan(50 * length) - expect(Object.values(result.paths)).toEqual(Array.from({ length }, () => ({ get: { responses } }))) - }) - - // Inlining each path through this graph separately would convert the - // webhooks about k! times, and cutting each copy only at the webhooks that - // enclose it would still take about 2^k copies. Past the budget, copies are - // shared even where they would come out differently. - it('converts a dense cyclic callback graph in linear work', async () => { - const reads = { count: 0 } - const k = 8 - const webhooks = cyclicCallbackGraph(k, name => `#/webhooks/${name}`, reads) - const result = convertSpec({ paths: { '/a': { $ref: '#/webhooks/h0' } }, webhooks }) - expect(reads.count).toBeLessThan(3 * k) - await expectValidAs(result, '3.0') - // `h0` is in progress inside its own callbacks, so the reference back to - // it keeps only the fields of `base`. - expect(dig(callbacks(result), 'c0', url)).toEqual({ get: { responses } }) - }) - - // Within the budget, each copy is cut only at the webhooks that enclose it. - // Inside `h0`, the copy of `h2` keeps the operation of `h1`, which does not - // enclose it there, although `h2` is converted inside `h1` first. - it('cuts a small cyclic callback graph only at the webhooks that enclose each copy', () => { - const webhooks = cyclicCallbackGraph(3, name => `#/webhooks/${name}`, { count: 0 }) - const result = convertSpec({ paths: { '/a': { $ref: '#/webhooks/h0' } }, webhooks }) - const cut = { get: { responses } } - const innermost = { get: { responses }, post: { callbacks: { c0: { [url]: cut }, c1: { [url]: cut }, c2: { [url]: cut } }, responses } } - expect(dig(callbacks(result), 'c1', url, 'post', 'callbacks', 'c2', url)).toEqual(innermost) - expect(dig(callbacks(result), 'c2', url, 'post', 'callbacks', 'c1', url)).toEqual(innermost) - }) - - // The callbacks of `B` enter the chain A0 → … → A99 → B → T, which passes - // `B` while it is in progress, so every copy inside `B` skips it. That merge - // is shared among those copies but not with `/q`, which enters the chain from - // outside and keeps the fields of `B`. - it('keeps the fields of a hop for a reference from outside its cycle, in linear work', () => { - const reads = { count: 0 } - const length = 100 - const pathItems: Record = { T: { summary: 't' } } - for (let index = 0; index < length; index++) { - pathItems[`A${index}`] = countReads({ $ref: pointer(index + 1 < length ? `A${index + 1}` : 'B') }, '$ref', reads) - } - const callbacks = Object.fromEntries(Array.from({ length }, (_, index) => [`c${index}`, { '{$url}': { $ref: pointer('A0') } }])) - pathItems.B = { $ref: pointer('T'), get: { callbacks, responses } } - const result = convertSpec({ components: { pathItems }, paths: { '/p': { $ref: pointer('B') }, '/q': { $ref: pointer('A0') } } }) - expect(reads.count).toBeLessThan(50 * length) - const inner = Object.fromEntries(Array.from({ length }, (_, index) => [`c${index}`, { '{$url}': { summary: 't' } }])) - expect(result.paths['/p']).toEqual({ get: { callbacks: inner, responses }, summary: 't' }) - expect(result.paths['/q']).toEqual(result.paths['/p']) - }) - - // `/t` is converted first, and the callbacks of `T` enter `N` while `T` is in - // progress, so that copy of the inner reference back to `N` cuts `T` too. - // `/n` enters `N` from outside, where only `N` is in progress, so its inner - // reference skips `N` and still merges `T`, operation included. - it('keeps the hops after a re-entered hop, whichever path is converted first', () => { - const result = convertSpec({ - components: { - pathItems: { - N: { $ref: pointer('T'), post: { callbacks: { self: { '{$url}': { $ref: pointer('N'), summary: 'inner' } } }, responses } }, - T: { get: { callbacks: { back: { '{$url}': { $ref: pointer('N') } } }, responses } }, - }, - }, - paths: { '/t': { $ref: pointer('T') }, '/n': { $ref: pointer('N') } }, - }) - expect(dig(result, 'paths', '/n', 'post', 'callbacks', 'self', '{$url}')).toMatchObject({ get: { responses }, summary: 'inner' }) - }) - - // Converting `/a` merges `B` as a later hop of `A`, so the own fields of `B` - // are converted while `A` is in progress, and their reference back to `A` - // cuts it. `/b` enters `B` where `A` is not in progress, so it does not - // reuse that merge, and its inner reference keeps the operation of `A`. - it.each([['/a', '/b'], ['/b', '/a']])('keeps a hop cut inside another hop\'s fields where it is not in progress, converting %s first', (...order) => { - const refs: Record = { '/a': { $ref: pointer('A') }, '/b': { $ref: pointer('B') } } - const result = convertSpec({ - components: { - pathItems: { - A: { $ref: pointer('B'), post: { operationId: 'aPost', responses } }, - B: { $ref: pointer('T'), get: { callbacks: { cb: { '{$url}': { $ref: pointer('A') } } }, responses } }, - T: { summary: 't' }, - }, - }, - paths: Object.fromEntries(order.map(path => [path, refs[path]])), - }) - expect(dig(result, 'paths', '/b', 'get', 'callbacks', 'cb', '{$url}')).toMatchObject({ post: { operationId: 'aPost', responses }, summary: 't' }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts deleted file mode 100644 index 9d5a8d7..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/recursion.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Inlining a target that refers back to itself would never end, and the -// result must stay a plain acyclic JSON value. The recursion is cut at its -// first repeat: -// - in a schema, the inner reference becomes `{}`, which accepts anything, -// so validation can only get looser -// - on a Path Item, the inner reference keeps only its own fields -// - anywhere else, the inner Reference Object is removed - -import { countReads, dig, expectAcyclic } from '../../helpers' -import { expectValidAs } from '../../validate' -import { convertSpec, webhookSchemaPointer } from './helpers' - -const responses = { 200: { description: 'ok' } } - -it('cuts recursion into {} for schemas and into own fields for path items, keeping the output acyclic', () => { - const tree = '#/webhooks/tree/post/requestBody/content/application~1json/schema' - const result = convertSpec({ - components: { schemas: { Tree: { $ref: tree } } }, - paths: { '/ping': { $ref: '#/webhooks/ping' }, '/tree': { $ref: '#/webhooks/tree' } }, - webhooks: { - ping: { - post: { - callbacks: { - pong: { $ref: '#/webhooks/ping/post/callbacks/self' }, - self: { '{$request.body#/url}': { $ref: '#/webhooks/ping' } }, - }, - responses: {}, - }, - }, - tree: { - post: { - requestBody: { - content: { - 'application/json': { - schema: { properties: { children: { items: { $ref: tree }, type: 'array' } }, type: 'object' }, - }, - }, - }, - responses: {}, - }, - }, - }, - }) - expect(result.components).toEqual({ schemas: { Tree: { properties: { children: { items: {}, type: 'array' } }, type: 'object' } } }) - expect(dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema')).toEqual(dig(result, 'components', 'schemas', 'Tree')) - expect(dig(result, 'paths', '/ping', 'post', 'callbacks')).toEqual({ - pong: { '{$request.body#/url}': {} }, - self: { '{$request.body#/url}': {} }, - }) - expectAcyclic(result) -}) - -it('cuts callbacks that reach back into an enclosing callback', () => { - const result = convertSpec({ - components: { - pathItems: { - Item: { - post: { - callbacks: { - A: { '{$url}': { post: { callbacks: { toB: { $ref: '#/components/pathItems/Item/post/callbacks/B' } }, responses } } }, - B: { '{$url}': { post: { callbacks: { toA: { $ref: '#/components/pathItems/Item/post/callbacks/A' } }, responses } } }, - }, - responses, - }, - }, - }, - }, - paths: { '/item': { $ref: '#/components/pathItems/Item' }, '/self': { $ref: '#/webhooks/w' } }, - webhooks: { - w: { - post: { - callbacks: { cb: { '{$url}': { post: { callbacks: { again: { $ref: '#/webhooks/w/post/callbacks/cb' } }, responses } } } }, - responses, - }, - }, - }, - }) - expect(dig(result, 'paths', '/self', 'post', 'callbacks', 'cb', '{$url}', 'post', 'callbacks')).toEqual({ again: {} }) - expect(dig(result, 'paths', '/item', 'post', 'callbacks', 'A', '{$url}', 'post', 'callbacks', 'toB', '{$url}', 'post', 'callbacks')).toEqual({ toA: {} }) - expectAcyclic(result) -}) - -it('cuts a callback that reaches back into the path item that contains it', () => { - expect(dig(convertSpec({ - components: { callbacks: { C: { $ref: '#/webhooks/ping/post/callbacks/self' } } }, - webhooks: { ping: { post: { callbacks: { self: { expr: { $ref: '#/webhooks/ping' } } }, responses: {} } } }, - }), 'components', 'callbacks', 'C')).toEqual({ - expr: { post: { callbacks: { self: {} }, responses: {} } }, - }) -}) - -it('cuts own fields that lead back into a path item still being converted', () => { - const loop = { - $ref: '#/components/pathItems/T', - get: { callbacks: { d: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses }, - } - const result = convertSpec({ - components: { - callbacks: { C: { '{$url}': { $ref: '#/components/pathItems/A/post/callbacks/c/{$url}' } } }, - pathItems: { A: { post: { callbacks: { c: { '{$url}': loop } }, responses } }, T: { summary: 't' } }, - }, - }) - expect(dig(result, 'components', 'callbacks', 'C', '{$url}', 'get', 'callbacks', 'd', '{$url}', 'post', 'callbacks')).toEqual({ - c: { '{$url}': { summary: 't' } }, - }) - expectAcyclic(result) -}) - -// `components.callbacks.C` references the callback of webhook `h0`. The -// callback of each webhook `h` references the callbacks of all of them, -// itself included, so inlining them is a dense cycle. `reads` counts how many -// times their Path Items are converted. -function callbackCycle(k: number, reads: { count: number }): Record { - const pointer = (i: number): string => `#/webhooks/h${i}/post/callbacks/cb` - const callbacks = Object.fromEntries(Array.from({ length: k }, (_, j) => [`c${j}`, { $ref: pointer(j) }])) - const webhooks = Object.fromEntries(Array.from({ length: k }, (_, i) => [ - `h${i}`, - { post: { callbacks: { cb: { '{$url}': countReads({ post: { callbacks, responses } }, 'post', reads) } }, responses } }, - ])) - return { components: { callbacks: { C: { $ref: pointer(0) } } }, webhooks } -} - -describe('cycles of inlined callbacks', () => { - const inner = (callbacks: Record): unknown => ({ '{$url}': { post: { callbacks, responses } } }) - - // Each copy is cut only at the callbacks that enclose it. Inside `C`, the - // copy of `h2`'s callback keeps its reference to `h1`'s, which does not - // enclose it there, although `h2`'s is first copied inside `h1`'s. - it('cuts a small cycle only at the callbacks that enclose each copy', () => { - const result = convertSpec(callbackCycle(3, { count: 0 })) - expect(dig(result, 'components', 'callbacks', 'C')).toEqual(inner({ - c1: inner({ c2: inner({}) }), - c2: inner({ c1: inner({}) }), - })) - }) - - // Cutting each copy only at the callbacks that enclose it would take about - // 2^k copies, so past a budget, copies are shared as soon as they are made. - it('inlines a dense cycle in linear work', async () => { - const reads = { count: 0 } - const k = 8 - const result = convertSpec(callbackCycle(k, reads)) - expect(reads.count).toBeLessThan(3 * k) - await expectValidAs(result, '3.0') - expectAcyclic(result) - }) -}) - -describe('object cycles of the input', () => { - // A cycle the input graph already has is kept as a cycle. Only the copy - // that inlining makes of it is cut, since a copy cannot point back into - // the original. - it('keeps an object cycle that an inlined target also reaches', () => { - const a: Record = { properties: {}, type: 'object' } - const b = { properties: { back: a }, type: 'object' } - a.properties = { hook: { $ref: webhookSchemaPointer }, b } - const result = convertSpec({ - components: { schemas: { A: a } }, - webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b }, type: 'object' } } } } } } }, - }) - const converted = dig(result, 'components', 'schemas', 'A') - expect(dig(converted, 'properties', 'b', 'properties', 'back')).toBe(converted) - expect(dig(converted, 'properties', 'hook', 'properties', 'b', 'properties', 'back')).toEqual({}) - }) - - it('cuts a reference that comes back to an object shared within the input', () => { - const shared: Record = { properties: { a: { $ref: webhookSchemaPointer } }, type: 'object' } - expect(dig(convertSpec({ - components: { schemas: { S: shared } }, - webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b: shared }, type: 'object' } } } } } } }, - }), 'components', 'schemas', 'S')).toEqual({ - properties: { a: { properties: { b: {} }, type: 'object' } }, - type: 'object', - }) - }) - - it('inlines into a cyclic input graph, preserving its cycle', () => { - const node: Record = { type: 'object' } - node.properties = { hook: { $ref: webhookSchemaPointer }, self: node } - const result = convertSpec({ - components: { schemas: { Node: node } }, - webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } } } } } } }, - }) - const converted = dig(result, 'components', 'schemas', 'Node') - expect(dig(converted, 'properties', 'self')).toBe(converted) - expect(dig(converted, 'properties', 'hook')).toEqual({ properties: { name: { type: 'string' } }, type: 'object' }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts deleted file mode 100644 index 5b5050f..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/reference-objects.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// 3.1 Reference Objects may carry `summary` and `description` overrides: -// https://spec.openapis.org/oas/v3.1.2.html#reference-object -// A 3.0 Reference Object "cannot be extended with additional properties, -// and any properties added SHALL be ignored": -// https://spec.openapis.org/oas/v3.0.4.html#reference-object -// Since 3.0 tools would ignore them anyway, they are removed, together with -// any other field beside `$ref`. - -import { convertComponent, convertPathItem, convertSpec } from './helpers' - -it('strips reference overrides across components maps', () => { - expect(convertSpec({ - components: { - callbacks: { C: { $ref: '#/c/cb', summary: 's' } }, - examples: { E: { $ref: '#/c/e', description: 'd' } }, - headers: { H: { $ref: '#/c/h', summary: 's' } }, - links: { L: { '$ref': '#/c/l', 'description': 'd', 'x-note': 'n' } }, - parameters: { P: { $ref: '#/c/p', description: 'd', summary: 's' } }, - requestBodies: { B: { $ref: '#/c/b', summary: 's' } }, - responses: { R: { $ref: '#/c/r', description: 'd' } }, - securitySchemes: { S: { $ref: '#/c/s', description: 'd' } }, - }, - }).components).toEqual({ - callbacks: { C: { $ref: '#/c/cb' } }, - examples: { E: { $ref: '#/c/e' } }, - headers: { H: { $ref: '#/c/h' } }, - links: { L: { $ref: '#/c/l' } }, - parameters: { P: { $ref: '#/c/p' } }, - requestBodies: { B: { $ref: '#/c/b' } }, - responses: { R: { $ref: '#/c/r' } }, - securitySchemes: { S: { $ref: '#/c/s' } }, - }) -}) - -it('strips reference overrides inside operations and path items', () => { - expect(convertPathItem({ - get: { - callbacks: { cb: { $ref: '#/c/cb', summary: 's' } }, - parameters: [{ $ref: '#/c/p', description: 'd' }], - requestBody: { $ref: '#/c/b', summary: 's' }, - responses: { 200: { $ref: '#/c/r', summary: 's' } }, - }, - parameters: [{ $ref: '#/c/pp', summary: 's' }], - })).toEqual({ - get: { - callbacks: { cb: { $ref: '#/c/cb' } }, - parameters: [{ $ref: '#/c/p' }], - requestBody: { $ref: '#/c/b' }, - responses: { 200: { $ref: '#/c/r' } }, - }, - parameters: [{ $ref: '#/c/pp' }], - }) -}) - -it('strips reference overrides in response headers, links, and media type examples', () => { - expect(convertComponent('responses', { - content: { 'application/json': { examples: { e: { $ref: '#/c/e', summary: 's' } }, schema: { type: ['string', 'null'] } } }, - description: 'ok', - headers: { H: { $ref: '#/c/h', summary: 's' } }, - links: { l: { $ref: '#/c/l', description: 'd' } }, - })).toEqual({ - content: { 'application/json': { examples: { e: { $ref: '#/c/e' } }, schema: { nullable: true, type: 'string' } } }, - description: 'ok', - headers: { H: { $ref: '#/c/h' } }, - links: { l: { $ref: '#/c/l' } }, - }) -}) - -// A Path Item `$ref` is not a Reference Object: its sibling fields are part -// of the Path Item in both versions (https://spec.openapis.org/oas/v3.0.4.html#path-item-ref). -it('keeps the fields beside a Path Item $ref that it leaves as written, whatever the $ref value', () => { - expect(convertPathItem({ $ref: '#/paths/~1other', summary: 's' })).toEqual({ $ref: '#/paths/~1other', summary: 's' }) - expect(convertPathItem({ $ref: 'https://example.com/paths.json#/a' })).toEqual({ $ref: 'https://example.com/paths.json#/a' }) - expect(convertPathItem({ $ref: 42 })).toEqual({ $ref: 42 }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts deleted file mode 100644 index 57cbab5..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/removed-parts.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -// `webhooks` and `components.pathItems` have no 3.0 form and are removed, -// which would leave every local `$ref` into them dangling. Instead, such a -// reference is replaced by a converted copy of its target (inlined), -// following the reference chain until it leaves the removed parts. - -import { dig } from '../../helpers' -import { convertSpec, item, removedPointer, webhookSchemaPointer } from './helpers' - -const hook = { - post: { - operationId: 'newPetHook', - parameters: [{ description: 'orig', in: 'header', name: 'X-Hook', schema: { type: ['string', 'null'] } }], - requestBody: { content: { 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } } } }, - responses: { 200: { description: 'ok' } }, - }, -} -/** Points into the `full` webhook that several tests below inline from. */ -function full(path: string): string { - return `#/webhooks/full/post/${path}` -} - -const hookParameter = { description: 'orig', in: 'header', name: 'X-Hook', schema: { nullable: true, type: 'string' } } - -describe('inlining', () => { - it('inlines references into webhooks and components.pathItems without mutating the input', () => { - const input = { - components: { pathItems: { Item: item }, schemas: { Pet: { $ref: webhookSchemaPointer } } }, - paths: { - '/a': { - get: { - parameters: [{ $ref: '#/webhooks/newPet/post/parameters/0' }, { $ref: '#/components/pathItems/Item/parameters/0' }], - responses: { 200: { $ref: '#/webhooks/newPet/post/responses/200' } }, - }, - }, - '/b': { $ref: '#/webhooks/newPet' }, - }, - webhooks: { newPet: hook }, - } - const before = structuredClone(input) - const result = convertSpec(input) - expect(result.components).toEqual({ schemas: { Pet: { properties: { name: { type: 'string' } }, type: 'object' } } }) - expect(result.paths).toEqual({ - '/a': { - get: { - parameters: [hookParameter, { in: 'query', name: 'q', schema: { enum: ['x'] } }], - responses: { 200: { description: 'ok' } }, - }, - }, - '/b': { post: { ...hook.post, parameters: [hookParameter] } }, - }) - expect(JSON.stringify(result)).not.toMatch(removedPointer) - expect(input).toEqual(before) - }) - - // The same target converts differently depending on what it is used as: - // a callback's path items get default responses, a parameter in the path - // becomes required, schemas lose their 3.1-only keywords, and so on. - it('converts each inlined target for its position, in every component map', () => { - const response = { - content: { 'application/json': { examples: { e: { value: 1 } } } }, - description: 'ok', - headers: { H: { schema: { const: 1 } } }, - } - const result = convertSpec({ - components: { - callbacks: { C: { $ref: full('callbacks/cb') } }, - examples: { E: { $ref: full('responses/200/content/application~1json/examples/e') } }, - headers: { H: { $ref: full('responses/200/headers/H') } }, - parameters: { P: { $ref: full('parameters/0') } }, - requestBodies: { B: { $ref: full('requestBody') } }, - responses: { R: { $ref: full('responses/200') } }, - securitySchemes: { S: { $ref: full('x-scheme') } }, - }, - webhooks: { - full: { - post: { - 'callbacks': { cb: { '{$url}': { get: {} } } }, - 'parameters': [{ in: 'path', name: 'id' }], - 'requestBody': { content: { 'application/json': { schema: { type: ['string', 'null'] } } } }, - 'responses': { 200: response }, - 'x-scheme': { in: 'header', name: 'k', type: 'apiKey' }, - }, - }, - }, - }) - expect(result.components).toEqual({ - callbacks: { C: { '{$url}': { get: { responses: { default: { description: '' } } } } } }, - examples: { E: { value: 1 } }, - headers: { H: { schema: { enum: [1] } } }, - parameters: { P: { in: 'path', name: 'id', required: true } }, - requestBodies: { B: { content: { 'application/json': { schema: { nullable: true, type: 'string' } } } } }, - responses: { R: { ...response, headers: { H: { schema: { enum: [1] } } } } }, - securitySchemes: { S: { in: 'header', name: 'k', type: 'apiKey' } }, - }) - }) - - it('inlines references in operation, path item, media type, parameter, and encoding positions', () => { - expect(convertSpec({ - paths: { - '/a': { - get: { - parameters: [{ examples: { e: { $ref: full('x-example') } }, in: 'query', name: 'q' }], - requestBody: { $ref: full('requestBody') }, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: { $ref: full('x-header') } } } }, - examples: { e: { $ref: full('x-example') } }, - }, - }, - description: 'ok', - }, - }, - }, - parameters: [{ $ref: full('x-parameter') }], - }, - }, - webhooks: { - full: { - post: { - 'requestBody': { content: { 'text/plain': { schema: { const: 'x' } } } }, - 'x-example': { value: 1 }, - 'x-header': { schema: { type: ['string', 'null'] } }, - 'x-parameter': { in: 'path', name: 'id' }, - }, - }, - }, - }).paths).toEqual({ - '/a': { - get: { - parameters: [{ examples: { e: { value: 1 } }, in: 'query', name: 'q' }], - requestBody: { content: { 'text/plain': { schema: { enum: ['x'] } } } }, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: { schema: { nullable: true, type: 'string' } } } } }, - examples: { e: { value: 1 } }, - }, - }, - description: 'ok', - }, - }, - }, - parameters: [{ in: 'path', name: 'id', required: true }], - }, - }) - }) - - // 3.0 Reference Objects cannot carry overrides (see reference-objects.test.ts), - // and the inlined object replaces the reference, so the overrides go. - it('ignores summary and description overrides when it inlines a reference', () => { - expect(convertSpec({ - components: { - callbacks: { C: { $ref: '#/webhooks/newPet/x-callback', description: 'ignored' } }, - examples: { E: { $ref: '#/webhooks/newPet/x-example', summary: 'outer' } }, - parameters: { P: { $ref: '#/webhooks/newPet/x-alias', description: 'outer' } }, - }, - webhooks: { - newPet: { - ...hook, - 'x-alias': { $ref: '#/webhooks/newPet/post/parameters/0', description: 'inner' }, - 'x-callback': { '{$url}': { summary: 's' } }, - 'x-example': { description: 'd', summary: 's', value: 1 }, - }, - }, - }).components).toEqual({ - callbacks: { C: { '{$url}': { summary: 's' } } }, - examples: { E: { description: 'd', summary: 's', value: 1 } }, - parameters: { P: hookParameter }, - }) - }) -}) - -describe('schema references', () => { - // A lone `$ref` is replaced by the target. Beside other keywords, the - // target joins `allOf`, which is how 3.0 combines a reference with - // siblings (see schema/references.test.ts). A boolean target converts - // like any boolean schema. - it('inlines Schema $refs with or without siblings and converts boolean targets', () => { - const pointer = (path: string) => `#/components/pathItems/Schemas/x-schemas/${path}` - const result = convertSpec({ - components: { - pathItems: { - Schemas: { - 'x-schemas': { - alias: { $ref: pointer('nullable') }, - never: false, - nullable: { type: ['string', 'null'] }, - withSiblings: { $ref: pointer('nullable'), description: 'wrapped' }, - }, - }, - }, - schemas: { - Alias: { $ref: pointer('alias') }, - Never: { $ref: pointer('never') }, - NotNever: { not: { $ref: pointer('never') } }, - Siblings: { $ref: pointer('nullable'), description: 'd' }, - WithSiblings: { $ref: pointer('withSiblings') }, - }, - }, - }) - const nullable = { nullable: true, type: 'string' } - expect(result.components).toEqual({ - schemas: { - Alias: nullable, - Never: { not: {} }, - NotNever: { not: { not: {} } }, - Siblings: { allOf: [nullable], description: 'd' }, - WithSiblings: { allOf: [nullable], description: 'wrapped' }, - }, - }) - }) - - // A diamond of references (two properties pointing at the same target, 64 - // levels deep) has 2^64 paths. Each target is converted once and shared. - it('converts a target reached through many references once', () => { - const pointer = (index: number) => `#/webhooks/w${index}/post/requestBody/content/application~1json/schema` - const leaf = { content: { 'application/json': { schema: { type: ['string', 'null'] } } } } - const webhooks: Record = { w64: { post: { requestBody: leaf } } } - for (let index = 0; index < 64; index++) { - const schema = { properties: { a: { $ref: pointer(index + 1) }, b: { $ref: pointer(index + 1) } }, type: 'object' } - webhooks[`w${index}`] = { post: { requestBody: { content: { 'application/json': { schema } } } } } - } - let node = dig(convertSpec({ components: { schemas: { Root: { $ref: pointer(0) } } }, webhooks }), 'components', 'schemas', 'Root') - for (let index = 0; index < 64; index++) { - expect(dig(node, 'properties', 'a')).toBe(dig(node, 'properties', 'b')) - node = dig(node, 'properties', 'a') - } - expect(node).toEqual({ nullable: true, type: 'string' }) - }) - - it('converts path items and headers reached through many references once', () => { - const webhooks: Record = { w30: { 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } } } - for (let index = 0; index < 30; index++) { - const next = { $ref: `#/webhooks/w${index + 1}` } - const header = { $ref: `#/webhooks/w${index + 1}/x-header` } - webhooks[`w${index}`] = { - 'get': { callbacks: { a: { expr: next }, b: { expr: next } }, responses: {} }, - 'x-header': { content: { 'text/plain': { encoding: { e: { headers: { a: header, b: header } } } } } }, - } - } - const result = convertSpec({ - components: { headers: { H: { $ref: '#/webhooks/w0/x-header' } } }, - paths: { '/a': { $ref: '#/webhooks/w0' } }, - webhooks, - }) - let pathItem = dig(result, 'paths', '/a') - let header = dig(result, 'components', 'headers', 'H') - for (let index = 0; index < 30; index++) { - expect(dig(pathItem, 'get', 'callbacks', 'a', 'expr')).toBe(dig(pathItem, 'get', 'callbacks', 'b', 'expr')) - pathItem = dig(pathItem, 'get', 'callbacks', 'a', 'expr') - const headers = dig(header, 'content', 'text/plain', 'encoding', 'e', 'headers') - expect(dig(headers, 'a')).toBe(dig(headers, 'b')) - header = dig(headers, 'a') - } - expect(pathItem).toEqual({ 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } }) - expect(header).toEqual({ schema: { type: 'string' } }) - }) -}) - -describe('reference chains', () => { - it('follows chains through the removed parts and keeps the reference where a chain leaves them', () => { - const result = convertSpec({ - components: { - parameters: { Shared: { in: 'query', name: 'shared' } }, - pathItems: { Deep: { parameters: [{ in: 'query', name: 'deep' }] } }, - schemas: { - Exit: { $ref: '#/webhooks/chain/post/requestBody/content/application~1json/schema' }, - Name: { type: 'string' }, - }, - }, - paths: { - '/a': { - post: { - parameters: [ - { $ref: '#/webhooks/chain/post/parameters/0' }, - { $ref: '#/webhooks/chain/post/parameters/1', description: 'dropped' }, - ], - responses: {}, - }, - }, - '/b': { $ref: '#/webhooks/alias', description: 'own' }, - }, - webhooks: { - alias: { $ref: '#/paths/~1a', summary: 'alias' }, - chain: { - post: { - parameters: [{ $ref: '#/components/pathItems/Deep/parameters/0' }, { $ref: '#/components/parameters/Shared' }], - requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Name' } } } }, - }, - }, - }, - }) - expect(result.components?.schemas?.Exit).toEqual({ $ref: '#/components/schemas/Name' }) - expect(result.paths).toEqual({ - '/a': { post: { parameters: [{ in: 'query', name: 'deep' }, { $ref: '#/components/parameters/Shared' }], responses: {} } }, - '/b': { $ref: '#/paths/~1a', description: 'own', summary: 'alias' }, - }) - }) - - // Chains are followed with loops rather than recursion, so their length is - // not bounded by the call stack. - it('follows long chains without growing the stack', () => { - const webhooks: Record = { w10000: { get: { responses: {} } } } - for (let index = 0; index < 10_000; index++) { - webhooks[`w${index}`] = { $ref: `#/webhooks/w${index + 1}` } - } - expect(convertSpec({ paths: { '/a': { $ref: '#/webhooks/w0' } }, webhooks }).paths).toEqual({ '/a': { get: { responses: {} } } }) - }) - - it('removes thousands of aliases chained to a removed security scheme', () => { - const securitySchemes: Record = { s0: { type: 'mutualTLS' } } - for (let index = 1; index <= 5000; index++) { - securitySchemes[`s${index}`] = { $ref: `#/components/securitySchemes/s${index - 1}` } - } - const result = convertSpec({ components: { securitySchemes }, security: [{ s5000: [] }] }) - expect(result.components).toEqual({ securitySchemes: {} }) - expect(result).not.toHaveProperty('security') - }) -}) - -describe('pointers', () => { - // Pointer fragments are percent-decoded (https://www.rfc-editor.org/rfc/rfc3986#section-2.1) - // before `~1` and `~0` are unescaped (https://www.rfc-editor.org/rfc/rfc6901#section-4). - it('resolves percent-encoded and tilde-escaped pointers', () => { - expect(convertSpec({ - paths: { - '/a': { - get: { - parameters: [ - { $ref: '#/webhooks/new%20pet/post/parameters/0' }, - { $ref: '#/webhooks/a~0b~1c/post/parameters/0' }, - { $ref: '#%2Fwebhooks%2Fnew%20pet%2Fpost%2Fparameters%2F1' }, - ], - responses: {}, - }, - }, - '/b': { $ref: '#/webhooks/new%20pet/post/callbacks/cb/%7B$request.body%23~1url%7D' }, - }, - webhooks: { - 'a~b/c': { post: { parameters: [{ in: 'query', name: 'tilde' }] } }, - 'new pet': { - post: { - callbacks: { cb: { '{$request.body#/url}': { summary: 'callback' } } }, - parameters: [{ in: 'query', name: 'space' }, { in: 'query', name: 'encoded' }], - }, - }, - }, - }).paths).toEqual({ - '/a': { - get: { - parameters: [{ in: 'query', name: 'space' }, { in: 'query', name: 'tilde' }, { in: 'query', name: 'encoded' }], - responses: {}, - }, - }, - '/b': { summary: 'callback' }, - }) - }) - - // Array tokens must be canonical indices ("0", not "00", "-", or - // "length"), and object tokens must be own keys, never inherited members - // such as `constructor`: https://www.rfc-editor.org/rfc/rfc6901#section-4 - it('resolves pointer tokens only against keys and indices the document owns', () => { - const refs = [ - '#/webhooks/__proto__/post/parameters/0', - '#/webhooks/__proto__/post/parameters/length', - '#/webhooks/__proto__/post/parameters/00', - '#/webhooks/__proto__/post/parameters/-', - '#/webhooks/constructor', - '#/webhooks/hasOwnProperty', - ] - const result = convertSpec({ - paths: { '/a': { get: { parameters: refs.map($ref => ({ $ref })), responses: {} } } }, - webhooks: JSON.parse('{"__proto__":{"post":{"parameters":[{"in":"query","name":"own"}]}}}'), - }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([{ in: 'query', name: 'own' }, ...refs.slice(1).map($ref => ({ $ref }))]) - }) -}) - -describe('path Item references', () => { - // A Path Item `$ref` is merged only when its pointer names a Path Item: - // an entry of `paths`, `webhooks`, `components.pathItems`, or a Callback - // Object. A schema property or an extension that happens to be called - // `callbacks` does not count, and neither does an `x-` key. - it.each([ - ['a schema property named callbacks', '#/webhooks/w/post/requestBody/content/a~1b/schema/properties/callbacks/properties/x'], - ['a webhook named callbacks', '#/webhooks/callbacks/get/responses'], - ['a callback extension', '#/webhooks/w/post/callbacks/c/x-note'], - ])('leaves a Path Item $ref to %s as written', (_name, ref) => { - expect(convertSpec({ - paths: { '/a': { $ref: ref } }, - webhooks: { - callbacks: { get: { responses: { 200: { description: 'ok' } } } }, - w: { - post: { - callbacks: { c: { 'x-note': { get: {} } } }, - requestBody: { content: { 'a/b': { schema: { properties: { callbacks: { properties: { x: { get: 'prop', type: 'string' } } } } } } } }, - }, - }, - }, - }).paths).toEqual({ '/a': { $ref: ref } }) - }) - - it('inlines a Path Item $ref to a callback nested in another callback', () => { - expect(convertSpec({ - paths: { '/a': { $ref: '#/webhooks/w/post/callbacks/c/{$url}/get/callbacks/d/{$url}' } }, - webhooks: { w: { post: { callbacks: { c: { '{$url}': { get: { callbacks: { d: { '{$url}': { summary: 'nested' } } } } } } } } } }, - }).paths).toEqual({ '/a': { summary: 'nested' } }) - }) -}) - -describe('references left as written', () => { - // Nothing can be inlined for a target that is missing, is not an object, - // or never ends. These references already dangle or loop in the input, - // and are left as written wherever they appear (only stripped of the - // overrides 3.0 ignores). - it.each([ - ['a missing target', '#/webhooks/newPet/post/parameters/9'], - ['a non-object target', '#/webhooks/newPet/post/operationId'], - ['a looping chain', '#/components/pathItems/Loop/parameters/0'], - ['a malformed percent escape', '#/webhooks/%E0%A4%A'], - ])('leaves a reference to %s as written, in every position', (_name, ref) => { - const reference = { $ref: ref, description: 'd' } - const bare = { $ref: ref } - const result = convertSpec({ - components: { - callbacks: { C: reference }, - examples: { E: reference }, - headers: { H: reference }, - links: { L: reference }, - parameters: { P: reference }, - pathItems: { - Loop: { parameters: [{ $ref: '#/components/pathItems/Loop/parameters/1' }, { $ref: '#/components/pathItems/Loop/parameters/0' }] }, - }, - requestBodies: { B: reference }, - responses: { R: reference }, - schemas: { S: bare, T: { $ref: ref, type: 'string' } }, - securitySchemes: { S: reference }, - }, - paths: { - '/a': { - get: { - callbacks: { cb: reference }, - parameters: [reference, { in: 'query', name: 'kept' }], - requestBody: reference, - responses: { - 200: { - content: { - 'application/json': { - encoding: { f: { headers: { H: reference } } }, - examples: { e: reference }, - schema: bare, - }, - }, - description: 'ok', - headers: { H: reference }, - links: { l: reference }, - }, - 201: reference, - }, - }, - parameters: [reference], - }, - '/b': reference, - }, - webhooks: { newPet: hook }, - }) - expect(result.components).toEqual({ - callbacks: { C: bare }, - examples: { E: bare }, - headers: { H: bare }, - links: { L: bare }, - parameters: { P: bare }, - requestBodies: { B: bare }, - responses: { R: bare }, - schemas: { S: bare, T: { allOf: [bare], type: 'string' } }, - securitySchemes: { S: bare }, - }) - expect(result.paths).toEqual({ - '/a': { - get: { - callbacks: { cb: bare }, - parameters: [bare, { in: 'query', name: 'kept' }], - requestBody: bare, - responses: { - 200: { - content: { 'application/json': { encoding: { f: { headers: { H: bare } } }, examples: { e: bare }, schema: bare } }, - description: 'ok', - headers: { H: bare }, - links: { l: bare }, - }, - 201: bare, - }, - }, - parameters: [bare], - }, - '/b': reference, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/schema-identifiers.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/schema-identifiers.test.ts deleted file mode 100644 index 7f63e7a..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/schema-identifiers.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// `$ref`s inside a schema with an `$id`, as in schema/references.test.ts, -// within a whole document. - -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' - -import { downgradeSpecV31ToV30 } from '@openapi-spec/downgrader' - -import { dig } from '../../helpers' -import { expectValidDowngrade } from '../../validate' -import { convertSpec, info, webhookSchemaPointer } from './helpers' - -it('rebases a recursive $ref onto the component that holds the $id', async () => { - const doc: OpenAPIV3_1.OpenAPIObject = { - components: { - schemas: { - Tree: { $id: 'https://example.com/tree', properties: { kids: { items: { $ref: '#' }, type: 'array' } }, type: 'object' }, - }, - }, - info, - openapi: '3.1.0', - paths: {}, - } - const v30 = await expectValidDowngrade(doc, downgradeSpecV31ToV30, '3.1', '3.0') - expect(dig(v30, 'components', 'schemas', 'Tree')).toEqual({ - properties: { kids: { items: { $ref: '#/components/schemas/Tree' }, type: 'array' } }, - type: 'object', - }) -}) - -it('inlines a $ref inside a removed schema from that schema, not from the document', () => { - const result = convertSpec({ - components: { schemas: { Pet: { $ref: webhookSchemaPointer } } }, - webhooks: { - newPet: { - post: { - requestBody: { - content: { - 'application/json': { - schema: { - $defs: { Name: { type: 'string' } }, - $id: 'https://example.com/pet', - properties: { name: { $ref: '#/$defs/Name' } }, - type: 'object', - }, - }, - }, - }, - responses: {}, - }, - }, - }, - }) - expect(dig(result, 'components', 'schemas', 'Pet')).toEqual({ properties: { name: { type: 'string' } }, type: 'object' }) -}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts deleted file mode 100644 index bcff65a..0000000 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/security.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { dig } from '../../helpers' -import { convertSpec } from './helpers' - -const apiKey = { in: 'header', name: 'k', type: 'apiKey' } - -describe('mutualTLS', () => { - // The `mutualTLS` scheme type is new in 3.1: https://spec.openapis.org/oas/v3.1.2.html#security-scheme-type - // 3.0 cannot describe it, so the scheme is removed together with its name - // in every Security Requirement. A requirement left empty is removed too: - // an empty requirement `{}` means "no security needed" - // (https://spec.openapis.org/oas/v3.0.4.html#security-requirement-object), - // which would open up an endpoint that required a client certificate. - it('removes mutualTLS schemes and requirements that become empty', () => { - const result = convertSpec({ - components: { securitySchemes: { api: apiKey, mtls: { type: 'mutualTLS' } } }, - security: [{ mtls: [] }, { api: [], mtls: [] }, {}], - }) - expect(result.components).toEqual({ securitySchemes: { api: apiKey } }) - expect(result.security).toEqual([{ api: [] }, {}]) - }) - - // An empty operation `security` list would remove all security from the - // operation (https://spec.openapis.org/oas/v3.0.4.html#operation-security), - // so an emptied list is removed instead. The operation then falls back to - // the root `security`. - it('removes a security list that became empty 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 a security list that was empty in the input', () => { - const result = convertSpec({ paths: { '/a': { get: { responses: {}, security: [] } } }, security: [] }) - expect(result.paths).toEqual({ '/a': { get: { responses: {}, security: [] } } }) - expect(result.security).toEqual([]) - }) - - 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: [] }] } } }) - }) - - // A scheme can be a Reference Object. It is judged by the scheme at the - // end of its reference chain. - it('removes reference aliases of mutualTLS schemes and their requirements', () => { - const result = convertSpec({ - components: { - securitySchemes: { - api: apiKey, - clientCert: { $ref: '#/components/securitySchemes/mtlsBase' }, - mtlsBase: { type: 'mutualTLS' }, - }, - }, - security: [{ clientCert: [] }, { api: [] }], - }) - expect(result.components).toEqual({ securitySchemes: { api: apiKey } }) - expect(result.security).toEqual([{ api: [] }]) - }) - - it('removes schemes aliased into the removed parts by the type of their target', () => { - const result = convertSpec({ - components: { - securitySchemes: { - 'Escaped': { $ref: '#/components/securitySchemes/m~1tls' }, - 'Http': { $ref: '#/webhooks/w/x-http' }, - 'm/tls': { type: 'mutualTLS' }, - 'Tls': { $ref: '#/webhooks/w/x-tls' }, - }, - }, - paths: { '/a': { get: { responses: {}, security: [{ Tls: [] }, { Escaped: [] }, { Http: ['read'] }] } } }, - security: [{ Tls: [] }], - webhooks: { w: { 'x-http': { scheme: 'bearer', type: 'http' }, 'x-tls': { type: 'mutualTLS' } } }, - }) - expect(result.components).toEqual({ securitySchemes: { Http: { scheme: 'bearer', type: 'http' } } }) - expect(result.security).toBeUndefined() - expect(dig(result, 'paths', '/a', 'get', 'security')).toEqual([{ Http: [] }]) - }) - - 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' }, - } - const security = [{ dangling: ['a'], external: ['b'], junk: ['c'], nested: ['d'], ping: ['e'] }] - expect(convertSpec({ components: { securitySchemes }, security })).toMatchObject({ components: { securitySchemes }, security }) - }) -}) - -describe('scopes of non-OAuth schemes', () => { - // 3.1 lets a requirement list role names for any scheme type: - // https://spec.openapis.org/oas/v3.1.2.html#security-requirements-name - // In 3.0, "for other security scheme types, the array MUST be empty": - // https://spec.openapis.org/oas/v3.0.4.html#security-requirements-name - // The roles are not exchanged in-band, so dropping them does not change - // what a client sends. - it('empties the list on apiKey and http schemes and keeps it elsewhere', () => { - expect(convertSpec({ - components: { - securitySchemes: { - api: apiKey, - basic: { scheme: 'basic', type: 'http' }, - oauth: { flows: {}, type: 'oauth2' }, - oidc: { openIdConnectUrl: 'https://x', type: 'openIdConnect' }, - }, - }, - security: [ - { api: ['read'], basic: ['admin'] }, - { oauth: ['read'], oidc: ['a'], unknownScheme: ['s'] }, - ], - }).security).toEqual([ - { api: [], basic: [] }, - { oauth: ['read'], oidc: ['a'], unknownScheme: ['s'] }, - ]) - }) -}) - -describe('malformed input', () => { - it('clones malformed security values and scheme maps unchanged', () => { - expect(convertSpec({ security: [{ api: [] }, 'junk', 42] }).security).toEqual([{ api: [] }, 'junk', 42]) - expect(convertSpec({ security: { api: [] } }).security).toEqual({ api: [] }) - expect(convertSpec({ components: { securitySchemes: 'junk' } }).components).toEqual({ securitySchemes: 'junk' }) - }) - - it('keeps non-array scopes as they are', () => { - expect(convertSpec({ - components: { securitySchemes: { api: apiKey } }, - security: [{ api: 'read' }], - }).security).toEqual([{ api: 'read' }]) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts new file mode 100644 index 0000000..4ec6bc4 --- /dev/null +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -0,0 +1,394 @@ +import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' + +import { runInNewContext } from 'node:vm' + +import { downgradeSchemaV32ToV31, downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' + +import { expectValidDowngrade } from './validate' + +const info = { title: 'API', version: '1.0.0' } + +function convert(fields: Omit) { + return downgradeSpecV32ToV31({ openapi: '3.2.0', info, ...fields }) +} + +/** Converts `operation` as `GET /a` and returns it. */ +function convertOperation(operation: OpenAPIV3_2.OperationObject) { + return convert({ paths: { '/a': { get: operation } } }).paths?.['/a']?.get +} + +describe('downgradeSpecV32ToV31', () => { + it('converts a document using every 3.2 feature into a valid 3.1 document', async () => { + const doc: OpenAPIV3_2.OpenAPIObject = { + openapi: '3.2.0', + $self: 'https://example.com/openapi.json', + info, + jsonSchemaDialect: 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17', + servers: [{ url: 'https://example.com', name: 'production' }], + tags: [{ name: 'pets', summary: 'Pets', kind: 'nav' }, { name: 'cats', parent: 'pets' }], + paths: { + '/pets': { + query: { responses: { 200: { description: 'ok' } } }, + additionalOperations: { PURGE: { responses: { 204: { description: 'purged' } } } }, + get: { + parameters: [ + { in: 'querystring', name: 'q', content: { 'application/x-www-form-urlencoded': { schema: { type: 'object' } } } }, + { in: 'cookie', name: 'session', style: 'cookie', schema: { type: 'string' } }, + { in: 'cookie', name: 'pref', style: 'form', allowReserved: true, schema: { type: 'string' } }, + { $ref: '#/components/parameters/Search' }, + ], + responses: { + 200: { + summary: 'The pets', + content: { + 'application/jsonl': { description: 'One pet per line', itemSchema: { $ref: '#/components/schemas/Pet' } }, + 'application/json': { $ref: '#/components/mediaTypes/Pets' }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + xml: { nodeType: 'element' }, + discriminator: { propertyName: 'kind', defaultMapping: '#/components/schemas/Pet' }, + properties: { kind: { type: 'string' }, id: { type: 'string', xml: { nodeType: 'attribute' } } }, + }, + }, + mediaTypes: { Pets: { schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } } } }, + parameters: { + Search: { in: 'querystring', name: 'search', content: { 'application/json': { schema: { type: 'object' } } } }, + }, + examples: { Pet: { dataValue: { kind: 'cat' }, serializedValue: '{"kind":"cat"}' } }, + securitySchemes: { + oauth: { + type: 'oauth2', + deprecated: true, + oauth2MetadataUrl: 'https://example.com/.well-known/oauth-authorization-server', + flows: { + deviceAuthorization: { deviceAuthorizationUrl: 'https://example.com/device', tokenUrl: 'https://example.com/token', scopes: {} }, + clientCredentials: { tokenUrl: 'https://example.com/token', scopes: {} }, + }, + }, + }, + }, + } + await expectValidDowngrade(doc, downgradeSpecV32ToV31, '3.2', '3.1') + }) + + it('sets the version, drops $self, and points the 3.2 dialect at the 3.1 one', () => { + const out = convert({ $self: 'https://example.com/openapi.json', jsonSchemaDialect: 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17' }) + expect(out).toEqual({ openapi: '3.1.2', info, jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base' }) + expect(convert({ jsonSchemaDialect: 'https://example.com/dialect' }).jsonSchemaDialect).toBe('https://example.com/dialect') + }) + + it('drops Server name wherever servers appear', () => { + const server = { url: 'https://example.com', name: 'production' } + const out = convert({ + servers: [server], + paths: { + '/a': { + servers: [server], + get: { + servers: [server], + responses: { 200: { description: 'ok', links: { self: { operationId: 'a', server } } } }, + }, + }, + }, + }) + const expected = { url: 'https://example.com' } + expect(out.servers).toEqual([expected]) + expect(out.paths?.['/a']?.servers).toEqual([expected]) + expect(out.paths?.['/a']?.get?.servers).toEqual([expected]) + expect(out.paths?.['/a']?.get?.responses?.['200']).toMatchObject({ links: { self: { server: expected } } }) + }) + + it('drops Tag summary, parent, and kind, and lets the summary stand in for a missing description', () => { + expect(convert({ tags: [{ name: 'cats', summary: 'Cats', parent: 'pets', kind: 'nav', description: 'd' }, { name: 'dogs', summary: 'Dogs' }] }).tags) + .toEqual([{ name: 'cats', description: 'd' }, { name: 'dogs', description: 'Dogs' }]) + }) + + it('drops the query method and additionalOperations', () => { + const responses = { 200: { description: 'ok' } } + const out = convert({ paths: { '/a': { get: { responses }, query: { responses }, additionalOperations: { PURGE: { responses } } } } }) + expect(out.paths).toEqual({ '/a': { get: { responses } } }) + }) + + describe('parameters', () => { + it('drops querystring parameters, and the components and $refs that resolve to them', () => { + const out = convert({ + paths: { + '/a': { + parameters: [{ $ref: '#/components/parameters/Alias' }, { in: 'query', name: 'kept' }], + get: { + parameters: [{ in: 'querystring', name: 'q', content: { 'text/plain': {} } }], + responses: {}, + }, + }, + }, + components: { + parameters: { + Q: { in: 'querystring', name: 'q', content: { 'text/plain': {} } }, + Alias: { $ref: '#/components/parameters/Q' }, + Kept: { in: 'query', name: 'k' }, + }, + }, + }) + expect(out.paths?.['/a']?.parameters).toEqual([{ in: 'query', name: 'kept' }]) + expect(out.paths?.['/a']?.get?.parameters).toEqual([]) + expect(out.components?.parameters).toEqual({ Kept: { in: 'query', name: 'k' } }) + }) + + it('keeps allowReserved only on query parameters', () => { + const out = convertOperation({ + parameters: [ + { in: 'query', name: 'q', allowReserved: true }, + { in: 'path', name: 'p', required: true, allowReserved: true }, + { in: 'cookie', name: 'c', style: 'form', allowReserved: true }, + ], + }) + expect(out?.parameters).toEqual([ + { in: 'query', name: 'q', allowReserved: true }, + { in: 'path', name: 'p', required: true }, + { in: 'cookie', name: 'c', style: 'form' }, + ]) + }) + + it('drops style: cookie', () => { + expect(convertOperation({ parameters: [{ in: 'cookie', name: 'c', style: 'cookie' }, { in: 'cookie', name: 'f', style: 'form' }] })?.parameters) + .toEqual([{ in: 'cookie', name: 'c' }, { in: 'cookie', name: 'f', style: 'form' }]) + }) + + it('drops the fields 3.1 allows only beside schema from a parameter with content', () => { + const content = { 'application/json': { example: 1 } } + // As oRPC emits a query parameter whose queryStyles entry is 'json'. + const parameter = { in: 'query', name: 'q', content, allowEmptyValue: true, allowReserved: true, style: 'form', explode: true, example: 1, examples: { a: { value: 1 } } } as const + expect(convertOperation({ parameters: [parameter] })?.parameters).toEqual([{ in: 'query', name: 'q', content, allowEmptyValue: true }]) + }) + }) + + describe('media types', () => { + it('drops description, prefixEncoding, and itemEncoding', () => { + const out = convertOperation({ + requestBody: { + content: { + 'multipart/mixed': { description: 'd', schema: { type: 'array' }, prefixEncoding: [{ contentType: 'a/b' }], itemEncoding: { contentType: 'a/b' } }, + }, + }, + }) + expect(out?.requestBody).toEqual({ content: { 'multipart/mixed': { schema: { type: 'array' } } } }) + }) + + it('turns a lone itemSchema into an array schema, and drops it beside schema', () => { + const out = convertOperation({ + responses: { + 200: { + description: 'ok', + content: { + 'application/jsonl': { itemSchema: { type: 'string', xml: { nodeType: 'attribute' } } }, + 'text/event-stream': { schema: { type: 'string' }, itemSchema: { type: 'object' } }, + }, + }, + }, + }) + expect(out?.responses?.['200']).toEqual({ + description: 'ok', + content: { + 'application/jsonl': { schema: { type: 'array', items: { type: 'string', xml: { attribute: true } } } }, + 'text/event-stream': { schema: { type: 'string' } }, + }, + }) + }) + + it('drops nested encodings inside an Encoding Object', () => { + const encoding = { contentType: 'multipart/mixed', encoding: { a: {} }, prefixEncoding: [{}], itemEncoding: {} } + const out = convertOperation({ requestBody: { content: { 'multipart/form-data': { encoding: { part: encoding } } } } }) + expect(out?.requestBody).toEqual({ content: { 'multipart/form-data': { encoding: { part: { contentType: 'multipart/mixed' } } } } }) + }) + + it('inlines $refs to components.mediaTypes, following chains, and drops the components', () => { + const out = convert({ + paths: { '/a': { get: { responses: { 200: { description: 'ok', content: { 'application/json': { $ref: '#/components/mediaTypes/Alias' } } } } } } }, + components: { + mediaTypes: { + Alias: { $ref: '#/components/mediaTypes/Pet' }, + Pet: { description: 'A pet', schema: { $ref: '#/components/schemas/Pet' } }, + }, + schemas: { Pet: { type: 'object' } }, + }, + }) + expect(out.paths?.['/a']?.get?.responses?.['200']).toEqual({ + description: 'ok', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } }, + }) + expect(out.components).toEqual({ schemas: { Pet: { type: 'object' } } }) + }) + + it('drops a content $ref that does not resolve or loops', () => { + const out = convertOperation({ + requestBody: { + content: { + 'application/json': { $ref: '#/components/mediaTypes/Missing' }, + 'text/plain': { $ref: '#/paths/~1a/get/requestBody/content/text~1plain' }, + }, + }, + }) + expect(out?.requestBody).toEqual({ content: {} }) + }) + }) + + it('drops Response summary, using it as the description when that is missing', () => { + const out = convertOperation({ + responses: { + 200: { summary: 'OK', description: 'Everything went fine' }, + 201: { summary: 'Created' }, + 204: {}, + }, + }) + expect(out?.responses).toEqual({ 200: { description: 'Everything went fine' }, 201: { description: 'Created' }, 204: { description: '' } }) + }) + + it('fills Example value from dataValue, or else serializedValue', () => { + const out = convert({ + components: { + examples: { + Data: { dataValue: { a: 1 }, serializedValue: '{"a":1}' }, + Serialized: { serializedValue: 'a=1' }, + Value: { value: 1, dataValue: 2 }, + External: { externalValue: 'https://example.com/a.json', dataValue: 1 }, + }, + }, + }) + expect(out.components?.examples).toEqual({ + Data: { value: { a: 1 } }, + Serialized: { value: 'a=1' }, + Value: { value: 1 }, + External: { externalValue: 'https://example.com/a.json' }, + }) + }) + + it('drops the device authorization flow, oauth2MetadataUrl, and deprecated from security schemes', () => { + const tokenUrl = 'https://example.com/token' + const out = convert({ + components: { + securitySchemes: { + oauth: { + type: 'oauth2', + deprecated: true, + oauth2MetadataUrl: 'https://example.com/meta', + flows: { + deviceAuthorization: { deviceAuthorizationUrl: 'https://example.com/device', tokenUrl, scopes: {} }, + clientCredentials: { tokenUrl, scopes: {} }, + }, + }, + }, + }, + }) + expect(out.components?.securitySchemes).toEqual({ oauth: { type: 'oauth2', flows: { clientCredentials: { tokenUrl, scopes: {} } } } }) + }) + + it('converts schemas everywhere in the document', () => { + const schema: OpenAPIV3_2.SchemaObject = { type: 'string', xml: { nodeType: 'attribute' } } + const out = convert({ + paths: { '/a': { parameters: [{ in: 'query', name: 'q', schema }] } }, + webhooks: { hook: { post: { requestBody: { content: { 'application/json': { schema } } } } } }, + components: { schemas: { S: schema }, headers: { H: { schema } }, pathItems: { P: { parameters: [{ in: 'query', name: 'q', schema }] } } }, + }) + const expected = { type: 'string', xml: { attribute: true } } + expect(out.paths?.['/a']?.parameters?.[0]).toMatchObject({ schema: expected }) + expect(out.webhooks?.hook?.post?.requestBody).toMatchObject({ content: { 'application/json': { schema: expected } } }) + expect(out.components).toMatchObject({ schemas: { S: expected }, headers: { H: { schema: expected } }, pathItems: { P: { parameters: [{ schema: expected }] } } }) + }) + + it('leaves the input untouched and shares no objects with it', () => { + const example = { nested: { a: 1 } } + const doc: OpenAPIV3_2.OpenAPIObject = { openapi: '3.2.0', info: { ...info, 'x-meta': example }, paths: {} } + const before = structuredClone(doc) + const out = downgradeSpecV32ToV31(doc) + expect(doc).toEqual(before) + expect(out.info['x-meta']).toEqual(example) + expect(out.info['x-meta']).not.toBe(example) + }) + + it('reads null-prototype and other-realm objects as JSON would, such as oRPC\'s generated documents', () => { + function NullProto() {} + NullProto.prototype = Object.freeze(Object.create(null)) + const object = (fields: object) => Object.assign(new (NullProto as any)(), fields) + const doc = object({ openapi: '3.2.0', info: object(info), tags: [object({ name: 'a', kind: 'nav' })], paths: object({}) }) + expect(downgradeSpecV32ToV31(doc)).toEqual({ openapi: '3.1.2', info, tags: [{ name: 'a' }], paths: {} }) + const other = runInNewContext(`(${JSON.stringify({ openapi: '3.2.0', info, servers: [{ url: '/', name: 'main' }] })})`) + expect(downgradeSpecV32ToV31(other)).toEqual({ openapi: '3.1.2', info, servers: [{ url: '/' }] }) + // Keys an object inherits are not part of it as JSON sees it, so such an object is kept as it is. + const inherits = Object.create(Object.assign(Object.create(null), { type: 'string' })) + expect(downgradeSchemaV32ToV31(inherits)).toBe(inherits) + }) + + it('treats keys holding undefined as missing, and keeps non-plain values as they are', () => { + const date = new Date(0) + const out = convertOperation({ 'summary': undefined, 'responses': { 200: { summary: 'OK', description: undefined } }, 'x-date': date }) + expect(out).toEqual({ 'responses': { 200: { description: 'OK' } }, 'x-date': date }) + expect(out?.['x-date']).toBe(date) + }) +}) + +describe('downgradeSchemaV32ToV31', () => { + it('turns XML nodeType into attribute or wrapped, in every subschema', () => { + expect(downgradeSchemaV32ToV31({ + type: 'object', + xml: { name: 'pet', nodeType: 'element' }, + properties: { + id: { type: 'string', xml: { nodeType: 'attribute' } }, + tags: { type: 'array', xml: { nodeType: 'element' }, items: { type: 'string', xml: { nodeType: 'text' } } }, + }, + $defs: { Note: { anyOf: [{ xml: { nodeType: 'cdata' } }] } }, + })).toEqual({ + type: 'object', + xml: { name: 'pet' }, + properties: { + id: { type: 'string', xml: { attribute: true } }, + tags: { type: 'array', xml: { wrapped: true }, items: { type: 'string', xml: {} } }, + }, + $defs: { Note: { anyOf: [{ xml: {} }] } }, + }) + }) + + it('drops discriminator defaultMapping', () => { + expect(downgradeSchemaV32ToV31({ discriminator: { propertyName: 'kind', defaultMapping: 'Cat', mapping: { dog: 'Dog' } } })) + .toEqual({ discriminator: { propertyName: 'kind', mapping: { dog: 'Dog' } } }) + }) + + it('passes everything else through, including properties named like keywords', () => { + const schema: OpenAPIV3_2.SchemaObject = { + $schema: 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17', + $ref: '#/$defs/Base', + type: ['string', 'null'], + prefixItems: [true, false], + properties: { xml: { const: 1 }, discriminator: { type: 'string' } }, + $defs: { Base: { examples: [1] } }, + } + expect(downgradeSchemaV32ToV31(schema)).toEqual(schema) + expect(downgradeSchemaV32ToV31(true)).toBe(true) + }) + + it('keeps a cycle as a cycle, in converted and copied values alike', () => { + const schema: Record = { type: 'object', properties: {} } + schema.properties.self = schema + schema.default = schema + const out = downgradeSchemaV32ToV31(schema) as Record + expect(out.properties.self).toBe(out) + expect(out.default.default).toBe(out.default) + }) +}) + +describe('unusual input', () => { + it('reads a type list when deciding whether an XML element wraps an array', () => { + expect(downgradeSchemaV32ToV31({ type: ['array', 'null'], xml: { nodeType: 'element' } })).toEqual({ type: ['array', 'null'], xml: { wrapped: true } }) + }) + + it('tolerates malformed input without throwing', () => { + expect(downgradeSchemaV32ToV31({ xml: true, allOf: {}, properties: 'none' } as any)).toEqual({ xml: true, allOf: {}, properties: 'none' }) + }) +}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts deleted file mode 100644 index 579a7f1..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/helpers.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' - -/** - * Converts `schema`. The input is typed loosely on purpose: many tests feed - * malformed schemas to check that the conversion tolerates them. - */ -export function convertSchema(schema: unknown): unknown { - return downgradeSchemaV32ToV31(schema as any) -} diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts deleted file mode 100644 index 2cc6a91..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/input.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' - -import { dig } from '../../helpers' -import { convertSchema } from './helpers' - -describe('input shapes', () => { - // `true` and `false` are complete schemas in JSON Schema 2020-12, and 3.1 - // accepts them as they are: https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 - it('passes boolean schemas through', () => { - expect(downgradeSchemaV32ToV31(true)).toBe(true) - expect(downgradeSchemaV32ToV31(false)).toBe(false) - }) - - it('passes non-schema input through', () => { - expect(convertSchema('junk')).toBe('junk') - expect(convertSchema(null)).toBeNull() - expect(convertSchema([{ type: 'string' }])).toEqual([{ type: 'string' }]) - }) -}) - -describe('copies', () => { - it('deep-clones the schema, sharing nothing with the input', () => { - const source = { - allOf: [{ discriminator: { defaultMapping: 'Dog' } }, true], - discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, - items: { xml: { nodeType: 'cdata' } }, - properties: { a: { xml: { name: 'a' } } }, - } - const result = convertSchema(source) - expect(result).toEqual({ - allOf: [{ discriminator: {} }, true], - discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, - items: { xml: {} }, - properties: { a: { xml: { name: 'a' } } }, - }) - expect(result).not.toBe(source) - for (const path of [['allOf'], ['allOf', '0'], ['discriminator'], ['discriminator', 'mapping'], ['items'], ['properties'], ['properties', 'a'], ['properties', 'a', 'xml']]) { - expect(dig(result, ...path)).not.toBe(dig(source, ...path)) - } - }) - - it('returns a fresh copy on every call', () => { - const schema: OpenAPIV3_2.SchemaObject = { properties: { a: { type: 'string' } }, type: 'object' } - expect(downgradeSchemaV32ToV31(schema)).not.toBe(downgradeSchemaV32ToV31(schema)) - }) - - it('never mutates the input schema', () => { - const schema: OpenAPIV3_2.SchemaObject = { - discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, - properties: { a: { xml: { nodeType: 'attribute' } } }, - type: 'object', - } - const before = structuredClone(schema) - downgradeSchemaV32ToV31(schema) - expect(schema).toEqual(before) - }) - - it('keeps the key order of the input', () => { - expect(Object.keys(downgradeSchemaV32ToV31({ type: 'object', required: ['a'], properties: {}, description: 'd' }))).toEqual([ - 'type', - 'required', - 'properties', - 'description', - ]) - }) - - // JSON.parse creates a real own `__proto__` key, here a property name. - it('copies a __proto__ property as a plain own key without polluting prototypes', () => { - const result = downgradeSchemaV32ToV31(JSON.parse('{"properties":{"__proto__":{"xml":{"nodeType":"attribute"}}}}')) - const properties = dig(result, 'properties') as object - expect(Object.getPrototypeOf(properties)).toBe(Object.prototype) - expect(Object.getOwnPropertyDescriptor(properties, '__proto__')?.value).toEqual({ xml: { attribute: true } }) - expect('xml' in {}).toBe(false) - }) -}) - -describe('keys holding undefined', () => { - it('drops undefined values everywhere in the output', () => { - const input = { 'default': { a: undefined, b: 1 }, 'properties': { a: undefined }, 'x-a': undefined, 'xml': { name: 'n', nodeType: undefined } } - expect(convertSchema(input)).toStrictEqual({ default: { b: 1 }, properties: {}, xml: { name: 'n' } }) - }) -}) - -describe('object graphs', () => { - // A dereferenced schema can contain itself. The output keeps the same - // shape: the cycle points at the converted ancestor. - it('converts a cyclic schema, pointing the cycle at the converted ancestor', () => { - const node: Record = { discriminator: { defaultMapping: 'A', propertyName: 'kind' }, type: 'object' } - node.properties = { self: node, children: { items: node, type: 'array' } } - const result = downgradeSchemaV32ToV31(node) - expect(dig(result, 'discriminator')).toEqual({ propertyName: 'kind' }) - expect(dig(result, 'properties', 'self')).toBe(result) - expect(dig(result, 'properties', 'children', 'items')).toBe(result) - }) - - it('converts a subschema shared by several positions once', () => { - const shared = { xml: { nodeType: 'attribute' } } - const result = convertSchema({ properties: { a: shared, b: shared } }) - expect(dig(result, 'properties', 'a')).toEqual({ xml: { attribute: true } }) - expect(dig(result, 'properties', 'b')).toBe(dig(result, 'properties', 'a')) - }) - - it('converts deeply nested schemas without overflowing the stack', () => { - let deep: OpenAPIV3_2.SchemaObject = { type: 'string' } - for (let index = 0; index < 1000; index += 1) { - deep = { items: deep, type: 'array' } - } - expect(() => downgradeSchemaV32ToV31(deep)).not.toThrow() - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts deleted file mode 100644 index ec0ecd0..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/keywords.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Both versions use JSON Schema 2020-12, so a Schema Object only changes in -// the two OpenAPI-specific keywords that 3.2 extended: `xml` and -// `discriminator`. Every other keyword passes through as is. - -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' -import { convertSchema } from './helpers' - -describe('xml.nodeType', () => { - // 3.2 replaces the `attribute` and `wrapped` flags with `nodeType`, one of - // `element`, `attribute`, `text`, `cdata`, or `none`: - // https://spec.openapis.org/oas/v3.2.0.html#xml-node-type - // 3.1 can express two of them: - // - `attribute` as `attribute: true`: https://spec.openapis.org/oas/v3.1.2.html#xml-attribute - // - `element` on an array as `wrapped: true`, since arrays default to - // `none` (unwrapped) and an explicit `element` wraps them: - // https://spec.openapis.org/oas/v3.2.0.html#modeling-element-lists - // https://spec.openapis.org/oas/v3.1.2.html#xml-wrapped - // `element` on any other schema is the default anyway, and `text`, - // `cdata`, and `none` have no 3.1 form, so those are removed. - it.each([ - ['maps an attribute node to attribute: true', { xml: { name: 'n', nodeType: 'attribute' } }, { xml: { attribute: true, name: 'n' } }], - ['maps an element node on an array to wrapped: true', { type: 'array', xml: { nodeType: 'element' } }, { type: 'array', xml: { wrapped: true } }], - ['maps an element node on a nullable array to wrapped: true', { type: ['array', 'null'], xml: { nodeType: 'element' } }, { type: ['array', 'null'], xml: { wrapped: true } }], - ['removes an element node elsewhere, where it is the default', { type: 'object', xml: { nodeType: 'element' } }, { type: 'object', xml: {} }], - ['removes a text node', { xml: { nodeType: 'text' } }, { xml: {} }], - ['removes a cdata node', { xml: { nodeType: 'cdata' } }, { xml: {} }], - ['removes a none node', { xml: { nodeType: 'none' } }, { xml: {} }], - ['keeps an xml object without nodeType', { xml: { name: 'n', prefix: 'p' } }, { xml: { name: 'n', prefix: 'p' } }], - ['passes a malformed xml value through', { xml: 'junk' }, { xml: 'junk' }], - ])('%s', (_name, input, expected) => { - expect(convertSchema(input)).toEqual(expected) - }) -}) - -describe('discriminator.defaultMapping', () => { - // 3.2 lets a discriminator name the schema to use when the property is - // absent or its value is unmapped: - // https://spec.openapis.org/oas/v3.2.0.html#discriminator-default-mapping - // 3.1 has no fallback, so the field is removed and `mapping` is kept. - it('removes defaultMapping and keeps mapping and propertyName', () => { - expect(downgradeSchemaV32ToV31({ - discriminator: { defaultMapping: 'Dog', mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, - oneOf: [{ $ref: '#/components/schemas/Dog' }], - })).toEqual({ - discriminator: { mapping: { dog: '#/components/schemas/Dog' }, propertyName: 'kind' }, - oneOf: [{ $ref: '#/components/schemas/Dog' }], - }) - }) - - it('passes a malformed discriminator or mapping through', () => { - expect(convertSchema({ discriminator: 'junk' })).toEqual({ discriminator: 'junk' }) - expect(convertSchema({ discriminator: { mapping: 'junk', propertyName: 'kind' } })).toEqual({ - discriminator: { mapping: 'junk', propertyName: 'kind' }, - }) - }) -}) - -describe('other keywords', () => { - it('keeps JSON Schema keywords, unknown keywords, and extensions unchanged', () => { - const schema = { - '$comment': 'c', - '$defs': { a: { type: 'string' } }, - '$id': 'https://example.com/s', - '$schema': 'https://spec.openapis.org/oas/3.1/dialect/base', - 'const': 1, - 'customKeyword': { nested: true }, - 'examples': [1], - 'externalDocs': { url: 'https://example.com' }, - 'maximum': 5, - 'type': 'number', - 'x-note': 'kept', - } - expect(downgradeSchemaV32ToV31(schema as OpenAPIV3_2.SchemaObject)).toEqual(schema) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts deleted file mode 100644 index 0f970a7..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/references.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' -import { convertSchema } from './helpers' - -// Converting a standalone schema removes nothing a `$ref` could point at: -// `$defs`, `$id`, and `$anchor` all exist in 3.1. So every reference stays as -// written, wherever it points. -it('leaves every $ref as written', () => { - const schema: OpenAPIV3_2.SchemaObject = { - $defs: { node: { properties: { next: { $ref: '#/$defs/node' } }, type: 'object' } }, - $ref: '#/$defs/node', - properties: { - anchor: { $ref: '#node' }, - external: { $ref: 'https://example.com/pet.json' }, - missing: { $ref: '#/$defs/missing' }, - sibling: { $ref: '#/$defs/node', description: 'with a sibling' }, - }, - } - expect(downgradeSchemaV32ToV31(schema)).toEqual(schema) -}) - -// References into the parts the conversion changes: the `xml` object -// survives, so a `$ref` to it stays valid. `defaultMapping` is removed, but -// its value is a string rather than a schema, so there is nothing to inline -// and the reference is left as written, like any other dangling one. -it('leaves references into converted keywords as written', () => { - const schema = { - discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, - properties: { a: { $ref: '#/xml' }, b: { $ref: '#/discriminator/defaultMapping' } }, - xml: { nodeType: 'attribute' }, - } - expect(convertSchema(schema)).toEqual({ - ...schema, - discriminator: { propertyName: 'kind' }, - xml: { attribute: true }, - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts deleted file mode 100644 index 321f2c4..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/schema/subschemas.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { downgradeSchemaV32ToV31 } from '@openapi-spec/downgrader' -import { convertSchema } from './helpers' - -const inner = { discriminator: { defaultMapping: 'A', propertyName: 'kind' } } -const converted = { discriminator: { propertyName: 'kind' } } - -// Every JSON Schema 2020-12 keyword that takes a schema, a list of schemas, or -// a map of schemas: https://json-schema.org/draft/2020-12/json-schema-core#section-10 -const single = ['additionalProperties', 'contains', 'contentSchema', 'else', 'if', 'items', 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties'] -const lists = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] -const maps = ['$defs', 'dependentSchemas', 'patternProperties', 'properties'] - -it('converts nested schemas at every subschema position', () => { - expect(convertSchema({ - ...Object.fromEntries(single.map(key => [key, inner])), - ...Object.fromEntries(lists.map(key => [key, [inner, true]])), - ...Object.fromEntries(maps.map(key => [key, { a: inner }])), - })).toEqual({ - ...Object.fromEntries(single.map(key => [key, converted])), - ...Object.fromEntries(lists.map(key => [key, [converted, true]])), - ...Object.fromEntries(maps.map(key => [key, { a: converted }])), - }) -}) - -// `const`, `default`, `enum`, and `examples` hold instance data, and -// extensions hold anything. A value there that looks like a schema is not -// one, so it is copied as is. -it('does not convert schema-like values outside subschema positions', () => { - const schema = { 'const': inner, 'default': inner, 'enum': [inner], 'examples': [inner], 'x-extension': inner } - expect(convertSchema(schema)).toEqual(schema) -}) - -it('converts nested schemas at any depth', () => { - expect(downgradeSchemaV32ToV31({ - items: { properties: { a: { anyOf: [{ xml: { nodeType: 'attribute' } }] } } }, - })).toEqual({ - items: { properties: { a: { anyOf: [{ xml: { attribute: true } }] } } }, - }) -}) - -it('clones malformed subschema containers through', () => { - expect(convertSchema({ allOf: 'junk', properties: 5 })).toEqual({ allOf: 'junk', properties: 5 }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts deleted file mode 100644 index faee838..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/components.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { convertComponent, convertSpec } from './helpers' - -describe('component maps', () => { - it('converts inline objects and keeps references to surviving ones in every map', () => { - expect(convertSpec({ - components: { - examples: { E: { dataValue: 1 }, ERef: { $ref: '#/components/examples/E' } }, - headers: { H: { style: 'cookie' }, HRef: { $ref: '#/components/headers/H' } }, - links: { junkLink: 42 }, - parameters: { P: { in: 'querystring', name: 'q' }, PRef: { $ref: '#/components/parameters/P' } }, - requestBodies: { - B: { content: { 'application/json': { itemSchema: { type: 'string' } } } }, - BRef: { $ref: '#/components/requestBodies/B' }, - }, - responses: { R: { summary: 'ok' }, RRef: { $ref: '#/components/responses/R' } }, - }, - }).components).toEqual({ - examples: { E: { value: 1 }, ERef: { $ref: '#/components/examples/E' } }, - headers: { H: {}, HRef: { $ref: '#/components/headers/H' } }, - links: { junkLink: 42 }, - parameters: {}, - requestBodies: { - B: { content: { 'application/json': { schema: { items: { type: 'string' }, type: 'array' } } } }, - BRef: { $ref: '#/components/requestBodies/B' }, - }, - responses: { R: { description: 'ok' }, RRef: { $ref: '#/components/responses/R' } }, - }) - }) - - it('converts components.schemas entries', () => { - expect(convertComponent('schemas', { - discriminator: { defaultMapping: 'Dog', propertyName: 'kind' }, - xml: { nodeType: 'attribute' }, - })).toEqual({ - discriminator: { propertyName: 'kind' }, - xml: { attribute: true }, - }) - }) - - it('clones unknown component keys and passes a non-object components value through', () => { - expect(convertSpec({ components: { custom: { anything: true } } }).components).toEqual({ custom: { anything: true } }) - expect(convertSpec({ components: 'junk' }).components).toBe('junk') - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts deleted file mode 100644 index 3718faa..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/content-references.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -// 3.2 lets a content map hold Reference Objects and adds `components.mediaTypes` -// for reusable Media Type Objects: -// https://spec.openapis.org/oas/v3.2.0.html#request-body-content -// https://spec.openapis.org/oas/v3.2.0.html#components-media-types -// A 3.1 content map holds Media Type Objects only -// (https://spec.openapis.org/oas/v3.1.2.html#request-body-content), so each -// reference is replaced by the converted Media Type Object it resolves to. -// A reference that cannot be resolved inside the document (external, -// missing, or looping) cannot be kept either, so its entry is removed. - -import { dig, expectAcyclic } from '../../helpers' -import { convertContent, convertPathItem, convertSpec } from './helpers' - -describe('inlining', () => { - it('replaces a 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 in request body, response, parameter, and header content maps', () => { - const reference = { 'application/json': { $ref: '#/components/mediaTypes/Json' } } - const inlined = { 'application/json': { schema: { type: 'string' } } } - expect(convertPathItem( - { - get: { - parameters: [{ content: reference, in: 'query', name: 'q' }], - responses: { 200: { content: reference, description: 'ok', headers: { 'X-H': { content: reference } } } }, - }, - }, - { mediaTypes: { Json: { schema: { type: 'string' } } } }, - )).toEqual({ - get: { - parameters: [{ content: inlined, in: 'query', name: 'q' }], - responses: { 200: { content: inlined, description: 'ok', headers: { 'X-H': { content: inlined } } } }, - }, - }) - }) - - it('follows chains of references down to the final media type', () => { - 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('follows long acyclic chains', () => { - const links = Array.from({ length: 40 }, (_, 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' } }, - }) - }) - - // Any local pointer to a Media Type Object works, not only ones into - // `components.mediaTypes`. Pointer tokens escape `/` as `~1`: https://www.rfc-editor.org/rfc/rfc6901#section-4 - it('inlines references to any local media type, decoding escaped names', () => { - expect(convertSpec({ - components: { - mediaTypes: { 'a/b': { schema: { type: 'string' } } }, - requestBodies: { - Json: { content: { 'application/json': { schema: { type: 'number' } } } }, - Reuse: { - content: { - 'application/json': { $ref: '#/components/requestBodies/Json/content/application~1json' }, - 'text/plain': { $ref: '#/components/mediaTypes/a~1b' }, - }, - }, - }, - }, - }).components?.requestBodies).toEqual({ - Json: { content: { 'application/json': { schema: { type: 'number' } } } }, - Reuse: { - content: { - 'application/json': { schema: { type: 'number' } }, - 'text/plain': { schema: { type: 'string' } }, - }, - }, - }) - }) - - it('removes the mediaTypes map from components', () => { - expect(convertSpec({ - components: { mediaTypes: { Json: { schema: {} } }, schemas: { S: { type: 'string' } } }, - }).components).toEqual({ schemas: { S: { type: 'string' } } }) - }) -}) - -describe('references that cannot be inlined', () => { - it('removes entries whose chain loops', () => { - 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 entries with external, missing, and unparseable references', () => { - expect(convertContent( - { - 'a/1': { $ref: '#/components/schemas/Foo' }, - 'a/2': { $ref: '#/components/mediaTypes/nested/name' }, - 'a/3': { $ref: '#/components/mediaTypes/' }, - 'a/4': { $ref: 'https://example.com/other.json#/mediaTypes/A' }, - 'a/5': { $ref: '#/components/mediaTypes/Unknown' }, - 'a/6': { $ref: '#/components/mediaTypes/Known' }, - }, - { mediaTypes: { Known: { example: 1 } } }, - )).toEqual({ 'a/6': { example: 1 } }) - }) - - it('does not resolve names through the prototype chain', () => { - expect(convertContent({ 'application/json': { $ref: '#/components/mediaTypes/hasOwnProperty' } }, { mediaTypes: {} })).toEqual({}) - }) - - it('removes entries when components.mediaTypes is missing or malformed', () => { - const content = { 'application/json': { $ref: '#/components/mediaTypes/A' } } - expect(convertContent(content)).toEqual({}) - expect(convertContent(content, { mediaTypes: 'junk' })).toEqual({}) - }) - - it('clones a non-object content value through', () => { - expect(convertContent('junk')).toBe('junk') - }) -}) - -describe('parameters and headers left without content', () => { - // With `content`, a parameter or header MUST hold exactly one entry - // (https://spec.openapis.org/oas/v3.1.2.html#parameter-content), and it - // has no `schema` to fall back on. Once its only entry is removed it no - // longer describes anything, so it is removed as well. - const missing = { 'application/json': { $ref: '#/components/mediaTypes/Missing' } } - - it('removes a parameter whose only content entry could not be inlined', () => { - expect(convertPathItem({ - get: { - parameters: [{ content: missing, in: 'query', name: 'q' }, { in: 'query', name: 'keep', schema: {} }], - responses: {}, - }, - })).toEqual({ - get: { parameters: [{ in: 'query', name: 'keep', schema: {} }], responses: {} }, - }) - }) - - it('keeps a parameter when part of its content could be inlined', () => { - expect(convertPathItem( - { - get: { - parameters: [{ content: { ...missing, 'application/xml': { $ref: '#/components/mediaTypes/Known' } }, in: 'query', name: 'q' }], - responses: {}, - }, - }, - { mediaTypes: { Known: { example: 1 } } }, - )).toEqual({ - get: { parameters: [{ content: { 'application/xml': { example: 1 } }, in: 'query', name: 'q' }], responses: {} }, - }) - }) - - it('removes headers and component parameters whose entire content could not be inlined', () => { - const result = convertSpec({ - components: { - headers: { Broken: { content: missing }, Keep: { schema: {} } }, - parameters: { Broken: { content: missing, in: 'query', name: 'q' } }, - }, - paths: { - '/a': { - get: { responses: { 200: { description: 'ok', headers: { 'X-Broken': { content: missing }, 'X-Keep': { schema: {} } } } } }, - }, - }, - }) - expect(result.components).toEqual({ headers: { Keep: { schema: {} } }, parameters: {} }) - expect(result.paths).toEqual({ - '/a': { get: { responses: { 200: { description: 'ok', headers: { 'X-Keep': { schema: {} } } } } } }, - }) - }) - - it('removes references to removed parameters and headers, following alias chains', () => { - const result = convertSpec({ - components: { - headers: { - Broken: { content: { 'text/plain': { $ref: '#/components/mediaTypes/Loop' } } }, - BrokenAlias: { $ref: '#/components/headers/Broken' }, - }, - mediaTypes: { Loop: { $ref: '#/components/mediaTypes/Loop' } }, - parameters: { - Broken: { content: missing, in: 'query', name: 'q' }, - BrokenAlias: { $ref: '#/components/parameters/Broken' }, - }, - }, - paths: { - '/a': { - get: { - parameters: [{ $ref: '#/components/parameters/Broken' }, { $ref: '#/components/parameters/BrokenAlias' }], - responses: { - 200: { - description: 'ok', - headers: { - 'X-Broken': { $ref: '#/components/headers/Broken' }, - 'X-BrokenAlias': { $ref: '#/components/headers/BrokenAlias' }, - }, - }, - }, - }, - }, - }, - }) - expect(result.components).toEqual({ headers: {}, parameters: {} }) - expect(result.paths).toEqual({ - '/a': { get: { parameters: [], responses: { 200: { description: 'ok', headers: {} } } } }, - }) - }) - - it('removes a reference to such a header through any pointer', () => { - expect(convertSpec({ - components: { headers: { H: { $ref: '#/paths/~1a/get/responses/200/headers/X-Broken' } } }, - paths: { '/a': { get: { responses: { 200: { description: 'ok', headers: { 'X-Broken': { content: missing } } } } } } }, - }).components).toEqual({ headers: {} }) - }) -}) - -describe('recursive media types', () => { - // A schema that reaches the media type it is inlined from would make the - // output an infinite (circular) object. The recursion is cut instead: the - // inner `$ref` becomes `{}`, the schema that accepts anything, which can - // only loosen validation, never tighten it. - it('cuts a recursive schema reached through a content map instead of emitting a circular object', () => { - const result = convertSpec({ - components: { - mediaTypes: { - Tree: { - schema: { - properties: { children: { items: { $ref: '#/components/mediaTypes/Tree/schema' }, type: 'array' } }, - type: 'object', - }, - }, - }, - }, - paths: { - '/a': { - get: { responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Tree' } }, description: 'ok' } } }, - }, - }, - }) - expectAcyclic(result) - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toEqual({ - properties: { children: { items: {}, type: 'array' } }, - type: 'object', - }) - }) - - // Here the header is reachable both on its own and from inside the media - // type it contains. The copy reached from inside is cut, but the header - // itself survives, so references to it stay valid and are kept. - it('keeps a header alias whose target is only cut by a media type cycle', () => { - const result = convertSpec({ - components: { - headers: { A: { $ref: '#/components/mediaTypes/M/encoding/e/headers/h' } }, - mediaTypes: { - M: { - encoding: { e: { headers: { h: { content: { 'a/b': { $ref: '#/components/mediaTypes/M' } } }, x: { $ref: '#/components/headers/A' } } } }, - schema: { type: 'string' }, - }, - }, - }, - paths: { - '/p': { - get: { - responses: { - 200: { - content: { 'a/b': { $ref: '#/components/mediaTypes/M' } }, - description: 'ok', - headers: { X: { $ref: '#/components/headers/A' } }, - }, - }, - }, - }, - }, - }) - expect(dig(result, 'paths', '/p', 'get', 'responses', '200', 'headers')).toEqual({ X: { $ref: '#/components/headers/A' } }) - expect(dig(result, 'components', 'headers', 'A', 'content', 'a/b', 'schema')).toEqual({ type: 'string' }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts deleted file mode 100644 index 34213f5..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/corpus.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Each official 3.2 document (see tests/corpus.ts) must downgrade to a -// document the official 3.1 JSON Schema accepts, without leaving a reference -// dangling that resolved before. - -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' - -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 mega } from '../../../../types/tests/schema-tests-3.2/mega' -import { corpusV32 } from '../../corpus' -import { expectValidAs, expectValidDowngrade } from '../../validate' - -describe('official corpus', () => { - it.each(corpusV32)('converts %s to a valid 3.1 document', async (_name, doc) => { - await expectValidDowngrade(doc, downgradeSpecV32ToV31, '3.2', '3.1') - }) -}) - -describe('official examples', () => { - it('removes the QUERY operation of the query example, leaving an empty path item', () => { - const v31 = downgradeSpecV32ToV31(queryExample) - expect(v31.paths?.['/flights/search']).toEqual({}) - expect(v31).toMatchSnapshot() - }) - - it('flattens the tag hierarchy of the tags example', () => { - const v31 = downgradeSpecV32ToV31(tagsExample) - 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', - }, - ]) - expect(v31).toMatchSnapshot() - }) - - it('removes the discriminator defaultMapping of the mega document', () => { - const v31 = downgradeSpecV32ToV31(mega) - const discriminator = ['components', 'pathItems', 'myPathItem', 'post', 'requestBody', 'content', 'application/json', 'schema', 'discriminator'] - expect(v31).not.toHaveProperty([...discriminator, 'defaultMapping']) - expect(v31).toHaveProperty([...discriminator, 'propertyName'], 'type') - expect(v31).toMatchSnapshot() - }) -}) - -describe('kitchen sink', () => { - it('converts every 3.2-only construct into a valid 3.1 document', async () => { - const kitchenSink: OpenAPIV3_2.OpenAPIObject = { - $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' }], - } - const before = structuredClone(kitchenSink) - const v31 = downgradeSpecV32ToV31(kitchenSink) - expect(v31).toEqual({ - 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' }], - }) - await expectValidAs(v31, '3.1') - expect(kitchenSink).toEqual(before) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts deleted file mode 100644 index bced2e2..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/document.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' - -import { convertSpec } from './helpers' - -describe('openapi', () => { - it('stamps 3.1.2, the latest 3.1 patch release', () => { - 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 the version when the input has none', () => { - expect(downgradeSpecV32ToV31({} as any)).toEqual({ openapi: '3.1.2' }) - }) -}) - -describe('$self', () => { - // `$self` gives the document its own URI, which then serves as the base URI - // for its relative references: https://spec.openapis.org/oas/v3.2.0.html#oas-self - // 3.1 has no such field, so it is removed. References that relied on it - // are left as written (a known limitation listed in the README). - it('removes $self', () => { - expect(convertSpec({ $self: 'https://example.com/api.json' })).toEqual({ openapi: '3.1.2' }) - }) -}) - -describe('jsonSchemaDialect', () => { - // `jsonSchemaDialect` sets the default `$schema` of every Schema Object: - // https://spec.openapis.org/oas/v3.1.2.html#oas-json-schema-dialect - // 3.2 publishes its own OAS dialects under https://spec.openapis.org/oas/3.2/dialect/, - // whose vocabulary knows 3.2-only keywords such as `xml.nodeType`. A 3.1 - // document instead uses the 3.1 OAS dialect schema id: - // https://spec.openapis.org/oas/v3.1.2.html#dialect-schema-id - it.each([ - ['rewrites the dated 3.2 OAS dialect', 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17', 'https://spec.openapis.org/oas/3.1/dialect/base'], - ['rewrites a draft 3.2 OAS dialect', 'https://spec.openapis.org/oas/3.2/dialect/WORK-IN-PROGRESS', 'https://spec.openapis.org/oas/3.1/dialect/base'], - ['keeps a 3.1 OAS dialect', 'https://spec.openapis.org/oas/3.1/dialect/base', 'https://spec.openapis.org/oas/3.1/dialect/base'], - ['keeps a custom dialect', 'https://example.com/my-dialect', 'https://example.com/my-dialect'], - ['clones a malformed value through', { junk: true }, { junk: true }], - ])('%s', (_name, dialect, expected) => { - expect(convertSpec({ jsonSchemaDialect: dialect })).toEqual({ jsonSchemaDialect: expected, openapi: '3.1.2' }) - }) -}) - -describe('document shape', () => { - it('returns non-object input unchanged', () => { - expect(downgradeSpecV32ToV31(null as any)).toBeNull() - expect(downgradeSpecV32ToV31('junk' as any)).toBe('junk') - expect(downgradeSpecV32ToV31([1, 2] as any)).toEqual([1, 2]) - }) - - it('keeps extensions and unknown keys at the document, path item, and operation levels', () => { - const fields = { - 'futureKey': { anything: [1] }, - 'info': { title: 't', version: '1' }, - 'jsonSchemaDialect': 'https://spec.openapis.org/oas/3.1/dialect/base', - 'paths': { - '/a': { - 'get': { 'operationId': 'getA', 'responses': {}, 'unknownOperationKey': 1, 'x-op': true }, - 'unknownPathItemKey': 'kept', - 'x-item': [1, 2], - }, - }, - 'security': [{ oauth: ['read'] }], - 'x-root': { deep: { value: 1 } }, - } - expect(convertSpec(fields)).toEqual({ ...fields, openapi: '3.1.2' }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts deleted file mode 100644 index 416a4c7..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/examples.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { convertComponent } from './helpers' - -// 3.2 splits an example value into `dataValue` (the data, as the schema sees -// it) and `serializedValue` (the bytes on the wire): -// https://spec.openapis.org/oas/v3.2.0.html#example-data-value -// https://spec.openapis.org/oas/v3.2.0.html#example-serialized-value -// 3.1 has only `value` and `externalValue`, which are mutually exclusive: -// https://spec.openapis.org/oas/v3.1.2.html#example-value -// The data form wins the free `value` slot because it is what 3.1 `value` -// holds for JSON-like media types. When `value` or `externalValue` is -// already set, the new fields are simply removed. -describe('dataValue and serializedValue', () => { - 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' }], - ['lets dataValue win the value slot over serializedValue', { dataValue: 1, serializedValue: 's' }, { value: 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 both when externalValue exists', - { dataValue: 1, externalValue: 'https://example.com/e.json', serializedValue: 's' }, - { externalValue: 'https://example.com/e.json' }, - ], - ['keeps the other example fields', { dataValue: 1, description: 'd', summary: 's' }, { description: 'd', summary: 's', value: 1 }], - ['leaves an example without value fields unchanged', { summary: 's' }, { summary: 's' }], - ['clones a malformed example through', 42, 42], - ])('%s', (_name, example, expected) => { - expect(convertComponent('examples', example)).toEqual(expected) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts deleted file mode 100644 index bafb6e4..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/helpers.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' - -import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' - -import { dig } from '../../helpers' - -/** - * Converts a 3.2 document built from `fields`. The input is typed loosely on - * purpose: many tests feed partial or malformed documents to check that the - * conversion tolerates them. - */ -export function convertSpec(fields: Record): OpenAPIV3_1.OpenAPIObject { - return downgradeSpecV32ToV31({ openapi: '3.2.0', ...fields } as any) -} - -/** Converts `pathItem` as the only entry of `paths` and returns it. */ -export function convertPathItem(pathItem: unknown, components?: Record): unknown { - return dig(convertSpec({ ...(components && { components }), paths: { '/a': pathItem } }), 'paths', '/a') -} - -/** Converts `value` as the entry `X` of the `kind` component map and returns it. */ -export function convertComponent(kind: string, value: unknown, components: Record = {}): unknown { - return dig(convertSpec({ components: { ...components, [kind]: { X: value } } }), 'components', kind, 'X') -} - -/** Converts `content` as the content map of a request body and returns it. */ -export function convertContent(content: unknown, components?: Record): unknown { - return dig( - convertPathItem({ post: { requestBody: { content }, responses: {} } }, components), - 'post', - 'requestBody', - 'content', - ) -} diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts deleted file mode 100644 index dd08a22..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/input-graph.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -// A document is usually parsed JSON or YAML, but it can also come from a -// dereferencing tool that turns every `$ref` into a shared JavaScript object, -// possibly with cycles. These tests pin down how the conversion treats the -// input as an object graph rather than as text. - -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' - -import { corpusV32 } from '../../corpus' -import { dig, withUndefinedKeys } from '../../helpers' -import { convertComponent, convertContent, convertPathItem, convertSpec } from './helpers' - -describe('the input document', () => { - it('is never mutated', () => { - const spec = { - $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: { - R: { $ref: '#/components/mediaTypes/B/itemSchema', description: 'r' }, - S: { discriminator: { defaultMapping: 'Dog' } }, - }, - securitySchemes: { O: { deprecated: true, type: 'oauth2' } }, - }, - openapi: '3.2.0', - paths: { - '/a': { - additionalOperations: { NOTIFY: { description: 'n' } }, - get: { - parameters: [{ in: 'querystring', name: 'q' }], - requestBody: { content: { 'application/json': { $ref: '#/components/mediaTypes/A' } } }, - responses: { 200: { summary: 'ok' } }, - }, - query: { description: 'q' }, - servers: [{ name: 's', url: '/u' }], - }, - }, - servers: [{ name: 'root', url: 'https://example.com' }], - tags: [{ kind: 'nav', name: 't', parent: 'p', summary: 's' }], - webhooks: { hook: { query: { description: 'wq' } } }, - } - const before = structuredClone(spec) - downgradeSpecV32ToV31(spec as any) - expect(spec).toEqual(before) - }) - - it('returns a fresh copy on every call', () => { - const spec: OpenAPIV3_2.OpenAPIObject = { info: { title: 't', version: '1' }, openapi: '3.2.0', paths: {} } - const first = downgradeSpecV32ToV31(spec) - const second = downgradeSpecV32ToV31(spec) - expect(first).toEqual(second) - expect(first).not.toBe(second) - expect(first.info).not.toBe(spec.info) - }) - - // Some parsers build objects without a prototype so that keys such as - // `__proto__` or `constructor` cannot collide with Object.prototype. - it('accepts objects with a null prototype, as some parsers produce', () => { - const response = Object.assign(Object.create(null), { summary: 'ok' }) - const operation = Object.assign(Object.create(null), { responses: { 200: response } }) - expect(convertPathItem({ get: operation, query: {} })).toEqual({ - get: { responses: { 200: { description: 'ok' } } }, - }) - }) - - // Values such as a Date or a Map cannot come from JSON or YAML. They are - // not walked into, and are kept as the same instance. - it('keeps values that are not plain objects or arrays by reference', () => { - const date = new Date(0) - const values = new Map([['a', 1]]) - const result = convertSpec({ 'x-date': date, 'x-values': values }) - expect(dig(result, 'x-date')).toBe(date) - expect(dig(result, 'x-values')).toBe(values) - }) -}) - -describe('keys', () => { - it('keeps the key order of the input', () => { - const result = convertSpec({ 'zebra': 1, 'x-apple': 2, 'info': { version: '1', title: 't' }, 'paths': {} }) - expect(Object.keys(result)).toEqual(['openapi', 'zebra', 'x-apple', 'info', 'paths']) - expect(Object.keys(result.info)).toEqual(['version', 'title']) - }) - - // JSON.parse creates a real own `__proto__` key. Assigning it with `=` - // would instead replace the prototype of the output object. - it('copies a __proto__ key as a plain own property without polluting prototypes', () => { - const spec = JSON.parse('{"openapi":"3.2.0","x-data":{"__proto__":{"polluted":true}},"components":{"schemas":{"__proto__":{"xml":{"nodeType":"attribute"}}}}}') - const result = downgradeSpecV32ToV31(spec) - const data = dig(result, 'x-data') as object - const schemas = dig(result, 'components', 'schemas') as object - expect(Object.getPrototypeOf(data)).toBe(Object.prototype) - expect(Object.getOwnPropertyDescriptor(data, '__proto__')?.value).toEqual({ polluted: true }) - expect(Object.getOwnPropertyDescriptor(schemas, '__proto__')?.value).toEqual({ xml: { attribute: true } }) - expect('polluted' in {}).toBe(false) - }) - - it('treats keys named like Object.prototype members as ordinary keys', () => { - const spec = JSON.parse('{"openapi":"3.2.0","constructor":1,"toString":2,"components":{"hasOwnProperty":{"a":1}}}') - const result = downgradeSpecV32ToV31(spec) - expect(Object.getOwnPropertyDescriptor(result, 'constructor')?.value).toBe(1) - expect(Object.getOwnPropertyDescriptor(result, 'toString')?.value).toBe(2) - expect(dig(result, 'components', 'hasOwnProperty')).toEqual({ a: 1 }) - }) -}) - -// Builders that spread options often leave a key holding `undefined`. JSON -// drops such a key, so the conversion treats it as missing, and the output -// never holds one. -describe('keys holding undefined', () => { - it.each(corpusV32)('converts %s as if the undefined keys were missing', (_name, doc) => { - const sprinkled = withUndefinedKeys(doc) as OpenAPIV3_2.OpenAPIObject - expect(downgradeSpecV32ToV31(sprinkled)).toStrictEqual(downgradeSpecV32ToV31(doc)) - }) - - it.each([ - ['fills an undefined response description from the summary', 'responses', { description: undefined, summary: 'OK' }, { description: 'OK' }], - ['moves dataValue into an undefined value', 'examples', { dataValue: { a: 1 }, value: undefined }, { value: { a: 1 } }], - ['moves serializedValue in when the other value fields are undefined', 'examples', { dataValue: undefined, externalValue: undefined, serializedValue: 's' }, { value: 's' }], - ['keeps allowReserved when in is undefined', 'parameters', { allowReserved: true, in: undefined, name: 'p' }, { allowReserved: true, name: 'p' }], - ['keeps a parameter whose content holds only undefined', 'parameters', { content: { 'application/json': undefined }, in: 'query', name: 'p' }, { content: {}, in: 'query', name: 'p' }], - ])('%s', (_name, kind, value, expected) => { - expect(convertComponent(kind, value)).toStrictEqual(expected) - }) - - it.each([ - ['adds no schema for an undefined itemSchema', { itemSchema: undefined }, {}], - ['turns itemSchema into an array schema when schema is undefined', { itemSchema: { type: 'string' }, schema: undefined }, { schema: { items: { type: 'string' }, type: 'array' } }], - ])('%s', (_name, mediaType, expected) => { - expect(convertContent({ 'application/jsonl': mediaType })).toStrictEqual({ 'application/jsonl': expected }) - }) - - it('inlines a dangling reference whose siblings are all undefined as a bare one', () => { - const mediaTypes = { B: { itemSchema: { type: 'string' } } } - const schema = { $ref: '#/components/mediaTypes/B/itemSchema', description: undefined } - expect(convertComponent('schemas', schema, { mediaTypes })).toStrictEqual({ type: 'string' }) - }) -}) - -describe('shared objects and cycles', () => { - it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { - const callback: Record = {} - const pathItem: Record = { - get: { callbacks: { cb: callback }, responses: { 200: { summary: 'ok' } } }, - query: { description: 'q' }, - } - callback.expr = pathItem - const result = convertPathItem(pathItem) - expect(result).not.toHaveProperty('query') - expect(dig(result, 'get', 'responses', '200')).toEqual({ description: 'ok' }) - expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) - expect(callback.expr).toBe(pathItem) - }) - - it('copies a dereferenced schema shared across the document once', () => { - const pet = { $anchor: 'pet', $id: 'https://example.com/pet', properties: { name: { type: 'string' } }, type: 'object' } - const result = convertSpec({ - components: { schemas: { Pet: pet } }, - paths: { '/pets': { get: { responses: { 200: { content: { 'application/json': { schema: pet } }, description: 'ok' } } } } }, - }) - const schema = dig(result, 'components', 'schemas', 'Pet') - expect(schema).toEqual(pet) - expect(schema).not.toBe(pet) - expect(dig(result, 'paths', '/pets', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toBe(schema) - }) - - // A diamond (two properties sharing one subschema) doubles the number of - // paths per level: 2^40 here. Converting each shared object once keeps the - // work linear. - it('converts a deep shared schema diamond once, even when references force a second pass', () => { - let schema: Record = { type: 'string' } - for (let depth = 0; depth < 40; depth++) { - schema = { properties: { a: schema, b: schema }, type: 'object' } - } - const result = convertSpec({ - components: { - mediaTypes: { Gone: { schema: {} } }, - schemas: { Dangling: { $ref: '#/components/mediaTypes/Gone/schema' }, Root: schema }, - }, - }) - const root = dig(result, 'components', 'schemas', 'Root') - expect(dig(root, 'properties', 'a')).toBe(dig(root, 'properties', 'b')) - }) - - it('keeps cycles and sharing inside values it only copies', () => { - const node: Record = { name: 'root' } - node.self = node - const list: unknown[] = [1] - list.push(list) - const result = convertSpec({ 'x-list': list, 'x-node': node, 'x-same': node }) - expect(dig(result, 'x-node', 'self')).toBe(dig(result, 'x-node')) - expect(dig(result, 'x-same')).toBe(dig(result, 'x-node')) - expect(dig(result, 'x-list', '1')).toBe(dig(result, 'x-list')) - expect(dig(result, 'x-node')).not.toBe(node) - }) - - // `#/components/mediaTypes/%50et` and `#/components/mediaTypes/Pet` name - // the same target once percent-decoded, so they share one converted copy. - it('converts a target inlined from several places once, however its pointer is spelled', () => { - const content = dig(convertPathItem( - { - get: { - responses: { - 200: { content: { 'a/b': { $ref: '#/components/mediaTypes/Pet' } }, description: 'ok' }, - 201: { content: { 'a/b': { $ref: '#/components/mediaTypes/%50et' } }, description: 'ok' }, - }, - }, - }, - { mediaTypes: { Pet: { schema: { type: 'string' } } } }, - ), 'get', 'responses') - expect(dig(content, '200', 'content', 'a/b')).toEqual({ schema: { type: 'string' } }) - expect(dig(content, '201', 'content', 'a/b')).toBe(dig(content, '200', 'content', 'a/b')) - }) -}) - -describe('long reference chains', () => { - // Chains are followed with loops rather than recursion, so their length is - // not bounded by the call stack. - it('removes thousands of aliases chained to a removed parameter', () => { - const parameters: Record = { p0: { in: 'querystring', name: 'q' } } - for (let index = 1; index <= 5000; index++) { - parameters[`p${index}`] = { $ref: `#/components/parameters/p${index - 1}` } - } - const result = convertSpec({ - components: { parameters }, - paths: { '/a': { get: { parameters: [{ $ref: '#/components/parameters/p5000' }], responses: {} } } }, - }) - expect(result.components).toEqual({ parameters: {} }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([]) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts deleted file mode 100644 index 1be3891..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/media-types.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { dig } from '../../helpers' -import { convertContent } from './helpers' - -describe('itemSchema', () => { - // 3.2 adds `itemSchema` to describe each item of a sequential media type - // such as `application/jsonl` or `text/event-stream`: - // https://spec.openapis.org/oas/v3.2.0.html#media-type-item-schema - // https://spec.openapis.org/oas/v3.2.0.html#sequential-media-types - // 3.1 can only describe the complete content, and the closest description - // of a sequence of items is an array of them. - 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: {} }, type: 'array' } }, - }) - const promoted = dig(result, 'application/jsonl', 'schema', 'items') - expect(promoted).not.toBe(itemSchema) - expect(dig(promoted, 'xml')).not.toBe(itemSchema.xml) - }) - - it('removes itemSchema when a schema already describes the complete content', () => { - expect(convertContent({ 'application/json': { itemSchema: { type: 'string' }, schema: { type: 'array' } } })).toEqual({ - 'application/json': { schema: { type: 'array' } }, - }) - }) -}) - -describe('media type fields 3.1 lacks', () => { - // `prefixEncoding` and `itemEncoding` encode multipart parts by position: - // https://spec.openapis.org/oas/v3.2.0.html#encoding-by-position - // 3.1 only encodes parts by property name, through `encoding`. - // - // `description` is missing from the 3.2.0 Fixed Fields table but is defined - // by the official 3.2 JSON Schema (`$defs/media-type`): https://github.com/OAI/OpenAPI-Specification/pull/4728 - it.each([ - ['removes prefixEncoding and itemEncoding', { example: 1, itemEncoding: { contentType: 'text/plain' }, prefixEncoding: [{ contentType: 'application/json' }] }, { example: 1 }], - ['removes description and keeps the other fields', { description: 'a JSON payload', example: 5, schema: { type: 'integer' } }, { example: 5, schema: { type: 'integer' } }], - ])('%s', (_name, mediaType, expected) => { - expect(convertContent({ 'application/json': mediaType })).toEqual({ 'application/json': expected }) - }) - - it('converts the example map', () => { - expect(convertContent({ - 'application/json': { examples: { inline: { serializedValue: 'raw' }, referenced: { $ref: '#/components/examples/E' } } }, - })).toEqual({ - 'application/json': { examples: { inline: { value: 'raw' }, referenced: { $ref: '#/components/examples/E' } } }, - }) - }) -}) - -describe('encoding objects', () => { - // 3.2 Encoding Objects can nest `encoding`, `prefixEncoding`, and - // `itemEncoding` for multipart parts that are themselves multipart: - // https://spec.openapis.org/oas/v3.2.0.html#nested-encoding - it('removes nested and positional encodings while still converting headers', () => { - expect(convertContent({ - 'multipart/form-data': { - encoding: { - part: { - contentType: 'application/json', - encoding: { inner: { headers: { 'X-C': { style: 'cookie' } } } }, - headers: { - 'Referenced': { $ref: '#/components/headers/H' }, - 'X-H': { description: 'h', style: 'cookie' }, - }, - itemEncoding: { contentType: 'text/plain' }, - prefixEncoding: [{ contentType: 'text/csv' }], - }, - }, - }, - })).toEqual({ - 'multipart/form-data': { - encoding: { - part: { - contentType: 'application/json', - headers: { - 'Referenced': { $ref: '#/components/headers/H' }, - 'X-H': { description: 'h' }, - }, - }, - }, - }, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts deleted file mode 100644 index 163daf7..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/parameters.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { dig } from '../../helpers' -import { convertComponent, convertPathItem, convertSpec } from './helpers' - -describe('in: querystring', () => { - // 3.2 adds `in: "querystring"` to describe the whole query string with one - // `content` schema: https://spec.openapis.org/oas/v3.2.0.html#parameter-in - // 3.1 has no such location, and no query parameter can stand in for it, - // so the parameter is removed together with every reference to it. - it('removes querystring parameters from operation and path item lists, keeping neighbors and references', () => { - expect(convertPathItem({ - get: { - parameters: [ - { content: { 'application/x-www-form-urlencoded': {} }, in: 'querystring', name: 'q' }, - { in: 'query', name: 'keep' }, - { $ref: '#/components/parameters/P' }, - ], - responses: {}, - }, - parameters: [ - { in: 'querystring', name: 'q' }, - { in: 'path', name: 'id', required: true }, - ], - })).toEqual({ - get: { - parameters: [{ in: 'query', name: 'keep' }, { $ref: '#/components/parameters/P' }], - responses: {}, - }, - parameters: [{ in: 'path', name: 'id', required: true }], - }) - }) - - it('removes querystring entries from components.parameters, keeping neighbors and references', () => { - 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' }, - }, - }) - }) - - // A Reference Object to a removed parameter would dangle, so it goes too. - // So does a chain of aliases (`$ref` to a `$ref`) that ends at one. - it('removes references to removed querystring parameters, following alias chains', () => { - const result = convertSpec({ - components: { - parameters: { - Alias: { $ref: '#/components/parameters/Qs' }, - AliasOfAlias: { $ref: '#/components/parameters/Alias' }, - Keep: { in: 'query', name: 'k', schema: {} }, - Qs: { content: { 'application/x-www-form-urlencoded': { schema: {} } }, in: 'querystring', name: 'filter' }, - }, - }, - paths: { - '/a': { - get: { - parameters: [ - { $ref: '#/components/parameters/AliasOfAlias' }, - { $ref: '#/components/parameters/Qs' }, - { $ref: '#/components/parameters/Keep' }, - ], - responses: {}, - }, - parameters: [{ $ref: '#/components/parameters/Qs' }], - }, - }, - }) - expect(result.components).toEqual({ parameters: { Keep: { in: 'query', name: 'k', schema: {} } } }) - expect(result.paths).toEqual({ - '/a': { - get: { parameters: [{ $ref: '#/components/parameters/Keep' }], responses: {} }, - parameters: [], - }, - }) - }) - - it('removes a reference to a querystring parameter through any pointer', () => { - expect(convertSpec({ - components: { parameters: { P: { $ref: '#/paths/~1a/get/parameters/0' } } }, - paths: { '/a': { get: { parameters: [{ in: 'querystring', name: 'qs' }], responses: {} } } }, - }).components).toEqual({ parameters: {} }) - }) -}) - -describe('style and allowReserved', () => { - // `style: "cookie"` is new in 3.2: https://spec.openapis.org/oas/v3.2.0.html#style-values - // Without it, a cookie parameter falls back to the 3.1 default for cookies, - // `form`: https://spec.openapis.org/oas/v3.1.2.html#parameter-style - // - // 3.1 defines `allowReserved` for query parameters only - // (https://spec.openapis.org/oas/v3.1.2.html#parameter-allow-reserved), - // while 3.2 extends it to every location that percent-encodes - // (https://spec.openapis.org/oas/v3.2.0.html#parameter-allow-reserved). - 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 when there is no in to judge by', { allowReserved: true, schema: {} }, { allowReserved: true, schema: {} }], - ])('%s', (_name, input, expected) => { - expect(convertComponent('parameters', input)).toEqual(expected) - }) - - it('removes style: cookie from headers wherever they appear', () => { - expect(convertComponent('responses', { - description: 'ok', - headers: { 'X-H': { description: 'h', style: 'cookie' } }, - })).toEqual({ description: 'ok', headers: { 'X-H': { description: 'h' } } }) - }) -}) - -describe('schemas and examples', () => { - it('converts the parameter schema and its example map', () => { - expect(convertComponent('parameters', { - examples: { inline: { dataValue: 1 }, referenced: { $ref: '#/components/examples/E' } }, - in: 'query', - name: 'q', - schema: { type: 'string', xml: { nodeType: 'attribute' } }, - })).toEqual({ - examples: { inline: { value: 1 }, referenced: { $ref: '#/components/examples/E' } }, - in: 'query', - name: 'q', - schema: { type: 'string', xml: { attribute: true } }, - }) - }) - - // 3.2 lists `example` and `examples` among the fields that MAY be used with - // either `schema` or `content`: https://spec.openapis.org/oas/v3.2.0.html#parameter-example - // 3.1 lists them only among the fields for use with `schema` - // (https://spec.openapis.org/oas/v3.1.2.html#parameter-example), and its - // official JSON Schema rejects them beside `content`. The media type inside - // `content` can carry its own examples instead. - it('removes parameter and header examples beside content', () => { - const content = { 'a/b': { schema: { type: 'object' } } } - const operation = dig(convertPathItem({ - get: { - parameters: [ - { content, example: { a: 1 }, in: 'query', name: 'moved' }, - { content: { 'a/b': { example: 'own' } }, examples: { e: { dataValue: 1 } }, in: 'query', name: 'kept' }, - { content: { 'a/b': {}, 'c/d': {} }, example: 1, in: 'query', name: 'many' }, - { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, - ], - responses: { 200: { description: 'ok', headers: { X: { content, examples: { e: { dataValue: 2 } } } } } }, - }, - }), 'get') - expect(dig(operation, 'parameters')).toEqual([ - { content, in: 'query', name: 'moved' }, - { content: { 'a/b': { example: 'own' } }, in: 'query', name: 'kept' }, - { content: { 'a/b': {}, 'c/d': {} }, in: 'query', name: 'many' }, - { example: 1, in: 'query', name: 'plain', schema: { type: 'integer' } }, - ]) - expect(dig(operation, 'responses', '200', 'headers', 'X')).toEqual({ content }) - }) - - it('keeps a parameter whose content map was already empty', () => { - const parameter = { content: {}, in: 'query', name: 'q' } - expect(dig(convertPathItem({ get: { parameters: [parameter] } }), 'get', 'parameters')).toEqual([parameter]) - }) -}) - -describe('malformed input', () => { - 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' }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts deleted file mode 100644 index ce4a9e6..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/path-items.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { convertComponent, convertPathItem, convertSpec } from './helpers' - -describe('operations 3.1 cannot hold', () => { - // 3.2 adds the QUERY method and `additionalOperations` for any other method: - // https://spec.openapis.org/oas/v3.2.0.html#path-item-query - // https://spec.openapis.org/oas/v3.2.0.html#path-item-additional-operations - // A 3.1 Path Item only has fixed fields for the eight classic methods, and - // an extension would not describe a callable operation either, so both are - // removed rather than moved. - it('removes the query operation and additionalOperations whatever their shape', () => { - expect(convertSpec({ - paths: { - '/a': { get: { responses: {} }, query: { description: 'q', responses: {} } }, - '/b': { additionalOperations: { NOTIFY: { description: 'n' } } }, - '/c': { additionalOperations: 'junk' }, - '/d': { additionalOperations: 42, query: 'junk' }, - }, - }).paths).toEqual({ - '/a': { get: { responses: {} } }, - '/b': {}, - '/c': {}, - '/d': {}, - }) - }) - - it('removes them from webhooks and components.pathItems too', () => { - expect(convertSpec({ - components: { pathItems: { P: { get: { responses: {} }, query: { description: 'q' } } } }, - webhooks: { newPet: { post: { responses: { 200: { summary: 'ok' } } }, query: { description: 'q' } } }, - })).toEqual({ - components: { pathItems: { P: { get: { responses: {} } } } }, - openapi: '3.1.2', - webhooks: { newPet: { post: { responses: { 200: { description: 'ok' } } } } }, - }) - }) - - it('removes them from path items inside callbacks', () => { - expect(convertComponent('callbacks', { - 'https://example.com/cb': { post: { responses: { 200: { summary: 'ok' } } }, query: { description: 'q' } }, - })).toEqual({ - 'https://example.com/cb': { post: { responses: { 200: { description: 'ok' } } } }, - }) - }) -}) - -describe('paths', () => { - // Paths Object keys are templates that start with a slash; any other key - // can only be a specification extension: https://spec.openapis.org/oas/v3.2.0.html#paths-object - it('converts only keys starting with a slash and clones the rest', () => { - expect(convertSpec({ - paths: { - '/a': { query: { description: 'dropped' } }, - 'x-meta': { query: { description: 'kept' } }, - }, - }).paths).toEqual({ '/a': {}, 'x-meta': { query: { description: 'kept' } } }) - }) - - it('clones malformed paths, path items, and nested objects through', () => { - expect(convertSpec({ paths: 'junk' }).paths).toBe('junk') - const paths = { - '/a': { - get: 'junk', - post: { - requestBody: { - content: { - 'application/json': 42, - 'multipart/form-data': { encoding: { field: 'junk' }, example: 5 }, - }, - }, - }, - put: { requestBody: 42, responses: { 200: 42 } }, - }, - } - expect(convertSpec({ paths }).paths).toEqual(paths) - }) -}) - -describe('callbacks', () => { - // Callback Object keys are runtime expressions; only `x-` keys are - // extensions: https://spec.openapis.org/oas/v3.2.0.html#callback-object - it('converts the path items of operation callbacks and clones x- keys and references', () => { - expect(convertPathItem({ - post: { - callbacks: { - onEvent: { - 'x-note': { query: { description: 'kept' } }, - '{$request.body#/url}': { post: { responses: { 200: { summary: 'ok' } } }, query: { description: 'q' } }, - }, - referenced: { $ref: '#/components/callbacks/C' }, - }, - responses: {}, - }, - })).toEqual({ - post: { - callbacks: { - onEvent: { - 'x-note': { query: { description: 'kept' } }, - '{$request.body#/url}': { post: { responses: { 200: { description: 'ok' } } } }, - }, - referenced: { $ref: '#/components/callbacks/C' }, - }, - responses: {}, - }, - }) - }) - - it('converts components.callbacks, keeping references to surviving callbacks', () => { - expect(convertSpec({ - components: { - callbacks: { - inline: { 'https://example.com/cb': { post: { responses: { 200: { summary: 'ok' } } } } }, - referenced: { $ref: '#/components/callbacks/inline' }, - }, - }, - }).components).toEqual({ - callbacks: { - inline: { 'https://example.com/cb': { post: { responses: { 200: { description: 'ok' } } } } }, - referenced: { $ref: '#/components/callbacks/inline' }, - }, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts deleted file mode 100644 index e70eb1b..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/removed-parts.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -// Removing or moving a part of the document would leave every local `$ref` -// into it dangling. Instead, such a reference is replaced by a converted copy -// of its target (inlined). The removed or moved parts in 3.2 → 3.1 are: -// - `components.mediaTypes`, the `query` operation, and `additionalOperations` -// - `itemSchema`, which moves into `schema.items` -// - parameter lists that lost `querystring` entries, since the indices of -// the entries after a removed one shift - -import { cyclicCallbackGraph, dig } from '../../helpers' -import { expectValidAs } from '../../validate' -import { convertComponent, convertPathItem, convertSpec } from './helpers' - -const petRef = { $ref: '#/components/mediaTypes/Pet/schema' } -const pet = { type: 'object', xml: { nodeType: 'element' } } -const convertedPet = { type: 'object', xml: {} } - -describe('schema references', () => { - it('inlines schema $refs at every subschema position', () => { - const everyPosition = (schema: unknown) => ({ - $defs: { d: schema }, - additionalProperties: schema, - allOf: [schema], - anyOf: [schema], - contains: schema, - contentSchema: schema, - dependentSchemas: { d: schema }, - else: schema, - if: schema, - items: schema, - not: schema, - oneOf: [schema], - patternProperties: { '^x': schema }, - prefixItems: [schema], - properties: { p: schema }, - propertyNames: schema, - then: schema, - unevaluatedItems: schema, - unevaluatedProperties: schema, - }) - expect(convertComponent('schemas', everyPosition(petRef), { mediaTypes: { Pet: { schema: pet } } })).toEqual(everyPosition(convertedPet)) - }) - - it('inlines schema $refs in parameter, header, media type, and itemSchema positions', () => { - expect(convertSpec({ - components: { - headers: { H: { schema: petRef } }, - mediaTypes: { Pet: { schema: pet } }, - parameters: { P: { in: 'query', name: 'p', schema: petRef } }, - requestBodies: { - B: { content: { 'application/json': { schema: petRef }, 'application/jsonl': { itemSchema: petRef } } }, - }, - }, - }).components).toEqual({ - headers: { H: { schema: convertedPet } }, - parameters: { P: { in: 'query', name: 'p', schema: convertedPet } }, - requestBodies: { - B: { - content: { - 'application/json': { schema: convertedPet }, - 'application/jsonl': { schema: { items: convertedPet, type: 'array' } }, - }, - }, - }, - }) - }) - - // `const`, `default`, `enum`, and `examples` hold instance data, not - // schemas, so an object there that looks like a reference is just a value. - // Only values under schema keywords are schemas: - // https://json-schema.org/draft/2020-12/json-schema-core#section-9.4.2 - it('keeps data keywords, extensions, and non-string $ref values verbatim', () => { - const schema = { - 'const': petRef, - 'default': petRef, - 'enum': [petRef], - 'examples': [petRef], - 'properties': { p: { $ref: 42 } }, - 'x-data': petRef, - } - expect(convertSpec({ - components: { headers: { H: { schema: petRef } }, mediaTypes: { Pet: { schema: pet } }, schemas: { S: schema } }, - }).components).toEqual({ headers: { H: { schema: convertedPet } }, schemas: { S: schema } }) - }) - - // In JSON Schema 2020-12, `$ref` applies its target alongside the sibling - // keywords, exactly like one more `allOf` entry: - // https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1 - // So the inlined target joins `allOf` instead of being merged key by key, - // which could let one side's keyword overwrite the other's. - it.each([ - ['adds allOf beside sibling annotations', { $ref: petRef.$ref, description: 'd' }, { allOf: [convertedPet], description: 'd' }], - ['appends to an existing allOf, keeping its indices', { $ref: petRef.$ref, allOf: [{ required: ['a'] }] }, { allOf: [{ required: ['a'] }, convertedPet] }], - ['nests a malformed allOf instead of discarding it', { $ref: petRef.$ref, allOf: 'junk' }, { allOf: [{ allOf: 'junk' }, convertedPet] }], - ])('merges a $ref with its siblings: %s', (_name, schema, expected) => { - expect(convertComponent('schemas', schema, { mediaTypes: { Pet: { schema: pet } } })).toEqual(expected) - }) - - // Boolean schemas are valid 3.1 schemas: https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 - it('inlines a boolean target schema as is', () => { - expect(convertComponent('schemas', { $ref: '#/components/mediaTypes/None/schema' }, { mediaTypes: { None: { schema: false } } })).toBe(false) - }) - - it('inlines pointers to an itemSchema that the conversion moves or removes', () => { - expect(convertSpec({ - components: { - requestBodies: { - B: { - content: { - 'application/json': { itemSchema: { type: 'number' }, schema: { type: 'array' } }, - 'application/jsonl': { itemSchema: { type: 'string' } }, - }, - }, - }, - schemas: { - Moved: { $ref: '#/components/requestBodies/B/content/application~1jsonl/itemSchema' }, - Removed: { $ref: '#/components/requestBodies/B/content/application~1json/itemSchema' }, - }, - }, - }).components?.schemas).toEqual({ Moved: { type: 'string' }, Removed: { type: 'number' } }) - }) - - // A schema that is only `{ $ref }` (an alias) adds nothing of its own, so - // inlining follows it to its target. An alias with siblings is a schema in - // its own right: it is converted and inlined itself, keeping the siblings. - it('follows schema alias chains through removed parts, stopping at an alias with siblings', () => { - expect(dig(convertSpec({ - components: { - mediaTypes: { - A: { schema: { $ref: '#/components/mediaTypes/B/schema' } }, - B: { schema: { $ref: '#/components/mediaTypes/C/schema', description: 'b' } }, - C: { schema: { type: 'string' } }, - }, - schemas: { S: { $ref: '#/components/mediaTypes/A/schema' } }, - }, - }), 'components', 'schemas', 'S')).toEqual({ allOf: [{ type: 'string' }], description: 'b' }) - }) - - // Pointer fragments are percent-decoded (https://www.rfc-editor.org/rfc/rfc3986#section-2.1) - // before `~1` and `~0` are unescaped (https://www.rfc-editor.org/rfc/rfc6901#section-4). - it('decodes escaped and percent-encoded pointer tokens', () => { - expect(convertSpec({ - components: { - mediaTypes: { - 'a/b~c': { schema: { type: 'string' } }, - 'My Type': { schema: { type: 'number' } }, - }, - schemas: { - Escaped: { $ref: '#/components/mediaTypes/a~1b~0c/schema' }, - Percent: { $ref: '#/components/mediaTypes/My%20Type/schema' }, - Templated: { $ref: '#/paths/~1pets~1%7Bid%7D/query/requestBody/content/application~1json/schema' }, - }, - }, - paths: { - '/pets/{id}': { query: { requestBody: { content: { 'application/json': { schema: { type: 'integer' } } } } } }, - }, - }).components).toEqual({ - schemas: { Escaped: { type: 'string' }, Percent: { type: 'number' }, Templated: { type: 'integer' } }, - }) - }) -}) - -describe('reference Objects', () => { - it('inlines references into query, additionalOperations, and components.mediaTypes, converting each target for its position', () => { - const result = convertSpec({ - components: { - callbacks: { C: { $ref: '#/paths/~1search/query/callbacks/onDone' } }, - examples: { E: { $ref: '#/components/mediaTypes/Pet/examples/e' } }, - headers: { H: { $ref: '#/components/mediaTypes/Pet/encoding/file/headers/X-Rate' } }, - links: { L: { $ref: '#/paths/~1search/query/responses/200/links/next' } }, - mediaTypes: { - Pet: { - encoding: { file: { headers: { 'X-Rate': { examples: { a: { serializedValue: '1' } } } } } }, - examples: { e: { dataValue: 1 } }, - }, - }, - }, - paths: { - '/search': { - additionalOperations: { COPY: { responses: { 201: { summary: 'Copied' } } } }, - post: { - parameters: [{ $ref: '#/paths/~1search/query/parameters/0' }], - requestBody: { $ref: '#/paths/~1search/query/requestBody' }, - responses: { - 200: { $ref: '#/paths/~1search/query/responses/200' }, - 201: { $ref: '#/paths/~1search/additionalOperations/COPY/responses/201' }, - }, - }, - query: { - callbacks: { onDone: { '{$request.body#/url}': { post: { responses: { 200: { summary: 'ack' } } } } } }, - parameters: [{ in: 'cookie', name: 'c', style: 'cookie' }], - requestBody: { content: { 'application/jsonl': { itemSchema: { type: 'string' } } } }, - responses: { 200: { links: { next: { operationId: 'x', server: { name: 'n', url: '/' } } }, summary: 'Found' } }, - }, - }, - }, - }) - expect(result.components).toEqual({ - callbacks: { C: { '{$request.body#/url}': { post: { responses: { 200: { description: 'ack' } } } } } }, - examples: { E: { value: 1 } }, - headers: { H: { examples: { a: { value: '1' } } } }, - links: { L: { operationId: 'x', server: { url: '/' } } }, - }) - expect(result.paths).toEqual({ - '/search': { - post: { - parameters: [{ in: 'cookie', name: 'c' }], - requestBody: { content: { 'application/jsonl': { schema: { items: { type: 'string' }, type: 'array' } } } }, - responses: { - 200: { description: 'Found', links: { next: { operationId: 'x', server: { url: '/' } } } }, - 201: { description: 'Copied' }, - }, - }, - }, - }) - }) - - it('follows chains through removed parts and keeps the reference where a chain reaches a surviving part', () => { - const result = convertSpec({ - components: { - responses: { - Deep: { $ref: '#/paths/~1a/query/responses/200' }, - Kept: { $ref: '#/paths/~1a/query/responses/201' }, - Real: { description: 'real' }, - }, - }, - paths: { - '/a': { query: { responses: { 200: { $ref: '#/paths/~1b/query/responses/200' }, 201: { $ref: '#/components/responses/Real' } } } }, - '/b': { query: { responses: { 200: { summary: 'deep' } } } }, - }, - }) - expect(result.components).toEqual({ - responses: { - Deep: { description: 'deep' }, - Kept: { $ref: '#/components/responses/Real' }, - Real: { description: 'real' }, - }, - }) - expect(result.paths).toEqual({ '/a': {}, '/b': {} }) - }) - - // Removing a `querystring` parameter shifts the indices of the entries - // after it, so `#/paths/~1a/get/parameters/2` would silently point at a - // different parameter, or at nothing. References into such a list are - // inlined, even though the list itself survives. References to entries - // that kept their index, and external references, stay as written. - it('inlines references into a parameter list that lost entries, since its indices shift', () => { - const result = convertSpec({ - components: { - parameters: { - External: { $ref: '#/paths/~1a/get/parameters/1' }, - Kept: { $ref: '#/paths/~1b/get/parameters/0' }, - Shifted: { $ref: '#/paths/~1a/get/parameters/2' }, - }, - }, - paths: { - '/a': { - get: { - parameters: [{ in: 'querystring', name: 'qs' }, { $ref: './parameters/limit.yaml' }, { in: 'query', name: 'b' }], - responses: {}, - }, - }, - '/b': { get: { parameters: [{ in: 'query', name: 'c' }], responses: {} } }, - }, - }) - expect(result.components).toEqual({ - parameters: { - External: { $ref: './parameters/limit.yaml' }, - Kept: { $ref: '#/paths/~1b/get/parameters/0' }, - Shifted: { in: 'query', name: 'b' }, - }, - }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([{ $ref: './parameters/limit.yaml' }, { in: 'query', name: 'b' }]) - }) -}) - -describe('path Item references', () => { - const pointer = (name: string): string => `#/paths/~1q/query/callbacks/cb/${name}` - const responses = { 200: { description: 'ok' } } - - // A Path Item `$ref` may sit beside the Path Item's own fields. The spec - // leaves a field on both sides undefined, but says `$ref` will move toward - // Reference Object behavior, where the referencing side's fields override - // the target's: https://spec.openapis.org/oas/v3.2.0.html#path-item-ref - // So when it is inlined, the own fields win. - it('inlines a path item $ref that points into a removed operation, keeping own fields', () => { - const callbacks = { c: { '{$url}': { description: 'inlined', summary: 'Inlined' } } } - expect(dig(convertSpec({ - components: { - pathItems: { - copy: { $ref: '#/paths/~1a/additionalOperations/COPY/callbacks/c/{$url}' }, - query: { $ref: '#/paths/~1a/query/callbacks/c/{$url}', summary: 'Own' }, - }, - }, - paths: { '/a': { additionalOperations: { COPY: { callbacks } }, query: { callbacks } } }, - }), 'components', 'pathItems')).toEqual({ - copy: { description: 'inlined', summary: 'Inlined' }, - query: { description: 'inlined', summary: 'Own' }, - }) - }) - - // Only pointers that actually land on a Path Item are merged as one. An - // extension inside a Callback Object is not a Path Item, so a `$ref` to it - // is left as written. - it('inlines a path item $ref into a removed operation of a callbacks component', () => { - expect(dig(convertSpec({ - components: { - callbacks: { - C: { '{$url}': { query: { callbacks: { d: { '{$v}': { description: 'inlined' } } } } }, 'x-cb': { query: {} } }, - }, - pathItems: { - P: { $ref: '#/components/callbacks/C/{$url}/query/callbacks/d/{$v}' }, - X: { $ref: '#/components/callbacks/C/x-cb' }, - }, - }, - }), 'components', 'pathItems')).toEqual({ - P: { description: 'inlined' }, - X: { $ref: '#/components/callbacks/C/x-cb' }, - }) - }) - - // The Path Items of the cyclic callback graph live in the callbacks of the - // removed `query` operation. Inlining each path through this graph - // separately would convert them about k! times, and cutting each copy only - // at the Path Items that enclose it would still take about 2^k copies. Past - // a budget, copies are shared even where they would come out differently. - it('converts a dense cyclic callback graph in a removed operation in linear work', async () => { - const reads = { count: 0 } - const k = 8 - const result = convertSpec({ - info: { title: 't', version: '1' }, - paths: { '/a': { $ref: pointer('h0') }, '/q': { query: { callbacks: { cb: cyclicCallbackGraph(k, pointer, reads) }, responses } } }, - }) - expect(reads.count).toBeLessThan(3 * k) - await expectValidAs(result, '3.1') - expect(dig(result, 'paths', '/q')).toEqual({}) - expect(dig(result, 'paths', '/a', 'post', 'callbacks', 'c0', '{$request.body#/url}')).toEqual({ get: { responses } }) - }) - - // Converting `/a` merges `B` as a later hop of `A`, so the own fields of `B` - // are converted while `A` is in progress, and their reference back to `A` - // cuts it. `/b` enters `B` where `A` is not in progress, so it does not - // reuse that merge, and its inner reference keeps the operation of `A`. - it.each([['/a', '/b'], ['/b', '/a']])('keeps a hop cut inside another hop\'s fields where it is not in progress, converting %s first', (...order) => { - const refs: Record = { '/a': { $ref: pointer('A') }, '/b': { $ref: pointer('B') } } - const pathItems = { - A: { $ref: pointer('B'), post: { operationId: 'aPost', responses } }, - B: { $ref: pointer('T'), get: { callbacks: { cb: { '{$url}': { $ref: pointer('A') } } }, responses } }, - T: { summary: 't' }, - } - const result = convertSpec({ - paths: { ...Object.fromEntries(order.map(path => [path, refs[path]])), '/q': { query: { callbacks: { cb: pathItems }, responses } } }, - }) - expect(dig(result, 'paths', '/b', 'get', 'callbacks', 'cb', '{$url}')).toMatchObject({ post: { operationId: 'aPost', responses }, summary: 't' }) - }) -}) - -describe('links and discriminator mappings', () => { - // A Link's `operationRef` and a discriminator `mapping` value are - // references too: https://spec.openapis.org/oas/v3.1.2.html#link-operation-ref - // https://spec.openapis.org/oas/v3.1.2.html#discriminator-mapping - // One that points into a removed part cannot be inlined (a Link needs an - // operation to point at), so it is removed. - it('removes links and discriminator mappings that point into removed parts', () => { - const result = convertSpec({ - components: { - links: { gone: { operationRef: '#/paths/~1a/query' }, kept: { operationRef: '#/paths/~1a/get' } }, - mediaTypes: { M: { schema: {} } }, - schemas: { - Pet: { - discriminator: { mapping: { cat: '#/components/schemas/Cat', item: '#/components/mediaTypes/M/schema' }, propertyName: 'kind' }, - }, - }, - }, - paths: { '/a': { get: {}, query: {} } }, - }) - expect(dig(result, 'components', 'links')).toEqual({ kept: { operationRef: '#/paths/~1a/get' } }) - expect(dig(result, 'components', 'schemas', 'Pet', 'discriminator')).toEqual({ mapping: { cat: '#/components/schemas/Cat' }, propertyName: 'kind' }) - }) -}) - -describe('references left as written', () => { - // A chain that loops never reaches an object to inline, and tools cannot - // resolve it either, so rewriting it would not fix anything. - it('leaves a Reference Object whose chain loops through removed parts as written', () => { - const result = convertSpec({ - components: { - responses: { Keep: { description: 'k' }, Loop: { $ref: '#/paths/~1a/query/responses/200' } }, - }, - paths: { - '/a': { query: { responses: { 200: { $ref: '#/paths/~1b/query/responses/200' } } } }, - '/b': { query: { responses: { 200: { $ref: '#/paths/~1a/query/responses/200' } } } }, - '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, - }, - }) - expect(result.components).toEqual({ - responses: { Keep: { description: 'k' }, Loop: { $ref: '#/paths/~1a/query/responses/200' } }, - }) - expect(result.paths).toEqual({ - '/a': {}, - '/b': {}, - '/c': { get: { responses: { 200: { $ref: '#/components/responses/Loop' } } } }, - }) - }) - - it('leaves references whose alias chain loops as written', () => { - const parameters = { - A: { $ref: '#/components/parameters/B' }, - B: { $ref: '#/components/parameters/A' }, - } - expect(convertSpec({ components: { parameters } }).components).toEqual({ parameters }) - }) - - // The looping entry stays in the list, so it keeps its index, and the - // reference to the entry after it still points at the right parameter. - it('leaves a looping entry in a parameter list, so later indices stay correct', () => { - const result = convertSpec({ - components: { parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }, - paths: { - '/a': { get: { parameters: [{ $ref: '#/paths/~1b/query/parameters/0' }, { in: 'query', name: 'b' }], responses: {} } }, - '/b': { query: { parameters: [{ $ref: '#/paths/~1c/query/parameters/0' }] } }, - '/c': { query: { parameters: [{ $ref: '#/paths/~1b/query/parameters/0' }] } }, - }, - }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([{ $ref: '#/paths/~1b/query/parameters/0' }, { in: 'query', name: 'b' }]) - expect(result.components).toEqual({ parameters: { P: { $ref: '#/paths/~1a/get/parameters/1' } } }) - }) - - // `#pet` names a plain-name `$anchor`, not a JSON Pointer - // (https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.2), - // and `#` points at the whole document, which survives the conversion. - it('leaves external, anchor, root, unparseable, and already dangling references untouched', () => { - const schemas = { - Anchor: { $ref: '#pet' }, - BadEscape: { $ref: '#/components/mediaTypes/%E0%A4%A' }, - External: { $ref: 'https://example.com/api.json#/components/mediaTypes/Pet/schema' }, - Missing: { $ref: '#/components/mediaTypes/Nope/schema' }, - Root: { $ref: '#' }, - } - expect(convertSpec({ - components: { headers: { H: { schema: petRef } }, mediaTypes: { Pet: { schema: pet } }, schemas }, - }).components).toEqual({ headers: { H: { schema: convertedPet } }, schemas }) - }) -}) - -describe('recursion', () => { - // Inlining a recursive schema would never end. The recursion is cut at its - // first repeat by removing only the inner `$ref` keyword: a bare `$ref` - // becomes `{}`, which accepts anything, and one with siblings keeps them. - // Cutting can only loosen validation, never reject a value the original - // accepted (apart from under `not` and friends, a known limitation). - it('cuts a recursive schema at its first repeat by removing only the $ref keyword', () => { - expect(convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema' }, { - mediaTypes: { - Tree: { - schema: { - properties: { - children: { items: { $ref: '#/components/mediaTypes/Tree/schema' }, type: 'array' }, - parent: { $ref: '#/components/mediaTypes/Tree/schema', description: 'up' }, - }, - type: 'object', - }, - }, - }, - })).toEqual({ - properties: { children: { items: {}, type: 'array' }, parent: { description: 'up' } }, - type: 'object', - }) - }) - - // `%54ree` percent-decodes to `Tree`, so the inner reference is the same - // target as the outer one and the recursion is still detected. - it('detects recursion however the pointer is spelled', () => { - expect(convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema' }, { - mediaTypes: { Tree: { schema: { items: { $ref: '#/components/mediaTypes/%54ree/schema' }, type: 'array' } } }, - })).toEqual({ items: {}, type: 'array' }) - }) - - it('cuts a cycle entered through a pointer into a recursive schema', () => { - const result = convertComponent('schemas', { $ref: '#/components/mediaTypes/Tree/schema/properties/children' }, { - mediaTypes: { - Tree: { - schema: { - properties: { children: { items: { $ref: '#/components/mediaTypes/Tree/schema' }, type: 'array' } }, - type: 'object', - }, - }, - }, - }) - expect(result).toEqual({ items: { properties: { children: {} }, type: 'object' }, type: 'array' }) - }) - - // Outside schemas there is no "accept anything" value to cut with, so a - // Reference Object that leads back into the object being inlined is - // removed instead. - it('removes a Reference Object that comes back to the object being inlined', () => { - expect(convertPathItem({ - post: { callbacks: { copy: { $ref: '#/paths/~1a/query/callbacks/cb' } } }, - query: { - callbacks: { - cb: { '{$url}': { get: { callbacks: { back: { $ref: '#/paths/~1a/query/callbacks/cb' } } } } }, - }, - }, - })).toEqual({ - post: { callbacks: { copy: { '{$url}': { get: { callbacks: {} } } } } }, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts deleted file mode 100644 index e9b6bcc..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/responses.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { convertComponent, convertPathItem } from './helpers' - -describe('summary and description', () => { - // 3.2 adds a response `summary` and makes `description` optional: - // https://spec.openapis.org/oas/v3.2.0.html#response-summary - // 3.1 requires `description` (https://spec.openapis.org/oas/v3.1.2.html#response-description), - // so the summary fills it when present, and an empty string otherwise. - 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' }], - ['adds an empty description when neither exists', {}, { description: '' }], - ['adds an empty description instead of promoting a malformed summary', { summary: 42 }, { description: '' }], - ['clones a non-object headers value through', { description: 'ok', headers: 'junk' }, { description: 'ok', headers: 'junk' }], - ])('%s', (_name, response, expected) => { - expect(convertComponent('responses', response)).toEqual(expected) - }) - - // Reference Objects already allow `summary` and `description` overrides in - // 3.1: https://spec.openapis.org/oas/v3.1.2.html#reference-object - it('leaves response Reference Objects untouched, including their overrides', () => { - const reference = { $ref: '#/components/responses/R', description: 'override', summary: 'kept' } - expect(convertComponent('responses', reference)).toEqual(reference) - }) -}) - -describe('responses maps', () => { - // Responses Object keys are status codes or `default`; `x-` keys are - // extensions, not responses: https://spec.openapis.org/oas/v3.2.0.html#responses-object - it('clones x- keys of the responses map without response conversion', () => { - expect(convertPathItem({ - get: { responses: { '200': { summary: 'ok' }, 'x-note': { summary: 'not a response' } } }, - })).toEqual({ - get: { responses: { '200': { description: 'ok' }, 'x-note': { summary: 'not a response' } } }, - }) - }) - - it('converts response headers, content, and links', () => { - expect(convertComponent('responses', { - content: { 'application/json': { itemSchema: { type: 'string' } } }, - description: 'ok', - headers: { 'X-H': { style: 'cookie' } }, - links: { - inline: { server: { name: 's', url: '/u' } }, - referenced: { $ref: '#/components/links/L' }, - }, - })).toEqual({ - content: { 'application/json': { schema: { items: { type: 'string' }, type: 'array' } } }, - description: 'ok', - headers: { 'X-H': {} }, - links: { - inline: { server: { url: '/u' } }, - referenced: { $ref: '#/components/links/L' }, - }, - }) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts deleted file mode 100644 index 9011e92..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/schema-identifiers.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// `$id`, `$anchor`, and `$dynamicAnchor` give a schema a URI. JSON Schema -// forbids two schemas from claiming the same one: "there is no way for a URI -// to identify more than one schema": -// https://json-schema.org/draft/2020-12/json-schema-core#section-9.1.2 -// Inlining a schema in several places would copy its identifiers, so only -// the first copy keeps them. -// -// A `$ref` inside a schema with an `$id` resolves against that `$id`: -// https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.1 -// Where the `$id` survives, such a `$ref` still resolves the same, because -// converting a schema keeps everything inside it in place. A copy that -// loses its `$id` would resolve it against the enclosing base instead, so -// there its target is inlined. - -import type * as OpenAPIV3_2 from '@openapi-spec/types/v3.2' - -import { downgradeSpecV32ToV31 } from '@openapi-spec/downgrader' - -import { dig } from '../../helpers' -import { expectValidAs } from '../../validate' -import { convertComponent, convertPathItem, convertSpec } from './helpers' - -it('keeps $id and $anchor on the first copy of a schema inlined in several places', () => { - const pet = { $id: 'https://example.com/pet', properties: { name: { $anchor: 'name', type: 'string' } }, type: 'object' } - const result = convertSpec({ - components: { - mediaTypes: { Pet: { schema: pet } }, - schemas: { Named: { $ref: '#/components/mediaTypes/Pet/schema', description: 'named' } }, - }, - paths: { - '/a': { get: { responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } }, description: 'ok' } } } }, - }, - }) - expect(dig(result, 'components', 'schemas', 'Named')).toEqual({ allOf: [pet], description: 'named' }) - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content')).toEqual({ - 'application/json': { schema: { properties: { name: { type: 'string' } }, type: 'object' } }, - }) -}) - -// When the original survives (moved into `schema.items`, or shifted in a -// parameter list), it keeps its identifiers and the inlined copies lose them. -it('keeps identifiers on a moved or shifted original rather than on the copies inlined from it', () => { - expect(convertPathItem({ - get: { - parameters: [ - { content: { 'text/plain': {} }, in: 'querystring', name: 'q' }, - { in: 'query', name: 'p', schema: { $dynamicAnchor: 'p', type: 'string' } }, - ], - responses: { - 200: { content: { 'application/jsonl': { itemSchema: { $id: 'https://example.com/item' } } }, description: 'ok' }, - }, - }, - post: { - parameters: [{ $ref: '#/paths/~1a/get/parameters/1' }], - requestBody: { - content: { 'application/json': { schema: { $ref: '#/paths/~1a/get/responses/200/content/application~1jsonl/itemSchema' } } }, - }, - }, - })).toEqual({ - get: { - parameters: [{ in: 'query', name: 'p', schema: { $dynamicAnchor: 'p', type: 'string' } }], - responses: { - 200: { content: { 'application/jsonl': { schema: { items: { $id: 'https://example.com/item' }, type: 'array' } } }, description: 'ok' }, - }, - }, - post: { - parameters: [{ in: 'query', name: 'p', schema: { type: 'string' } }], - requestBody: { content: { 'application/json': { schema: {} } } }, - }, - }) -}) - -it('produces a valid 3.1 document with unique identifiers', async () => { - const doc: OpenAPIV3_2.OpenAPIObject = { - components: { - mediaTypes: { - Pet: { - schema: { - $id: 'https://example.com/pet', - properties: { name: { $anchor: 'name', type: 'string' } }, - type: 'object', - }, - }, - }, - }, - info: { title: 'Identifiers', version: '1.0.0' }, - openapi: '3.2.0', - paths: { - '/pets': { - get: { - responses: { 200: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } }, description: 'Pet' } }, - }, - post: { - requestBody: { content: { 'application/json': { $ref: '#/components/mediaTypes/Pet' } } }, - responses: { - 201: { - content: { 'application/json': { schema: { $ref: '#/components/mediaTypes/Pet/schema/properties/name' } } }, - description: 'Name', - }, - }, - }, - }, - }, - } - const v31 = downgradeSpecV32ToV31(doc) - const serialized = JSON.stringify(v31) - expect(serialized.match(/"\$id"/g)).toHaveLength(1) - expect(serialized.match(/"\$anchor"/g)).toHaveLength(1) - await expectValidAs(v31, '3.1') -}) - -// Only the first copy is special. The copies after it are identical, so they -// share one converted object, like any other target inlined several times. -it('shares one identifier-free copy among the places after the first', () => { - const pet = { $id: 'https://example.com/pet', type: 'object' } - const result = convertSpec({ - components: { - mediaTypes: { Pet: { schema: pet } }, - schemas: { - A: { $ref: '#/components/mediaTypes/Pet/schema' }, - B: { $ref: '#/components/mediaTypes/Pet/schema' }, - C: { $ref: '#/components/mediaTypes/Pet/schema' }, - }, - }, - }) - const schemas = dig(result, 'components', 'schemas') - expect(schemas).toEqual({ A: pet, B: { type: 'object' }, C: { type: 'object' } }) - expect(dig(schemas, 'C')).toBe(dig(schemas, 'B')) -}) - -it('leaves a $ref or mapping value inside a schema with an $id as written, rather than resolving it to a removed document target of the same name', () => { - const own = { - $id: 'https://example.com/own', - components: { mediaTypes: { M: { schema: { type: 'number' } } } }, - discriminator: { mapping: { m: '#/components/mediaTypes/M/schema' }, propertyName: 'kind' }, - properties: { y: { $ref: '#/components/mediaTypes/M/schema' } }, - } - expect(convertComponent('schemas', own, { mediaTypes: { M: { schema: { type: 'string' } } } })).toEqual(own) -}) - -// A `mapping` value cannot be inlined, so the copies drop it instead. -it('inlines the targets of relative $refs in the copies that lose the $id, cutting recursion into {}', () => { - const tree = { - $defs: { Name: { type: 'string' } }, - $id: 'https://example.com/tree', - discriminator: { mapping: { tree: '#' }, propertyName: 'kind' }, - properties: { kids: { items: { $ref: '#' }, type: 'array' }, name: { $ref: '#/$defs/Name' } }, - type: 'object', - } - const result = convertSpec({ - components: { - mediaTypes: { Tree: { schema: tree } }, - schemas: { A: { $ref: '#/components/mediaTypes/Tree/schema' }, B: { $ref: '#/components/mediaTypes/Tree/schema' } }, - }, - }) - expect(dig(result, 'components', 'schemas')).toEqual({ - A: tree, - B: { - $defs: { Name: { type: 'string' } }, - discriminator: { mapping: {}, propertyName: 'kind' }, - properties: { kids: { items: {}, type: 'array' }, name: { type: 'string' } }, - type: 'object', - }, - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts deleted file mode 100644 index e43121e..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/security-schemes.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { convertComponent, convertSpec } from './helpers' - -const flow = { - authorizationUrl: 'https://example.com/auth', - scopes: {}, - tokenUrl: 'https://example.com/token', -} - -// 3.2 adds three security fields that 3.1 cannot express: -// - `deprecated`: https://spec.openapis.org/oas/v3.2.0.html#security-scheme-deprecated -// - `oauth2MetadataUrl` (RFC 8414 discovery): https://spec.openapis.org/oas/v3.2.0.html#security-scheme-oauth2-metadata-url -// - the OAuth device authorization flow (RFC 8628): https://spec.openapis.org/oas/v3.2.0.html#oauth-flows-device-authorization -// The scheme itself survives, so requirements naming it stay valid. -describe('3.2-only security scheme fields', () => { - 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 the other flows', - { - flows: { - authorizationCode: flow, - deviceAuthorization: { deviceAuthorizationUrl: 'https://example.com/device', scopes: {}, tokenUrl: flow.tokenUrl }, - }, - type: 'oauth2', - }, - { flows: { authorizationCode: flow }, type: 'oauth2' }, - ], - ['removes a malformed deviceAuthorization', { flows: { deviceAuthorization: 'junk' }, type: 'oauth2' }, { flows: {}, type: 'oauth2' }], - ['clones malformed flows through', { flows: 'junk', type: 'oauth2' }, { flows: 'junk', type: 'oauth2' }], - ['clones references through', { $ref: '#/components/securitySchemes/Other' }, { $ref: '#/components/securitySchemes/Other' }], - ['passes a non-object scheme through', 'junk', 'junk'], - ])('%s', (_name, scheme, expected) => { - expect(convertComponent('securitySchemes', scheme)).toEqual(expected) - }) - - // An oauth2 scheme whose only flow was the device flow keeps an empty - // `flows`, which the official 3.1 schema accepts. Requirements that name - // the scheme are left intact rather than silently dropped. - it('keeps a scheme left with no flows, and the requirements that name it', () => { - const result = convertSpec({ - components: { - securitySchemes: { - device: { - flows: { deviceAuthorization: { deviceAuthorizationUrl: 'https://example.com/device', scopes: { read: 'Read' }, tokenUrl: flow.tokenUrl } }, - type: 'oauth2', - }, - }, - }, - security: [{ device: ['read'] }], - }) - expect(result.components).toEqual({ securitySchemes: { device: { flows: {}, type: 'oauth2' } } }) - expect(result.security).toEqual([{ device: ['read'] }]) - }) -}) diff --git a/packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts b/packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts deleted file mode 100644 index e4605b3..0000000 --- a/packages/downgrader/tests/v3.2-to-v3.1/spec/servers-and-tags.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { convertSpec } from './helpers' - -describe('servers', () => { - // Server `name` is new in 3.2: https://spec.openapis.org/oas/v3.2.0.html#server-name - it('removes server name at the root, path item, operation, and link levels', () => { - expect(convertSpec({ - components: { links: { L: { operationId: 'op', server: { name: 's', url: '/u' } } } }, - paths: { - '/a': { - get: { responses: {}, servers: [{ name: 's', url: '/u' }] }, - servers: [{ name: 's', url: '/u' }], - }, - }, - servers: [{ description: 'd', name: 'prod', url: 'https://example.com' }], - })).toEqual({ - components: { links: { L: { operationId: 'op', server: { url: '/u' } } } }, - openapi: '3.1.2', - paths: { - '/a': { - get: { responses: {}, servers: [{ url: '/u' }] }, - servers: [{ url: '/u' }], - }, - }, - servers: [{ description: 'd', url: 'https://example.com' }], - }) - }) - - it('clones non-array servers and non-object server entries through', () => { - expect(convertSpec({ paths: { '/a': { servers: 'junk' } }, servers: [5, null] })).toEqual({ - openapi: '3.1.2', - paths: { '/a': { servers: 'junk' } }, - servers: [5, null], - }) - }) -}) - -describe('tags', () => { - // 3.2 turns tags into a hierarchy with `summary`, `parent`, and `kind`: - // https://spec.openapis.org/oas/v3.2.0.html#tag-object - // 3.1 tags are flat, so the hierarchy is lost. Operations keep referring to - // every tag by name, so no operation loses a tag. - it('removes tag summary, parent, and kind and keeps the 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, - ]) - }) -})