diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index e206646..7443a37 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -215,7 +215,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise 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. A `$ref` into one right under a component schema, as NocoDB writes for schemas grouped under one name, is replaced by its converted target. | +| 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. A `$ref` into one right under a component schema or a converted schema's root, as NocoDB writes for schemas grouped under one name, is replaced by its converted target. | | [`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, so a `$ref` beside it, as aide writes one, gets no `allOf`. | | A boolean `exclusiveMinimum` or `exclusiveMaximum`, the 3.0 form, as NestJS writes in 3.1 | Kept beside a numeric `minimum` or `maximum`, where it means in 3.0 what its author meant. Removed otherwise. | | [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] | Kept. 3.0 requires such a property in responses or in requests only, where 3.1 requires it everywhere, so the schema counts as having lost a restriction, and the property tells no `oneOf` branch apart. | @@ -268,7 +268,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `$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` 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. +- 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 or a converted schema's root, 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`. - A record keyed by an enum repeats its value schema for each name once serialized, so a large enum, or such records nested in each other, make a large document: three levels of 20 names repeat the innermost schema 8,000 times. Register the value schema, as with Zod's `.meta({ id })`, so each name repeats only a `$ref`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 9a88085..9c5a00f 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -141,6 +141,9 @@ const refTargets = new Set() // holds it, also once the target it was cut at is no longer being inlined. const cutHolders = new Set() +// The input of `downgradeSchemaV31ToV30`, whose own keys `$ref`s can point into. +let schemaRoot: unknown + // Pairs of null and a `$ref` that became the `$ref` alone, with their branches. const collapsedPairs: [schema: Record, anyOf: unknown[]][] = [] @@ -300,16 +303,16 @@ function isInlined(ref: string, ctx: Context): boolean { // 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/')) { + if (ctx.root === schemaRoot) { + // A key of a schema root that no keyword names becomes an extension too. + unconverted ||= ref.startsWith('#/') && becomesExtension(ref.slice(1)) + } else if (ref.startsWith('#/components/')) { const name = ref.indexOf('/', 13) + 1 const end = ref.indexOf('/', name) keywords = name === 0 || end === -1 ? '' : ref.slice(end) - // A key no keyword names becomes an extension, as when NocoDB groups - // schemas under one component. + // As when NocoDB groups schemas under one component. if (keywords !== '' && ref.startsWith('#/components/schemas/')) { - const next = keywords.indexOf('/', 1) - const key = next === -1 ? keywords.slice(1) : keywords.slice(1, next) - unconverted = !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) + unconverted = becomesExtension(keywords) } } return ( @@ -328,6 +331,13 @@ function isInlined(ref: string, ctx: Context): boolean { const INTO_REPLACED = /\/(?:items|additionalProperties)(?:\/|$)/ +/** Whether the first key of `path`, which starts with `/`, is one no keyword names, so a schema renames it to an extension. */ +function becomesExtension(path: string): boolean { + const next = path.indexOf('/', 1) + const key = next === -1 ? path.slice(1) : path.slice(1, next) + return !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) +} + /** A 3.0 Reference Object holds only `$ref`. */ function refOr(convert: Convert): Convert { const self: Convert = (value, ctx) => { @@ -1774,6 +1784,7 @@ function leadsBack(schema: Record, ctx: Context): boolean { function downgradeV30(root: unknown, convert: Convert): unknown { try { const ctx = createContext(root) + schemaRoot = convert === convertSchema ? root : undefined const out = convert(root, ctx) for (const [schema, input, key] of nullableUnions) { if (!unionTargets.has(input) && (key === 'oneOf' || dropEqualBranches(schema))) { @@ -1807,6 +1818,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { cutHolders.clear() collapsedPairs.length = 0 LOOSE.clear() + schemaRoot = undefined } } 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 ddcbc3f..6ba32e0 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2611,6 +2611,33 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ type: 'object', properties: { b: { type: 'string', nullable: true } } }) }) + it('replaces a $ref into a root key that becomes an extension with its target', () => { + // SchemaStore-style schemas group definitions under keys of their own. + expect( + downgradeSchemaV31ToV30({ + type: 'object', + properties: { + image: { $ref: '#/CustomTypes/Path' }, + cat: { $ref: '#/components/schemas/Cat' }, + kept: { $ref: '#/properties/image' }, + self: { $ref: '#' }, + }, + CustomTypes: { Path: { type: ['string', 'null'] } }, + components: { schemas: { Cat: { const: 'cat' } } }, + } as OpenAPIV3_1.SchemaObject), + ).toEqual({ + 'type': 'object', + 'properties': { + image: { type: 'string', nullable: true }, + cat: { type: 'string', enum: ['cat'] }, + kept: { $ref: '#/properties/image' }, + self: { $ref: '#' }, + }, + 'x-CustomTypes': { Path: { type: ['string', 'null'] } }, + 'x-components': { schemas: { Cat: { const: 'cat' } } }, + }) + }) + it('decodes percent-encoding, ~1, and ~0 in a $ref, in that order', () => { expect( downgradeSchemaV31ToV30({