diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 668bf72..d4dd608 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -176,31 +176,32 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem 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. -| 3.1 | In 3.0 | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | -| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | -| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | -| 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. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | -| An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | -| 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. | +| 3.1 | In 3.0 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | +| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | +| An [`anyOf`][js-any-of] of `{ type: "null" }` and one branch with a single `type`, as Zod writes a nullable object, array, or integer | That branch with `nullable: true` in place of the `anyOf`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. | +| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | +| 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. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | +| An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | +| 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 @@ -246,6 +247,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. - 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. - Kiota reads the `"null"` branch's `type: "string"` as a real type, so array items that may be null, and a union of several types with null, come out as strings. Kiota reads 3.1 too, which keeps their types. ## Performance diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index f426bd4..69ad2fb 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -123,6 +123,15 @@ const IN_PLACE_APPLICATORS = [ // Converted schemas that lost a restriction, directly or in a subschema. const LOOSE = new WeakSet() +// Converted schemas with an `anyOf` of two branches, for `mergeNullable`. It +// runs once the whole input is converted, since a cycle can meet a branch +// before its conversion is done. +const twoBranchAnyOfs: Record[] = [] + +// A branch with these stays: null can still fail them beside `nullable: true`, +// and a `nullable` already there was written 3.0-style. +const NOT_NULLABLE_KEYWORDS = ['$ref', 'allOf', 'anyOf', 'enum', 'not', 'nullable', 'oneOf'] + const convertAnyOf = list(convertSchema) const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) @@ -468,6 +477,35 @@ function isOnlyNull(values: unknown): boolean { return Array.isArray(values) && values.length === 1 && values[0] === null } +function isOnlyNullSchema(value: unknown): boolean { + return ( + isRecord(value) && + Object.keys(value).length === 3 && + value.type === 'string' && + value.nullable === true && + isOnlyNull(value.enum) + ) +} + +// Zod writes a nullable object, array, or integer as an `anyOf` of it and +// `{ type: "null" }`. Code generators read that pair in 3.0 as a union or an +// empty class, so the branch takes its place with `nullable: true`, as 3.0 +// writes it, when the schema around it has none of its keywords. +function mergeNullable(out: Record): void { + const [first, second] = out.anyOf as unknown[] + const other = isOnlyNullSchema(first) ? second : isOnlyNullSchema(second) ? first : undefined + if ( + !isRecord(other) || + typeof other.type !== 'string' || + NOT_NULLABLE_KEYWORDS.some((key) => Object.hasOwn(other, key)) || + Object.keys(other).some((key) => Object.hasOwn(out, key)) + ) { + return + } + delete out.anyOf + Object.assign(out, other, { nullable: true }) +} + // The type of a JSON value other than null. function valueType(value: unknown): string | undefined { if (Array.isArray(value)) { @@ -599,6 +637,9 @@ function finishSchema( delete out[key] } } + if (Array.isArray(out.anyOf) && out.anyOf.length === 2) { + twoBranchAnyOfs.push(out) + } if (loose) { LOOSE.add(out) } @@ -720,14 +761,23 @@ function convertDocument(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, DOCUMENT_FIELDS, finishDocument) } +function downgradeV30(root: unknown, convert: Convert): unknown { + twoBranchAnyOfs.length = 0 + const out = downgrade(root, convert) + for (const schema of twoBranchAnyOfs.splice(0)) { + mergeNullable(schema) + } + return out +} + export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject { - return downgrade(spec, convertDocument) as OpenAPIV3_0.OpenAPIObject + return downgradeV30(spec, convertDocument) as OpenAPIV3_0.OpenAPIObject } export function downgradeSchemaV31ToV30( schema: OpenAPIV3_1.SchemaObject, ): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return downgrade(schema, convertSchema) as + return downgradeV30(schema, convertSchema) as | OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } 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 d43ddb0..5cd40f9 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -460,9 +460,6 @@ describe('downgradeSchemaV31ToV30', () => { it('turns type null into a nullable type that only null matches', () => { const onlyNull = { type: 'string', nullable: true, enum: [null] } - expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }, { type: 'null' }] })).toEqual({ - anyOf: [{ type: 'string' }, onlyNull], - }) expect(Object.keys(downgradeSchemaV31ToV30({ type: 'null' }))).toEqual([ 'type', 'nullable', @@ -477,6 +474,61 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('merges an anyOf of one type and null into that type with nullable, as Zod writes nullable objects', () => { + const object: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + additionalProperties: false, + } + expect( + downgradeSchemaV31ToV30({ description: 'A', anyOf: [object, { type: 'null' }] }), + ).toEqual({ description: 'A', ...object, nullable: true }) + expect( + downgradeSchemaV31ToV30({ anyOf: [{ const: null }, { type: 'array', items: {} }] }), + ).toEqual({ type: 'array', items: {}, nullable: true }) + expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }, { type: 'null' }] })).toEqual({ + type: 'string', + nullable: true, + }) + }) + + it('keeps an anyOf with null whose other branch null would not pass, or shares a key', () => { + const onlyNull = { type: 'string', nullable: true, enum: [null] } + const branches: OpenAPIV3_1.SchemaObject[] = [ + { $ref: '#/components/schemas/Pet' }, + { type: 'string', enum: ['a', 'b'] }, + { type: 'object', allOf: [{ required: ['a'] }] }, + { type: 'integer', not: { const: 0 } }, + { type: 'string', nullable: true }, + { minLength: 1 }, + ] + for (const branch of branches) { + expect(downgradeSchemaV31ToV30({ anyOf: [branch, { type: 'null' }] })).toEqual({ + anyOf: [downgradeSchemaV31ToV30(branch), onlyNull], + }) + } + expect( + downgradeSchemaV31ToV30({ + description: 'A', + anyOf: [{ type: 'string', description: 'B' }, { type: 'null' }], + }), + ).toEqual({ description: 'A', anyOf: [{ type: 'string', description: 'B' }, onlyNull] }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }], + }), + ).toEqual({ anyOf: [{ type: 'string' }, onlyNull, onlyNull] }) + }) + + it('merges a branch in full where a cycle meets it before its conversion is done', () => { + const node: Record = { type: 'object', properties: {} } + node.properties.parent = { anyOf: [node, { type: 'null' }] } + const parent = (downgradeSchemaV31ToV30(node) as Record).properties.parent + expect(parent).toMatchObject({ type: 'object', nullable: true }) + expect(parent.properties.parent).toBe(parent) + }) + it('adds the items 3.0 requires on arrays', () => { expect(downgradeSchemaV31ToV30({ type: 'array' })).toEqual({ type: 'array', items: {} }) })