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 @@ -254,7 +254,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc

## Performance

Each object is converted once, however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output.
Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output.

Two shapes no generator emits fall outside this. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first.

Expand Down
29 changes: 25 additions & 4 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export interface Context {
readonly targets: Map<string, unknown>
/** The output of each object converted so far, per field table, so a shared object is converted once and a cycle ends. */
readonly seen: Map<Fields, Map<object, Record<string, unknown>>>
/** The `seen` outside any inlined target, which each keeps its own. */
readonly shared: Context['seen']
/** The `seen` of each target inlined so far. */
readonly apart: Map<unknown, Context['seen']>
/** The targets of the `$ref`s being inlined, so a reference back into one stops there. */
readonly inlining: Set<unknown>
}
Expand Down Expand Up @@ -239,19 +243,36 @@ export function resolve(ref: string, ctx: Context): unknown {
/**
* Converts the target of `ref` to stand in for the reference. Returns `DROP`
* when the target is missing, or when it is already being inlined around
* this point, where inlining it again would never end.
* this point, where inlining it again would never end. Each target keeps its
* own outputs, so a `$ref` into part of one being inlined, or around it,
* never reaches an output still being filled, which would then hold itself.
* With `shared`, for a caller that only reads the output, the target gets the
* one it has outside any inlined target.
*/
export function inline(ref: string, ctx: Context, convert: Convert): unknown {
export function inline(ref: string, ctx: Context, convert: Convert, shared = false): unknown {
const target = resolvePointer(ref, ctx)
if (target === undefined || ctx.inlining.has(target)) {
return DROP
}
let seen = shared ? ctx.shared : ctx.apart.get(target)
if (seen === undefined) {
seen = new Map()
ctx.apart.set(target, seen)
}
ctx.inlining.add(target)
const out = convert(target, ctx)
const out = convert(target, seen === ctx.seen ? ctx : { ...ctx, seen })
ctx.inlining.delete(target)
return out
}

export function downgrade(root: unknown, convert: Convert): unknown {
return convert(root, { inlining: new Set(), root, seen: new Map(), targets: new Map() })
const seen: Context['seen'] = new Map()
return convert(root, {
apart: new Map(),
inlining: new Set(),
root,
seen,
shared: seen,
targets: new Map(),
})
}
2 changes: 1 addition & 1 deletion packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function convertSchemaRef(ref: string, ctx: Context): unknown {
// 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.
const out = { $ref: ref }
return isLoose(inline(ref, ctx, convertSchema)) ? loosened(out) : out
return isLoose(inline(ref, ctx, convertSchema, true)) ? loosened(out) : out
}
const out = inline(ref, ctx, convertSchema)
return out === DROP ? loosened({}) : out
Expand Down
118 changes: 118 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 @@ -239,6 +239,34 @@ describe('downgradeSpecV31ToV30', () => {
{ in: 'query', name: 'q', schema: { type: 'integer', enum: [1] } },
])
})

it('inlines a $ref into part of a webhook from inside it, cutting recursion with {}', () => {
const schema = {
type: 'object',
properties: {
next: { $ref: '#/webhooks/hook/post/requestBody/content/application~1json/schema' },
},
} as const
const out = convert({
paths: { '/a': { $ref: '#/webhooks/hook' } },
webhooks: {
hook: {
post: { requestBody: { content: { 'application/json': { schema } } }, responses },
},
},
})
expect(out.paths['/a']?.post?.requestBody).toEqual({
content: {
'application/json': {
schema: {
type: 'object',
properties: { next: { type: 'object', properties: { next: {} } } },
},
},
},
})
expect(() => JSON.stringify(out)).not.toThrow()
})
})

describe('mutual TLS', () => {
Expand Down Expand Up @@ -825,6 +853,96 @@ describe('downgradeSchemaV31ToV30', () => {
})
})

it('inlines a $ref into part of a $defs entry from inside it, cutting recursion with {}', () => {
const out = downgradeSchemaV31ToV30({
type: 'object',
properties: { a: { $ref: '#/$defs/A' } },
$defs: {
A: {
type: 'object',
properties: {
child: {
type: 'object',
properties: { next: { $ref: '#/$defs/A/properties/child' } },
},
},
},
},
})
expect(out).toEqual({
type: 'object',
properties: {
a: {
type: 'object',
properties: {
child: {
type: 'object',
properties: { next: { type: 'object', properties: { next: {} } } },
},
},
},
},
})
expect(() => JSON.stringify(out)).not.toThrow()
})

it('inlines a $ref into part of a $defs entry from another entry inlined inside it', () => {
const out = downgradeSchemaV31ToV30({
type: 'object',
properties: { a: { $ref: '#/$defs/A' } },
$defs: {
A: {
type: 'object',
properties: { c: { type: 'object', properties: { go: { $ref: '#/$defs/B' } } } },
},
B: { type: 'object', properties: { back: { $ref: '#/$defs/A/properties/c' } } },
},
})
const back = { type: 'object', properties: { go: {} } }
expect(out).toEqual({
type: 'object',
properties: {
a: {
type: 'object',
properties: {
c: {
type: 'object',
properties: { go: { type: 'object', properties: { back } } },
},
},
},
},
})
expect(() => JSON.stringify(out)).not.toThrow()
})

it('inlines a $ref to the $defs entry around a part being inlined', () => {
const out = downgradeSchemaV31ToV30({
type: 'object',
properties: { p: { $ref: '#/$defs/A/properties/c' }, q: { $ref: '#/$defs/A' } },
$defs: {
A: {
type: 'object',
properties: {
c: {
type: 'object',
properties: { n: { $ref: '#/$defs/A/properties/c' }, up: { $ref: '#/$defs/A' } },
},
},
},
},
})
const a = {
type: 'object',
properties: { c: { type: 'object', properties: { n: {}, up: {} } } },
}
expect(out).toEqual({
type: 'object',
properties: { p: { type: 'object', properties: { n: {}, up: a } }, q: a },
})
expect(() => JSON.stringify(out)).not.toThrow()
})

it('reads definitions, the draft-07 name of $defs, as $defs', () => {
expect(
downgradeSchemaV31ToV30({
Expand Down
Loading