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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,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 `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. |
Expand Down Expand Up @@ -247,6 +248,7 @@ 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, is removed with its value schema rather than turned into `additionalProperties`.
- 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`.
- 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.
Expand Down
52 changes: 50 additions & 2 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,13 +370,53 @@ function isUnevaluatedAdditional(schema: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>).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`.
function losesRestriction(
key: string,
schema: Record<string, unknown>,
exactTuple: boolean,
ctx: Context,
): boolean {
switch (key) {
case 'prefixItems':
Expand All @@ -385,6 +425,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'),
Expand All @@ -406,13 +447,14 @@ function loosen(
out: Record<string, unknown>,
schema: Record<string, unknown>,
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
}
Expand Down Expand Up @@ -600,13 +642,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) {
Expand Down
138 changes: 138 additions & 0 deletions packages/downgrader/tests/v3.1-to-v3.0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,144 @@ 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'], description: 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({})
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', description: 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('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => {
const properties = { name: { type: 'string' } } as const
expect(
Expand Down
Loading