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: 3 additions & 0 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc
| 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. |
| A boolean `exclusiveMinimum` or `exclusiveMaximum`, the 3.0 form, as NestJS writes in 3.1 | Kept beside a numeric `minimum` or `maximum`, where it means in 3.0 what its author meant. Removed otherwise. |
| [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] | Kept. 3.0 requires such a property in responses or in requests only, where 3.1 requires it everywhere, so the schema counts as having lost a restriction, and the property tells no `oneOf` branch apart. |
| [`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. A schema that loses one only around a cycle, of `$ref`s or of objects, counts too, which converts the input a second time. |
| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. |

Expand Down Expand Up @@ -248,6 +249,8 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc
[js-one-of]: https://json-schema.org/draft/2020-12/json-schema-core#name-oneof
[3.0-schema-nullable]: https://spec.openapis.org/oas/v3.0.4.html#schema-nullable
[3.0-schema-discriminator]: https://spec.openapis.org/oas/v3.0.4.html#schema-discriminator
[3.0-schema-read-only]: https://spec.openapis.org/oas/v3.0.4.html#schema-read-only
[3.0-schema-write-only]: https://spec.openapis.org/oas/v3.0.4.html#schema-write-only

#### Limitations

Expand Down
34 changes: 32 additions & 2 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,8 +655,11 @@ function tagsOf(schema: Record<string, unknown>, ctx: Context): Tags {
if (schema.nullable === true) {
return { type: undefined, values }
}
const required = schema.type === 'object' && Array.isArray(schema.required) ? schema.required : []
const properties = isRecord(schema.properties) ? schema.properties : {}
const required =
schema.type === 'object' && Array.isArray(schema.required)
? schema.required.filter((name) => !isOneWay(properties[name as string], ctx))
: []
for (const name of Object.keys(properties)) {
const value = properties[name]
const property =
Expand All @@ -676,6 +679,27 @@ function tagsOf(schema: Record<string, unknown>, ctx: Context): Tags {
return { type: schema.type, values }
}

// Whether 3.0 requires the property in one direction only, as it applies
// `required` to a `readOnly` property in responses only, and to a `writeOnly`
// one in requests only. Read through `$ref`s, as 3.0 reads each target in its
// place.
function isOneWay(value: unknown, ctx: Context): boolean {
const visited = new Set<unknown>()
let part = value
while (isRecord(part) && !visited.has(part)) {
if (part.readOnly === true || part.writeOnly === true) {
return true
}
const ref = getRef(part)
if (ref === undefined) {
return false
}
visited.add(part)
part = resolvePointer(ref, ctx)
}
return false
}

// Whether no value matches both: they have different types, other than an
// integer and a number, or one requires a property that the other holds to
// other values, as OpenAI, Zod and oRPC write a tagged union.
Expand Down Expand Up @@ -1441,7 +1465,13 @@ function finishSchema(
schema.const !== undefined &&
Array.isArray(schema.enum) &&
!includesJson(schema.enum, schema.const)
let loose = loosen(out, schema, exactTuple, ctx) || constOutsideEnum
// 3.0 requires a `readOnly` property in responses only and a `writeOnly` one
// in requests only, where 2020-12 requires both everywhere.
let loose =
loosen(out, schema, exactTuple, ctx) ||
constOutsideEnum ||
schema.readOnly === true ||
schema.writeOnly === true
// 3.0 lacks `patternProperties`, and `additionalProperties` beside it is
// dropped, as it would reject the properties `patternProperties` allowed.
// Code generators read a missing one as allowing no other properties.
Expand Down
63 changes: 63 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 @@ -2990,6 +2990,69 @@ describe('downgradeSchemaV31ToV30', () => {
).toEqual({})
})

it('includes a readOnly or writeOnly schema, which 3.0 requires one way only', () => {
// 3.0 applies required to a readOnly property in responses only, so a
// request without it matches the object, which the not then rejects.
const id = { type: 'string', readOnly: true } as const
const withId: OpenAPIV3_1.SchemaObject = {
type: 'object',
properties: { id },
required: ['id'],
}
expect(downgradeSchemaV31ToV30({ not: withId })).toEqual({})
expect(downgradeSchemaV31ToV30({ not: { type: 'string', writeOnly: true } })).toEqual({})
// Also an optional one, and through a chain of $refs.
const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` })
expect(
convert({
components: {
schemas: {
Not: { not: { type: 'object', properties: { id: ref('IdAlias') } } },
IdAlias: ref('Id'),
Id: id,
},
},
}).components?.schemas?.Not,
).toEqual({})
// Nor does such a property tell branches apart as a required tag.
const tagged = (kind: string, more: object = {}): OpenAPIV3_1.SchemaObject => ({
type: 'object',
properties: { kind: { const: kind, ...more } },
required: ['kind'],
})
const tagged30 = (kind: string, more: object = {}) => ({
type: 'object',
properties: { kind: { type: 'string', enum: [kind], ...more } },
required: ['kind'],
})
// A request without either tag matches both.
const readOnly = { readOnly: true }
expect(
downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b', readOnly)] }),
).toEqual({ anyOf: [tagged30('a', readOnly), tagged30('b', readOnly)] })
// One required both ways still tells them apart.
expect(downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b')] })).toEqual({
oneOf: [tagged30('a', readOnly), tagged30('b')],
})
// A cycle of $refs, which never reaches a schema, holds neither keyword.
const loop: OpenAPIV3_1.SchemaObject = {
type: 'object',
properties: { kind: ref('Loop') },
required: ['kind'],
}
expect(
convert({
components: {
schemas: {
Union: { oneOf: [id, loop] },
Loop: ref('Back'),
Back: ref('Loop'),
},
},
}).components?.schemas?.Union,
).toEqual({ oneOf: [id, loop] })
})

it('includes a const outside the enum beside it, which matched nothing', () => {
expect(downgradeSchemaV31ToV30({ not: { const: 'a', enum: ['b'] } })).toEqual({})
expect(downgradeSchemaV31ToV30({ not: { const: 'a', enum: [] } })).toEqual({})
Expand Down
Loading