Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc
| [`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 other branch, as Zod writes a nullable object, array, integer, enum, or `$ref`, and Effect a nullish one with `{ type: "null" }` twice | That branch with `nullable: true` in place of the `anyOf` when it has a single `type`, 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. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. |
| A [`oneOf`][js-one-of] of `{ type: "null" }` and one other branch, as typia and Litestar write a nullable value | That branch with `nullable: true` in place of the `oneOf`, under the same conditions as the `anyOf` pair above but only with a single null branch, since null then matches one branch only. Otherwise the `oneOf` stays. |
| [`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. |
Expand Down Expand Up @@ -245,7 +246,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc

- A `not` or `oneOf` inside a cycle of schemas, over a `$ref` back into that cycle to a schema that lost a restriction, can still reject values the original accepts.
- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment.
- An `anyOf` 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.
- 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`, or into a `oneOf` that becomes `anyOf`, are left as written and dangle.
- 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.
Expand Down
53 changes: 31 additions & 22 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,15 @@ const IN_PLACE_APPLICATORS = [
// Converted schemas that lost a restriction, directly or in a subschema.
const LOOSE = new WeakSet<object>()

// Converted schemas with an `anyOf` of several branches, for `mergeNullable`.
// It runs once the whole input is converted, since a cycle can meet a branch
// before its conversion is done.
const nullableAnyOfs: [schema: Record<string, unknown>, input: object][] = []
// Converted schemas with an `anyOf` of several branches, or a `oneOf` of two,
// for `mergeNullable`. It runs once the whole input is converted, since a
// cycle can meet a branch before its conversion is done.
const nullableUnions: [schema: Record<string, unknown>, input: object, key: 'anyOf' | 'oneOf'][] =
[]

// Input schemas whose `anyOf` a `$ref` kept as written points into. They keep
// it, so the `$ref` still resolves.
const anyOfTargets = new Set<unknown>()
// Input schemas whose `anyOf` or `oneOf` a `$ref` kept as written points
// into. They keep it, so the `$ref` still resolves.
const unionTargets = new Set<unknown>()

// A branch with these stays: null can still fail them beside `nullable: true`,
// and a `nullable` already there was written 3.0-style.
Expand Down Expand Up @@ -295,8 +296,10 @@ function refOr(convert: Convert): Convert {

function convertSchemaRef(ref: string, ctx: Context): unknown {
if (!isInlined(ref, ctx)) {
for (let at = ref.indexOf('/anyOf/'); at !== -1; at = ref.indexOf('/anyOf/', at + 1)) {
anyOfTargets.add(resolvePointer(ref.slice(0, at), ctx))
for (const key of ['/anyOf/', '/oneOf/']) {
for (let at = ref.indexOf(key); at !== -1; at = ref.indexOf(key, at + 1)) {
unionTargets.add(resolvePointer(ref.slice(0, at), ctx))
}
}
// A `$ref` kept as written loses what its target loses. Converting the
// target here reuses the copy the components get, and stops at a cycle.
Expand Down Expand Up @@ -570,9 +573,11 @@ function rejectsNull(schema: unknown): boolean {
// writes it, when the schema around it has none of its keywords. A branch
// that stays, such as a `$ref` or an `enum`, makes the pair a `oneOf` when it
// rejects null, as null then matches one branch only. NSwag reads that
// `oneOf` as nullable, and the `anyOf` as an empty class.
function mergeNullable(out: Record<string, unknown>, root: unknown): void {
const branches = out.anyOf as unknown[]
// `oneOf` as nullable, and the `anyOf` as an empty class. typia and Litestar
// write the pair as a `oneOf`, which means the same when the other branch
// takes `nullable`, and oapi-codegen reads it as a union.
function mergeNullable(out: Record<string, unknown>, root: unknown, key: 'anyOf' | 'oneOf'): void {
const branches = out[key] as unknown[]
// Effect writes null twice for a nullish value, once for `undefined`.
const others = branches.filter((branch) => !isOnlyNullSchema(branch))
const [other] = others
Expand All @@ -581,12 +586,12 @@ function mergeNullable(out: Record<string, unknown>, root: unknown): void {
}
if (
typeof other.type === 'string' &&
!NOT_NULLABLE_KEYWORDS.some((key) => Object.hasOwn(other, key)) &&
!Object.keys(other).some((key) => Object.hasOwn(out, key))
!NOT_NULLABLE_KEYWORDS.some((name) => Object.hasOwn(other, name)) &&
!Object.keys(other).some((name) => Object.hasOwn(out, name))
) {
delete out.anyOf
delete out[key]
Object.assign(out, other, { nullable: true })
} else if (out.oneOf === undefined) {
} else if (key === 'anyOf' && out.oneOf === undefined) {
// A `$ref` kept as written rejects null when its target does.
const ref = getRef(other)
if (rejectsNull(ref === undefined ? other : find(root, ref))) {
Expand Down Expand Up @@ -742,7 +747,11 @@ function finishSchema(
}
}
if (Array.isArray(out.anyOf) && out.anyOf.length > 1) {
nullableAnyOfs.push([out, schema])
nullableUnions.push([out, schema, 'anyOf'])
}
// A `oneOf` with null twice rejects null, so only a pair can merge.
if (Array.isArray(out.oneOf) && out.oneOf.length === 2) {
nullableUnions.push([out, schema, 'oneOf'])
}
if (loose) {
LOOSE.add(out)
Expand Down Expand Up @@ -870,16 +879,16 @@ function convertDocument(value: unknown, ctx: Context): unknown {
function downgradeV30(root: unknown, convert: Convert): unknown {
try {
const out = downgrade(root, convert)
for (const [schema, input] of nullableAnyOfs) {
if (!anyOfTargets.has(input)) {
mergeNullable(schema, root)
for (const [schema, input, key] of nullableUnions) {
if (!unionTargets.has(input)) {
mergeNullable(schema, root, key)
}
}
return out
} finally {
// Cleared even when conversion throws, so nothing keeps the input alive.
nullableAnyOfs.length = 0
anyOfTargets.clear()
nullableUnions.length = 0
unionTargets.clear()
}
}

Expand Down
46 changes: 46 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 @@ -563,6 +563,52 @@ describe('downgradeSchemaV31ToV30', () => {
})
})

it('merges a oneOf of one type and null into that type with nullable, as typia and Litestar write it', () => {
const object: OpenAPIV3_1.SchemaObject = {
type: 'object',
properties: { a: { type: 'string' } },
required: ['a'],
}
expect(downgradeSchemaV31ToV30({ oneOf: [{ type: 'null' }, { type: 'string' }] })).toEqual({
type: 'string',
nullable: true,
})
expect(
downgradeSchemaV31ToV30({ description: 'A', oneOf: [object, { type: 'null' }] }),
).toEqual({ description: 'A', ...object, nullable: true })
const onlyNull = { type: 'string', nullable: true, enum: [null] }
// Null twice matches two branches, so the oneOf rejects it.
expect(
downgradeSchemaV31ToV30({
oneOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }],
}),
).toEqual({ oneOf: [{ type: 'string' }, onlyNull, onlyNull] })
// A branch that takes null too, or that nullable would not let null through, stays.
for (const branch of [
{ type: ['string', 'null'] },
{ type: 'string', enum: ['a'] },
{ $ref: '#/components/schemas/Pet' },
] as OpenAPIV3_1.SchemaObject[]) {
expect(downgradeSchemaV31ToV30({ oneOf: [branch, { type: 'null' }] })).toEqual({
oneOf: [downgradeSchemaV31ToV30(branch), onlyNull],
})
}
// Kept for a $ref that points into it.
expect(
downgradeSchemaV31ToV30({
properties: {
a: { oneOf: [{ type: 'null' }, object] },
b: { $ref: '#/properties/a/oneOf/1' },
},
}),
).toEqual({
properties: {
a: { oneOf: [onlyNull, object] },
b: { $ref: '#/properties/a/oneOf/1' },
},
})
})

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] }
// Null matches only the null branch here, so it becomes a oneOf.
Expand Down
Loading