Skip to content
Closed
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
16 changes: 9 additions & 7 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const DROP: unique symbol = Symbol('drop')
// The document root as a `$ref`, and the base outside any schema with an `$id`.
const ROOT = '#'

const PLACEHOLDERS = new WeakSet<object>()
const REPLACED = new Map<string, WeakSet<object>>()

export interface Context {
readonly resolve: (ref: string) => unknown
Expand Down Expand Up @@ -178,10 +178,12 @@ export function hasType(type: unknown, name: string): boolean {
return type === name || (Array.isArray(type) && type.includes(name))
}

export function placeholder(): Record<string, unknown> {
const out = {}
PLACEHOLDERS.add(out)
return out
// Sets a value that stands in for whatever the source holds under `key`
// instead of converting it. A `$ref` into that source location then dangles,
// so it is inlined rather than kept pointing at the stand-in.
export function replace(target: Record<string, unknown>, key: string, value: unknown): void {
REPLACED.set(key, (REPLACED.get(key) ?? new WeakSet<object>()).add(target))
setOwn(target, key, value)
}

export function allOfItems(allOf: unknown): unknown[] {
Expand Down Expand Up @@ -964,12 +966,12 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] |
to = undefined
}
from = child(from, token)
to = child(to, token)
to = REPLACED.get(token)?.has(to as object) ? undefined : child(to, token)
if (from === undefined) {
return false
}
}
return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to))
return to === undefined
}

export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown {
Expand Down
10 changes: 5 additions & 5 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import {
list,
map,
mergeRef,
placeholder,
rebasedRef,
refOr,
removedPrefixes,
replace,
resourceOf,
setOwn,
} from './shared'
Expand Down Expand Up @@ -269,7 +269,7 @@ function convertType(out: Record<string, unknown>, type: unknown): boolean {
if (rest.length === 1) {
out.type = rest[0]
if (nullable) {
out.nullable = true
replace(out, 'nullable', true)
}
}
else if (rest.length > 1) {
Expand All @@ -292,7 +292,7 @@ function convertType(out: Record<string, unknown>, type: unknown): boolean {
out.enum = [null]
}
else {
out.not = {}
replace(out, 'not', {})
}
return false
}
Expand All @@ -313,7 +313,7 @@ function finishSchema(out: Record<string, unknown>, schema: Record<string, unkno
}
if (has(schema, 'const')) {
loose ||= has(schema, 'enum') && !(Array.isArray(schema.enum) && schema.enum.includes(schema.const))
out.enum = [clone(schema.const)]
replace(out, 'enum', [clone(schema.const)])
}
loose = convertType(out, schema.type) || loose
const { exclusiveMaximum, exclusiveMinimum, maximum, minimum } = schema
Expand All @@ -338,7 +338,7 @@ function finishSchema(out: Record<string, unknown>, schema: Record<string, unkno
}
}
if (out.type === 'array' && out.items === undefined) {
out.items = placeholder()
replace(out, 'items', {})
}
return loose ? loosened(out) : out
}
Expand Down
28 changes: 28 additions & 0 deletions packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ describe('references into removed keywords', () => {
})
})

// `const`, a null-only `type`, and `"null"` in `type` write new values over
// `enum`, `not`, and `nullable`. A `$ref` to the original keyword must get
// the original schema, not the value that replaced it.
it.each([
[
'a not that a null-only type replaces',
{ not: { $ref: '#/properties/a/not' }, properties: { a: { enum: ['x'], not: { type: 'string' }, type: 'null' } } },
{ not: { type: 'string' }, properties: { a: { enum: ['x'], not: {} } } },
],
[
'an enum that const replaces',
{ properties: { t: { const: { type: 'integer' }, enum: [{ type: 'string' }] }, x: { $ref: '#/properties/t/enum/0' } } },
{ properties: { t: { enum: [{ type: 'integer' }] }, x: { type: 'string' } } },
],
[
'an enum that a null const replaces and a null-only type narrows',
{ properties: { t: { const: null, enum: [false], type: 'null' }, x: { $ref: '#/properties/t/enum/0' } } },
{ properties: { t: { enum: [null] }, x: { not: {} } } },
],
[
'a 3.0 nullable that null in type replaces',
{ not: { $ref: '#/properties/a/nullable' }, properties: { a: { nullable: false, type: ['string', 'null'] } } },
{ not: { not: {} }, properties: { a: { nullable: true, type: 'string' } } },
],
])('inlines a $ref to %s, rather than pointing it at the replacement', (_name, input, expected) => {
expect(convertSchema(input)).toEqual(expected)
})

// A standalone schema has no document around it, so pointers into
// `components` or `webhooks` cannot be checked and stay as written.
it('leaves references and mapping entries that point outside the schema as written', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ it('keeps examples as they are, since 3.0 examples have the same fields', () =>
})
})

// The conversion replaces this `not` with `not: {}` (see
// schema/references.test.ts), so the `$ref` to it gets the original.
it('inlines a schema $ref to a keyword the conversion replaced', () => {
expect(convertSpec({
components: {
schemas: {
A: { enum: ['x'], not: { type: 'string' }, type: 'null' },
B: { not: { $ref: '#/components/schemas/A/not' } },
},
},
}).components).toEqual({
schemas: {
A: { enum: ['x'], not: {} },
B: { not: { type: 'string' } },
},
})
})

it('clones a malformed components value unchanged', () => {
expect(convertSpec({ components: 'junk' }).components).toBe('junk')
})
Expand Down
Loading