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
18 changes: 5 additions & 13 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ 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>()

export interface Context {
readonly resolve: (ref: string) => unknown
readonly locate: (ref: string) => Location
Expand Down Expand Up @@ -178,12 +176,6 @@ 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
}

export function allOfItems(allOf: unknown): unknown[] {
if (Array.isArray(allOf)) {
return allOf
Expand Down Expand Up @@ -953,7 +945,7 @@ export function removedPrefixes(tables: Readonly<Record<string, Fields>>): strin
)
}

function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | undefined): boolean {
function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | undefined, isTarget: (value: unknown) => boolean): boolean {
if (tokens === undefined) {
return false
}
Expand All @@ -969,10 +961,10 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] |
return false
}
}
return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to))
return to === undefined || !isTarget(to)
}

export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown {
export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = [], isTarget: (value: unknown) => boolean = () => true): unknown {
const locations = new Map<string, Location>()
const locateRef = (ref: string): Location => {
let location = locations.get(ref)
Expand Down Expand Up @@ -1026,7 +1018,7 @@ export function downgrade(root: unknown, convert: Convert, removed: readonly str
copies: new Map(),
dangles: (ref) => {
if (!dangling.has(ref) && !kept.has(ref)) {
if ((isRemovedPart(ref) || (previous !== root && danglesIn(previous, root, parsePointer(ref)))) && isInlinable(ref)) {
if ((isRemovedPart(ref) || (previous !== root && danglesIn(previous, root, parsePointer(ref), isTarget))) && isInlinable(ref)) {
dangling.add(ref)
}
else {
Expand All @@ -1045,7 +1037,7 @@ export function downgrade(root: unknown, convert: Convert, removed: readonly str
})
let stale = false
for (const ref of kept) {
if ((dangling.has(ref) || danglesIn(out, root, parsePointer(ref))) && isInlinable(ref)) {
if ((dangling.has(ref) || danglesIn(out, root, parsePointer(ref), isTarget)) && isInlinable(ref)) {
dangling.add(ref)
stale = true
}
Expand Down
17 changes: 14 additions & 3 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
list,
map,
mergeRef,
placeholder,
rebasedRef,
refOr,
removedPrefixes,
Expand Down Expand Up @@ -72,6 +71,8 @@ const CONTENT_TYPE_OVERRIDES = ['allowReserved', 'contentType', 'explode', 'styl

const LOOSE = new WeakSet<object>()

const PLACEHOLDERS = new WeakSet<object>()

const convertCallback = map(convertPathItem, isNotExtension)
const convertContent = map(convertMediaType)
const convertRequestContent = map(convertRequestMediaType)
Expand Down Expand Up @@ -200,6 +201,16 @@ function reference(value: Record<string, unknown>): unknown {
return { $ref: value.$ref }
}

function isRefTarget(value: unknown): boolean {
return typeof value !== 'boolean' && !PLACEHOLDERS.has(value as object)
}

function placeholder(): object {
const out = {}
PLACEHOLDERS.add(out)
return out
}

function isLoose(value: unknown): boolean {
return LOOSE.has(value as object)
}
Expand Down Expand Up @@ -545,9 +556,9 @@ function convertDocument(value: unknown, ctx: Context): unknown {
}

export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject {
return downgrade(spec, convertDocument, REMOVED) as OpenAPIV3_0.OpenAPIObject
return downgrade(spec, convertDocument, REMOVED, isRefTarget) as OpenAPIV3_0.OpenAPIObject
}

export function downgradeSchemaV31ToV30<T = unknown>(schema: OpenAPIV3_1.SchemaObject<T>): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject<T> {
return downgrade(schema, convertSchema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject<T>
return downgrade(schema, convertSchema, [], isRefTarget) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject<T>
}
51 changes: 51 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 @@ -204,3 +204,54 @@ describe('references inside a schema with an $id', () => {
})).toEqual({ properties: { x: { properties: { y: { type: 'string' } } }, z: { allOf: [{ type: 'string' }] } } })
})
})

describe('references to a boolean additionalProperties', () => {
// `additionalProperties` is the one place 3.0 still takes a boolean, so it
// stays one (see subschemas.test.ts). But a 3.0 `$ref` never resolves to a
// boolean: a Schema Object is always an object.
// https://spec.openapis.org/oas/v3.0.4.html#schema-object
// Such a `$ref` is replaced by the converted boolean schema instead. A
// boolean schema converted in place becomes a Schema Object, so a `$ref`
// to it stays as written.
it.each([
[
'inlines a $ref to a false additionalProperties',
{ additionalProperties: false, properties: { a: { $ref: '#/additionalProperties' } } },
{ additionalProperties: false, properties: { a: { not: {} } } },
],
[
'inlines a $ref to a true additionalProperties under not',
{ additionalProperties: true, properties: { a: { not: { $ref: '#/additionalProperties' } } } },
{ additionalProperties: true, properties: { a: { not: {} } } },
],
[
'inlines a $ref with siblings into allOf',
{ $ref: '#/additionalProperties', additionalProperties: false },
{ additionalProperties: false, allOf: [{ not: {} }] },
],
[
'keeps a $ref to the location that now holds the inlined schema',
{
properties: {
a: { $ref: '#/properties/m/additionalProperties' },
b: { $ref: '#/properties/a' },
m: { additionalProperties: false, type: 'object' },
},
},
{
properties: {
a: { not: {} },
b: { $ref: '#/properties/a' },
m: { additionalProperties: false, type: 'object' },
},
},
],
[
'keeps a $ref to a boolean schema converted in place',
{ items: false, properties: { a: { $ref: '#/items' } } },
{ items: { not: {} }, properties: { a: { $ref: '#/items' } } },
],
])('%s', (_name, input, expected) => {
expect(convertSchema(input)).toEqual(expected)
})
})
19 changes: 19 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 @@ -37,6 +37,25 @@ it('converts component callbacks and schemas, including boolean schemas', () =>
})
})

// 3.0 keeps a boolean `additionalProperties`, but a 3.0 `$ref` never resolves
// to a boolean, so a `$ref` to one is replaced by the converted boolean schema
// (see schema/references.test.ts).
it('inlines a schema $ref to a boolean additionalProperties', () => {
expect(convertSpec({
components: {
schemas: {
M: { additionalProperties: false, type: 'object' },
X: { $ref: '#/components/schemas/M/additionalProperties' },
},
},
}).components).toEqual({
schemas: {
M: { additionalProperties: false, type: 'object' },
X: { not: {} },
},
})
})

it('strips the overrides from a callback reference to a missing path item', () => {
expect(convertComponent('callbacks', { $ref: '#/components/pathItems/Reusable', summary: 's' })).toEqual({
$ref: '#/components/pathItems/Reusable',
Expand Down
Loading