Skip to content
Open
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
5 changes: 5 additions & 0 deletions lib/spec/openapi/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ module.exports = function (opts, cache, routes, Ref) {
openapiObject.paths[url] = openapiRoute
}

// the schemas with an `$id` nested in the route schemas (eg. TypeBox
// recursive types) are referenced as `#/components/schemas/def-N`:
// they must be emitted as well
Object.assign(openapiObject.components.schemas, prepareOpenapiSchemas(defOpts, ref.inlineDefinitions, ref, hoisted))

rewriteHoistedRefs(openapiObject, '#/components/schemas/', hoisted)

const transformObjectResult = defOpts.transformObject
Expand Down
2 changes: 1 addition & 1 deletion lib/spec/openapi/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ function prepareOpenapiSchemas (opts, jsonSchemas, ref, hoisted = new Map()) {
const resolvedJsonSchema = ref.resolve(jsonSchema, { externalSchemas })
// OpenAPI does not support the `definitions` keyword: before it gets
// dropped by the conversion, the definitions are moved to the top-level
const hoistedSchemas = hoistDefinitions(schemaName, resolvedJsonSchema, jsonSchemas, hoisted)
const hoistedSchemas = hoistDefinitions(schemaName, resolvedJsonSchema, { ...ref.definitions().definitions, ...jsonSchemas }, hoisted)

for (const [name, schema] of [[schemaName, resolvedJsonSchema], ...hoistedSchemas]) {
const openapiSchema = convertJsonSchemaToOpenapi3(opts, schema)
Expand Down
5 changes: 5 additions & 0 deletions lib/spec/swagger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ module.exports = function (opts, cache, routes, Ref) {
swaggerObject.paths[url] = swaggerRoute
}

// the schemas with an `$id` nested in the route schemas (eg. TypeBox
// recursive types) are referenced as `#/definitions/def-N`:
// they must be emitted as well
Object.assign(swaggerObject.definitions, prepareSwaggerDefinitions(ref.inlineDefinitions, ref, hoisted))

rewriteHoistedRefs(swaggerObject, '#/definitions/', hoisted)

const transformObjectResult = defOpts.transformObject
Expand Down
2 changes: 1 addition & 1 deletion lib/spec/swagger/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ function prepareSwaggerDefinitions (definitions, ref, hoisted = new Map()) {
// Swagger doesn't accept the `definitions` keyword either:
// the nested definitions are moved to the top-level ones
res[name] = resolved
for (const [hoistedName, schema] of hoistDefinitions(name, resolved, definitions, hoisted)) {
for (const [hoistedName, schema] of hoistDefinitions(name, resolved, { ...ref.definitions().definitions, ...definitions }, hoisted)) {
delete schema.$id
res[hoistedName] = schema
}
Expand Down
31 changes: 28 additions & 3 deletions lib/util/add-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const Ref = require('json-schema-resolver')
const cloner = require('rfdc')({ proto: true, circles: false })
const { prepareSharedSchemas, rewriteAnchorRefs } = require('./definitions')
const { prepareSharedSchemas, referenceInlineDefinitions, rewriteAnchorRefs } = require('./definitions')

function addHook (fastify, pluginOptions) {
const routes = []
Expand Down Expand Up @@ -77,14 +77,39 @@ function addHook (fastify, pluginOptions) {
pluginOptions.refResolver,
{ clone: true, externalSchemas })
)
if (anchors.size === 0) return ref

// The ref resolver does not support the references to an anchor
// (`http://foo/common.json#address`): they are converted to JSON pointers
// before the resolution. The clone avoids touching the route schemas.
const resolve = anchors.size === 0
? ref.resolve
: (schema, opts) => ref.resolve(rewriteAnchorRefs(cloner(schema), anchors), opts)

// The ref resolver collects the subschemas having an `$id` (eg. TypeBox
// recursive types) and rewrites the references to them, but it does not
// emit them: they are replaced by a reference and collected here, to be
// added to the top-level definitions of the document.
const sharedNames = new Set(Object.keys(ref.definitions().definitions))
const inlineDefinitions = {}

return {
definitions: ref.definitions,
resolve: (schema, opts) => ref.resolve(rewriteAnchorRefs(cloner(schema), anchors), opts)
resolve (schema, opts) {
const resolved = resolve(schema, opts)
// the schemas nested in a collected one are collected as well
const queue = [resolved]
while (queue.length > 0) {
const found = referenceInlineDefinitions(queue.shift(), ref, sharedNames)
for (const name of Object.keys(found)) {
if (Object.hasOwn(inlineDefinitions, name)) continue
inlineDefinitions[name] = found[name]
queue.push(found[name])
}
}
return resolved
},
// the definitions collected from the route schemas, by name
inlineDefinitions
}
}
}
Expand Down
50 changes: 50 additions & 0 deletions lib/util/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,58 @@ function rewriteHoistedRefs (node, prefix, hoisted) {
}
}

/**
* The ref resolver collects the subschemas having an `$id` (eg. the ones
* produced by TypeBox `Type.Recursive()`) as definitions, and rewrites the
* references to them as `#/definitions/def-N`, but it leaves them inline.
* Replaces each of them with a reference, so that the schema is emitted once,
* as a top-level definition.
* @param {object} schema - Resolved and cloned schema; mutated in place.
* @param {object} ref - Ref resolver instance.
* @param {Set<string>} [ignore] - Names of the definitions to leave as they are.
* @returns {Record<string, object>} The `name -> schema` inline definitions found.
*/
function referenceInlineDefinitions (schema, ref, ignore = new Set()) {
const names = new Map()
const definitions = ref.definitions().definitions
for (const name of Object.keys(definitions)) {
const { $id } = definitions[name]
if (typeof $id === 'string' && !ignore.has(name)) names.set($id, name)
}

const found = {}
const replacements = []
walkSchema(schema, (subschema, pointer) => {
if (pointer.length === 0) return
// the ref resolver adds the consumed definitions to the root `definitions`:
// those are moved to the top-level by `hoistDefinitions`
if (pointer.length === 2 && DEFINITIONS_KEYWORDS.includes(pointer[0])) return
// a fragment-only $id is an anchor, not a schema resource
if (typeof subschema.$id !== 'string' || subschema.$id[0] === '#') return
// the resolver ignores the duplicates of an `$id` (eg. the same schema
// used by two routes): they are matched by `$id`, not by identity
const name = names.get(subschema.$id)
if (name === undefined) return
found[name] = definitions[name]
replacements.push([pointer.slice(), name])
})

// A nested match is inside a definition (replaced as a whole): skip it
const replaced = []
for (const [pointer, name] of replacements) {
if (replaced.some((prefix) => prefix.every((token, i) => pointer[i] === token))) continue
replaced.push(pointer)
let parent = schema
for (let i = 0; i < pointer.length - 1; i++) parent = parent[pointer[i]]
parent[pointer[pointer.length - 1]] = { $ref: `#/definitions/${name}` }
}

return found
}

module.exports = {
prepareSharedSchemas,
referenceInlineDefinitions,
rewriteAnchorRefs,
hoistDefinitions,
unknownSchemas,
Expand Down
51 changes: 51 additions & 0 deletions test/spec/openapi/refs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -788,3 +788,54 @@ for (const [name, option, getSchemas, prefix] of definitionsCases) {
t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0/properties/shipping"`))
})
}

// https://github.com/fastify/fastify-swagger/issues/865
test('openapi: support recursive schemas with an `$id` nested in a route schema', async (t) => {
const fastify = Fastify()
await fastify.register(fastifySwagger, { openapi: {} })

// what TypeBox `Type.Recursive(..., { $id: 'Node' })` produces
const node = {
$id: 'Node',
type: 'object',
properties: {
name: { type: 'string' },
children: { type: 'array', items: { $ref: 'Node' } }
}
}
const body = { type: 'object', properties: { treeNodes: { type: 'array', items: node } } }
fastify.post('/', { schema: { body } }, () => {})
fastify.put('/', { schema: { body } }, () => {})

await fastify.ready()

const document = fastify.swagger()
await Swagger.validate(JSON.parse(JSON.stringify(document)))

const schemas = document.components.schemas
t.assert.deepStrictEqual(Object.keys(schemas), ['def-0'])
t.assert.deepStrictEqual(schemas['def-0'].properties.children.items, { $ref: '#/components/schemas/def-0' })
t.assert.deepStrictEqual(document.paths['/'].post.requestBody.content['application/json'].schema.properties.treeNodes.items, { $ref: '#/components/schemas/def-0' })
t.assert.deepStrictEqual(document.paths['/'].put, document.paths['/'].post)
})

test('openapi: support schemas with an `$id` nested in another one', async (t) => {
const fastify = Fastify()
await fastify.register(fastifySwagger, { openapi: {} })

fastify.addSchema({ $id: 'Shared', type: 'object', properties: { name: { type: 'string' } } })
const leaf = { $id: 'Leaf', type: 'object', properties: { shared: { $ref: 'Shared#' } } }
const tree = { $id: 'Tree', type: 'object', properties: { leaf, leaves: { type: 'array', items: { $ref: 'Leaf' } } } }
fastify.post('/', { schema: { body: { type: 'object', properties: { tree } } } }, () => {})

await fastify.ready()

const document = fastify.swagger()
await Swagger.validate(JSON.parse(JSON.stringify(document)))

const schemas = document.components.schemas
t.assert.deepStrictEqual(Object.keys(schemas).sort(), ['def-0', 'def-1', 'def-2'])
t.assert.deepStrictEqual(document.paths['/'].post.requestBody.content['application/json'].schema.properties.tree, { $ref: '#/components/schemas/def-1' })
t.assert.deepStrictEqual(schemas['def-1'].properties, { leaf: { $ref: '#/components/schemas/def-2' }, leaves: { type: 'array', items: { $ref: '#/components/schemas/def-2' } } })
t.assert.deepStrictEqual(schemas['def-2'].properties, { shared: { $ref: '#/components/schemas/def-0' } })
})
51 changes: 51 additions & 0 deletions test/spec/swagger/refs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,54 @@ for (const [name, option, getSchemas, prefix] of definitionsCases) {
t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0/properties/shipping"`))
})
}

// https://github.com/fastify/fastify-swagger/issues/865
test('swagger: support recursive schemas with an `$id` nested in a route schema', async (t) => {
const fastify = Fastify()
await fastify.register(fastifySwagger, { swagger: {} })

// what TypeBox `Type.Recursive(..., { $id: 'Node' })` produces
const node = {
$id: 'Node',
type: 'object',
properties: {
name: { type: 'string' },
children: { type: 'array', items: { $ref: 'Node' } }
}
}
const body = { type: 'object', properties: { treeNodes: { type: 'array', items: node } } }
fastify.post('/', { schema: { body } }, () => {})
fastify.put('/', { schema: { body } }, () => {})

await fastify.ready()

const document = fastify.swagger()
await Swagger.validate(JSON.parse(JSON.stringify(document)))

const schemas = document.definitions
t.assert.deepStrictEqual(Object.keys(schemas), ['def-0'])
t.assert.deepStrictEqual(schemas['def-0'].properties.children.items, { $ref: '#/definitions/def-0' })
t.assert.deepStrictEqual(document.paths['/'].post.parameters[0].schema.properties.treeNodes.items, { $ref: '#/definitions/def-0' })
t.assert.deepStrictEqual(document.paths['/'].put, document.paths['/'].post)
})

test('swagger: support schemas with an `$id` nested in another one', async (t) => {
const fastify = Fastify()
await fastify.register(fastifySwagger, { swagger: {} })

fastify.addSchema({ $id: 'Shared', type: 'object', properties: { name: { type: 'string' } } })
const leaf = { $id: 'Leaf', type: 'object', properties: { shared: { $ref: 'Shared#' } } }
const tree = { $id: 'Tree', type: 'object', properties: { leaf, leaves: { type: 'array', items: { $ref: 'Leaf' } } } }
fastify.post('/', { schema: { body: { type: 'object', properties: { tree } } } }, () => {})

await fastify.ready()

const document = fastify.swagger()
await Swagger.validate(JSON.parse(JSON.stringify(document)))

const schemas = document.definitions
t.assert.deepStrictEqual(Object.keys(schemas).sort(), ['def-0', 'def-1', 'def-2'])
t.assert.deepStrictEqual(document.paths['/'].post.parameters[0].schema.properties.tree, { $ref: '#/definitions/def-1' })
t.assert.deepStrictEqual(schemas['def-1'].properties, { leaf: { $ref: '#/definitions/def-2' }, leaves: { type: 'array', items: { $ref: '#/definitions/def-2' } } })
t.assert.deepStrictEqual(schemas['def-2'].properties, { shared: { $ref: '#/definitions/def-0' } })
})
32 changes: 32 additions & 0 deletions test/util.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ describe('definitions', () => {
const {
prepareSharedSchemas,
hoistDefinitions,
referenceInlineDefinitions,
rewriteAnchorRefs,
rewriteHoistedRefs
} = require('../lib/util/definitions')
Expand Down Expand Up @@ -310,4 +311,35 @@ describe('definitions', () => {
})
t.assert.strictEqual(rewriteAnchorRefs(true, anchors), true)
})

test('referenceInlineDefinitions only replaces the known schema resources', (t) => {
const node = { $id: 'Node', type: 'object' }
const shared = { $id: 'Shared', type: 'object' }
const ref = { definitions: () => ({ definitions: { 'def-0': shared, 'def-1': node } }) }
const schema = {
$id: 'Root',
definitions: { 'def-0': shared },
properties: {
node: { $id: 'Node', type: 'object', properties: { child: { $id: 'Node', type: 'object' } } },
shared: { $id: 'Shared', type: 'object' },
anchor: { $id: '#anchor', type: 'object' },
unknown: { $id: 'Unknown', type: 'object' },
plain: { type: 'object' }
}
}

const found = referenceInlineDefinitions(schema, ref, new Set(['def-0']))
t.assert.deepStrictEqual(found, { 'def-1': node })
t.assert.deepStrictEqual(schema, {
$id: 'Root',
definitions: { 'def-0': shared },
properties: {
node: { $ref: '#/definitions/def-1' },
shared: { $id: 'Shared', type: 'object' },
anchor: { $id: '#anchor', type: 'object' },
unknown: { $id: 'Unknown', type: 'object' },
plain: { type: 'object' }
}
})
})
})
Loading