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
2 changes: 1 addition & 1 deletion packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,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` | 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. |
| 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. |
| [`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
26 changes: 15 additions & 11 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,10 @@ 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 two branches, for `mergeNullable`. It
// runs once the whole input is converted, since a cycle can meet a branch
// 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 twoBranchAnyOfs: [schema: Record<string, unknown>, input: object][] = []
const nullableAnyOfs: [schema: Record<string, unknown>, input: object][] = []

// Input schemas whose `anyOf` a `$ref` kept as written points into. They keep
// it, so the `$ref` still resolves.
Expand Down Expand Up @@ -514,9 +514,11 @@ function rejectsNull(schema: unknown): boolean {
// 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 [first, second] = out.anyOf as unknown[]
const other = isOnlyNullSchema(first) ? second : isOnlyNullSchema(second) ? first : undefined
if (!isRecord(other)) {
const branches = out.anyOf as unknown[]
// Effect writes null twice for a nullish value, once for `undefined`.
const others = branches.filter((branch) => !isOnlyNullSchema(branch))
const [other] = others
if (others.length !== 1 || !isRecord(other)) {
return
}
if (
Expand All @@ -530,7 +532,9 @@ function mergeNullable(out: Record<string, unknown>, root: unknown): void {
// A `$ref` kept as written rejects null when its target does.
const ref = getRef(other)
if (rejectsNull(ref === undefined ? other : find(root, ref))) {
out.oneOf = out.anyOf
// Null once, since `oneOf` rejects a value that matches two branches.
const nullBranch = branches.find(isOnlyNullSchema)
out.oneOf = branches[0] === other ? [other, nullBranch] : [nullBranch, other]
delete out.anyOf
}
}
Expand Down Expand Up @@ -667,8 +671,8 @@ function finishSchema(
delete out[key]
}
}
if (Array.isArray(out.anyOf) && out.anyOf.length === 2) {
twoBranchAnyOfs.push([out, schema])
if (Array.isArray(out.anyOf) && out.anyOf.length > 1) {
nullableAnyOfs.push([out, schema])
}
if (loose) {
LOOSE.add(out)
Expand Down Expand Up @@ -796,15 +800,15 @@ 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 twoBranchAnyOfs) {
for (const [schema, input] of nullableAnyOfs) {
if (!anyOfTargets.has(input)) {
mergeNullable(schema, root)
}
}
return out
} finally {
// Cleared even when conversion throws, so nothing keeps the input alive.
twoBranchAnyOfs.length = 0
nullableAnyOfs.length = 0
anyOfTargets.clear()
}
}
Expand Down
46 changes: 41 additions & 5 deletions packages/downgrader/tests/v3.1-to-v3.0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,47 @@ describe('downgradeSchemaV31ToV30', () => {
type: 'string',
nullable: true,
})
// As Effect writes a nullish value, with null twice.
expect(
downgradeSchemaV31ToV30({
anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }],
}),
).toEqual({ type: 'string', nullable: true })
// One branch holds no null to merge, as Valibot writes a union of one schema.
expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }] })).toEqual({
anyOf: [{ type: 'string' }],
})
expect(
downgradeSchemaV31ToV30({
anyOf: [{ type: 'null' }, { type: 'string', enum: ['a'] }, { type: 'null' }],
}),
).toEqual({
oneOf: [
{ type: 'string', nullable: true, enum: [null] },
{ type: 'string', enum: ['a'] },
],
})
// One null object twice still gives null once, or the oneOf rejects it.
const nullType: OpenAPIV3_1.SchemaObject = { type: 'null' }
expect(
downgradeSchemaV31ToV30({ anyOf: [{ type: 'string', enum: ['a'] }, nullType, nullType] }),
).toEqual({
oneOf: [
{ type: 'string', enum: ['a'] },
{ type: 'string', nullable: true, enum: [null] },
],
})
expect(
downgradeSchemaV31ToV30({
anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }],
}),
).toEqual({
anyOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'string', nullable: true, enum: [null] },
],
})
})

it('keeps an anyOf with null whose other branch null would not pass, or shares a key', () => {
Expand Down Expand Up @@ -561,11 +602,6 @@ describe('downgradeSchemaV31ToV30', () => {
anyOf: [{ type: 'string', enum: ['a'] }, onlyNull],
oneOf: [{ minLength: 1 }, { maxLength: 0 }],
})
expect(
downgradeSchemaV31ToV30({
anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }],
}),
).toEqual({ anyOf: [{ type: 'string' }, onlyNull, onlyNull] })
// A null branch with more than its type is not only null.
expect(
downgradeSchemaV31ToV30({
Expand Down
Loading