diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 094c8b7..e206646 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -61,6 +61,8 @@ The input is never changed, and the output shares no plain objects or arrays wit External `$ref`s are left as written, except in a 3.2 `content` map, where 3.1 allows none and the entry becomes `{}`, which takes any payload. The files they point at are not converted, so bundle a multi-file document into one first. +Extensions pass through unconverted, so a `$ref` into one at the document root, in `components`, or right under a component schema, as Stoplight writes when it bundles files into `x-ext`, is replaced by its converted target. + ## 3.2 → 3.1 ### Spec (`downgradeSpecV32ToV31`) @@ -265,7 +267,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - An `anyOf` or `oneOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union. - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, into a `not` removed over a schema that lost a restriction, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. - 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. Such a cut `$ref` tells no `oneOf` branch apart, and counts as taking null, wherever its copy is reused. -- Each `$ref` into `$defs` repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written. +- Each `$ref` into `$defs` or an extension repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written. - 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, except right under a component schema, and array-form `items` passes through. Convert such schemas to 2020-12 first. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. - `patternProperties` that matches every name, as TypeBox emits for a record, gives `additionalProperties: true`, so its value schema is lost. - A record keyed by a union of literals, which Zod and Valibot write as an `anyOf` of `const` branches in `propertyNames`, loses its key names, so 3.0 allows any key. Key it by an enum instead, such as `z.enum` or `v.picklist`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 6c8baac..9a88085 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -297,7 +297,9 @@ const DOCUMENT_FIELDS = defineFields({ // `#/components//` is a name, so a component named `items` is not one. function isInlined(ref: string, ctx: Context): boolean { let keywords = ref - let renamed = false + // Extensions pass through unconverted, so a target kept in one, as Stoplight + // bundles files into `x-ext`, is converted where a `$ref` uses it. + let unconverted = ref.startsWith('#/x-') || ref.startsWith('#/components/x-') if (ref.startsWith('#/components/')) { const name = ref.indexOf('/', 13) + 1 const end = ref.indexOf('/', name) @@ -307,12 +309,11 @@ function isInlined(ref: string, ctx: Context): boolean { if (keywords !== '' && ref.startsWith('#/components/schemas/')) { const next = keywords.indexOf('/', 1) const key = next === -1 ? keywords.slice(1) : keywords.slice(1, next) - renamed = - !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) && !key.startsWith('x-') + unconverted = !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) } } return ( - (renamed || + (unconverted || ref.includes('%') || keywords.includes('/$defs/') || keywords.includes('/definitions/') || diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index b24e9b2..ddcbc3f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -271,6 +271,38 @@ describe('downgradeSpecV31ToV30', () => { }) }) + describe('extensions', () => { + it('inlines $refs into an extension, whose content passes through unconverted, as Stoplight bundles into x-ext', () => { + const ext = { + tag: { const: 'a' }, + loop: { type: 'object', properties: { next: { $ref: '#/x-ext/loop' } } }, + query: { name: 'q', in: 'query', schema: { type: ['string', 'null'] } }, + } + const out = convert({ + 'paths': { '/a': { get: { parameters: [{ $ref: '#/x-ext/query' }], responses } } }, + 'components': { + 'schemas': { + Tag: { oneOf: [{ $ref: '#/x-ext/tag' }, { type: 'null' }] }, + Loop: { $ref: '#/x-ext/loop' }, + Kind: { $ref: '#/components/x-schemas/Kind' }, + }, + 'x-schemas': { Kind: { const: 'b' } }, + }, + 'x-ext': ext, + }) + expect(out.paths['/a']?.get?.parameters).toEqual([ + { name: 'q', in: 'query', schema: { type: 'string', nullable: true } }, + ]) + // A 3.0 reader would ignore `const`, so null would match both branches. + expect(out.components?.schemas).toEqual({ + Tag: { oneOf: [{ type: 'string', enum: ['a'] }, onlyNull] }, + Loop: { type: 'object', properties: { next: {} } }, + Kind: { type: 'string', enum: ['b'] }, + }) + expect(out['x-ext']).toEqual(ext) + }) + }) + describe('mutual TLS', () => { it('drops mutualTLS schemes, and the $refs that resolve to them', () => { const out = convert({ @@ -2968,7 +3000,7 @@ describe('downgradeSchemaV31ToV30', () => { FieldOptions: { 'Text': { type: 'object', properties: { type: { const: 'Text' } } }, 'Number': { type: ['number', 'null'] }, - 'x-note': { type: 'string' }, + 'x-note': { const: 'note' }, 'prefixItems': [{ type: 'string' }], }, Field: { @@ -2979,7 +3011,7 @@ describe('downgradeSchemaV31ToV30', () => { }, }, Nested: ref('FieldOptions/Text/properties/type'), - Kept: ref('FieldOptions/x-note'), + Extension: ref('FieldOptions/x-note'), Removed: ref('FieldOptions/prefixItems/0'), // A key 3.0 keeps, and a key of another component type, are not renamed. Negated: { not: { type: 'string' } }, @@ -2992,7 +3024,7 @@ describe('downgradeSchemaV31ToV30', () => { // The discriminator goes, as its mapping would dangle. expect(out.components?.schemas?.Field).not.toHaveProperty('discriminator') expect(out.components?.schemas).toMatchObject({ - FieldOptions: { 'x-Text': {}, 'x-Number': {} }, + FieldOptions: { 'x-Text': {}, 'x-Number': {}, 'x-note': { const: 'note' } }, Field: { oneOf: [ { type: 'object', properties: { type: { type: 'string', enum: ['Text'] } } }, @@ -3000,7 +3032,7 @@ describe('downgradeSchemaV31ToV30', () => { ], }, Nested: { type: 'string', enum: ['Text'] }, - Kept: ref('FieldOptions/x-note'), + Extension: { type: 'string', enum: ['note'] }, Removed: ref('FieldOptions/prefixItems/0'), Negated: { not: { type: 'string' } }, })