diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 6b0af5a..ce18c45 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -196,6 +196,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`$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. | +| [`propertyNames`][js-property-names] holding only an `enum` or `const`, or a `$ref` to one, as Zod and Valibot write record keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. A `$ref` into that schema is replaced by its target. A `title` or `description` beside the names is dropped. | | [`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. | @@ -249,6 +250,8 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - 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, 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`. - An untyped schema that gains a `format` from `contentEncoding` or `contentMediaType` also gains `type: "string"`, as 3.0 binary strings need, so it rejects non-string values it accepted. 3.0 tools find file uploads only by `type: "string"` with `format: "binary"`. - OpenAPI Generator merges the `"null"` branch beside a `$ref` to a string enum into a new enum, so the reference is lost. Pass `--openapi-normalizer SIMPLIFY_ONEOF_ANYOF_ENUM=false` to keep it. - datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 42c66b8..b1a91ef 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -254,17 +254,33 @@ const DOCUMENT_FIELDS = defineFields({ webhooks: DROP, }) -// A `$ref` into a removed part, `$defs`, or `definitions` is replaced by its -// target, unless it already dangles. +// A `$ref` into a removed part, `$defs`, or `definitions`, or into the +// `additionalProperties` of a record keyed by an enum, which becomes `false`, +// 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))) && + REMOVED_PARTS.some((prefix) => ref.startsWith(prefix)) || + isIntoNamedValue(ref, ctx)) && resolvePointer(ref, ctx) !== undefined ) } +function isIntoNamedValue(ref: string, ctx: Context): boolean { + for ( + let at = ref.indexOf('/additionalProperties'); + at !== -1; + at = ref.indexOf('/additionalProperties', at + 1) + ) { + const holder = resolvePointer(ref.slice(0, at), ctx) + if (isRecord(holder) && enumNames(holder, ctx) !== undefined) { + return true + } + } + return false +} + /** A 3.0 Reference Object holds only `$ref`. */ function refOr(convert: Convert): Convert { const self: Convert = (value, ctx) => { @@ -370,6 +386,45 @@ function isUnevaluatedAdditional(schema: Record): boolean { ) } +// The names allowed by a `propertyNames` holding only an `enum`, or a `const` +// for a single name, as Zod and Valibot write a record with enum or literal +// keys, when no other keyword names properties. Each becomes a property of its +// own, which 3.0 can express. A reusable enum is a `$ref` to it. +function enumNames(schema: Record, ctx: Context): string[] | undefined { + const ref = getRef(schema.propertyNames) + // Followed alone or beside a `title` or `description`, as Zod writes a + // described use, since other keywords beside it narrow the names further. + const names = + ref !== undefined && + Object.entries(schema.propertyNames as Record).every( + ([name, item]) => item === undefined || ['$ref', 'title', 'description'].includes(name), + ) + ? resolve(ref, ctx) + : schema.propertyNames + if (!isRecord(names)) { + return undefined + } + // An `enum` beside a `const` allows only the names in both, so both stay as before. + const values = + names.const === undefined ? names.enum : names.enum === undefined ? [names.const] : undefined + if ( + !Array.isArray(values) || + schema.properties !== undefined || + schema.patternProperties !== undefined || + schema.unevaluatedProperties !== undefined || + schema.additionalProperties === false || + !Object.entries(names).every( + ([name, item]) => + item === undefined || + ['enum', 'const', 'title', 'description'].includes(name) || + (name === 'type' && item === 'string'), + ) + ) { + return undefined + } + return values.filter((name) => typeof name === 'string') +} + // Whether removing `key` from `schema` lets it accept more values. Property // names are strings anyway, and `unevaluatedProperties` does nothing beside // `additionalProperties`. @@ -377,6 +432,7 @@ function losesRestriction( key: string, schema: Record, exactTuple: boolean, + ctx: Context, ): boolean { switch (key) { case 'prefixItems': @@ -385,6 +441,7 @@ function losesRestriction( const names = schema.propertyNames return !( names === true || + enumNames(schema, ctx) !== undefined || (isRecord(names) && Object.entries(names).every( ([name, item]) => item === undefined || (name === 'type' && item === 'string'), @@ -406,13 +463,14 @@ function loosen( out: Record, schema: Record, exactTuple: boolean, + ctx: Context, ): boolean { let loose = false for (const key in schema) { if ( RESTRICTING_KEYWORDS.has(key) && schema[key] !== undefined && - losesRestriction(key, schema, exactTuple) + losesRestriction(key, schema, exactTuple, ctx) ) { loose = true } @@ -604,13 +662,19 @@ function finishSchema( out.additionalProperties = typeof additional === 'boolean' ? additional : convertSchema(additional, ctx) } + const names = enumNames(schema, ctx) + if (names !== undefined) { + const value = isRecord(out.additionalProperties) ? out.additionalProperties : {} + out.properties = Object.fromEntries(names.map((name) => [name, value])) + out.additionalProperties = false + } // 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) && !includesJson(schema.enum, schema.const) - let loose = loosen(out, schema, exactTuple) || constOutsideEnum + let loose = loosen(out, schema, exactTuple, ctx) || 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) { 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 f97cdec..81a8a7f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1088,6 +1088,167 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ type: 'array', items: {} }) }) + it('turns a propertyNames enum, as Zod writes a record with enum keys, into one property per name', () => { + const value = { type: 'number' } as const + expect( + downgradeSchemaV31ToV30({ + type: 'object', + propertyNames: { type: 'string', enum: ['a', 'b', 1] }, + additionalProperties: value, + required: ['a', 'b'], + }), + ).toEqual({ + type: 'object', + properties: { a: value, b: value }, + additionalProperties: false, + required: ['a', 'b'], + }) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] } })).toEqual({ + properties: { a: {} }, + additionalProperties: false, + }) + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], minLength: undefined } }), + ).toEqual({ properties: { a: {} }, additionalProperties: false }) + // A title or description changes nothing, and a literal key is a const. + expect( + downgradeSchemaV31ToV30({ + propertyNames: { enum: ['a'], title: 'Key', description: 'A key' }, + }), + ).toEqual({ properties: { a: {} }, additionalProperties: false }) + expect( + downgradeSchemaV31ToV30({ + type: 'object', + propertyNames: { type: 'string', const: 'a' }, + additionalProperties: value, + required: ['a'], + }), + ).toEqual({ + type: 'object', + properties: { a: value }, + additionalProperties: false, + required: ['a'], + }) + // Beside other keywords that name properties, or with more than an enum, it is removed. + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, properties: { a: value } }), + ).toEqual({ properties: { a: value } }) + expect( + downgradeSchemaV31ToV30({ + propertyNames: { enum: ['a'] }, + patternProperties: { '^a$': value }, + }), + ).toEqual({ additionalProperties: true }) + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, unevaluatedProperties: false }), + ).toEqual({ additionalProperties: false }) + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, additionalProperties: false }), + ).toEqual({ additionalProperties: false }) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], minLength: 2 } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], type: 'number' } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a', 'b'], const: 'a' } })).toEqual({}) + expect( + downgradeSchemaV31ToV30({ propertyNames: { anyOf: [{ const: 'a' }, { const: 'b' }] } }), + ).toEqual({}) + // Converted exactly, it keeps an enclosing oneOf. + expect( + downgradeSchemaV31ToV30({ + oneOf: [ + { type: 'object', propertyNames: { enum: ['a'] }, additionalProperties: value }, + { type: 'string' }, + ], + }), + ).toEqual({ + oneOf: [ + { type: 'object', properties: { a: value }, additionalProperties: false }, + { type: 'string' }, + ], + }) + }) + + it('turns a propertyNames $ref to an enum, as Zod writes a record keyed by a reusable enum, into one property per name', () => { + const value = { type: 'number' } as const + const Lang = { type: 'string' as const, enum: ['en', 'fr'], title: 'Lang' } + const names = { + type: 'object', + properties: { en: value, fr: value }, + additionalProperties: false, + } as const + expect( + convert({ + components: { + schemas: { + Lang, + Names: { + type: 'object', + propertyNames: { $ref: '#/components/schemas/Lang' }, + additionalProperties: value, + }, + }, + }, + }).components?.schemas, + ).toEqual({ Lang, Names: names }) + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + oneOf: [ + { type: 'object', propertyNames: { $ref: '#/$defs/Lang' }, additionalProperties: value }, + { type: 'string' }, + ], + }), + ).toEqual({ oneOf: [names, { type: 'string' }] }) + // A target that is not only an enum, or a $ref with more beside it, is removed as before. + expect( + downgradeSchemaV31ToV30({ + $defs: { Short: { type: 'string', maxLength: 2 } }, + propertyNames: { $ref: '#/$defs/Short' }, + }), + ).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + propertyNames: { $ref: '#/$defs/Lang', maxLength: 1 }, + }), + ).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + propertyNames: { $ref: '#/$defs/Lang', minLength: undefined }, + }), + ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) + // A described use, as Zod writes for `z.record(Lang.describe('...'), ...)`, drops the description. + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + propertyNames: { $ref: '#/$defs/Lang', title: 'Key', description: 'A key' }, + }), + ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) + }) + + it('replaces a $ref into the value schema of a record keyed by an enum with its target', () => { + // zod-to-json-schema points a reused schema at its first use. + const value = { type: 'number' } as const + expect( + downgradeSchemaV31ToV30({ + properties: { + a: { propertyNames: { enum: ['x'] }, additionalProperties: value }, + b: { $ref: '#/properties/a/additionalProperties' }, + }, + }), + ).toEqual({ + properties: { a: { properties: { x: value }, additionalProperties: false }, b: value }, + }) + // A record keyed by any string keeps its additionalProperties, and so the $ref. + const map = { + properties: { + a: { additionalProperties: value }, + b: { $ref: '#/properties/a/additionalProperties' }, + }, + } as const + expect(downgradeSchemaV31ToV30(map)).toEqual(map) + }) + it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { const properties = { name: { type: 'string' } } as const expect(