diff --git a/lib/spec/openapi/index.js b/lib/spec/openapi/index.js index a309822b..a145b4bb 100644 --- a/lib/spec/openapi/index.js +++ b/lib/spec/openapi/index.js @@ -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 diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index 8845a55c..8d330ad7 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -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) diff --git a/lib/spec/swagger/index.js b/lib/spec/swagger/index.js index cdf722f4..fdbc8503 100644 --- a/lib/spec/swagger/index.js +++ b/lib/spec/swagger/index.js @@ -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 diff --git a/lib/spec/swagger/utils.js b/lib/spec/swagger/utils.js index af9db7ad..96ea2f68 100644 --- a/lib/spec/swagger/utils.js +++ b/lib/spec/swagger/utils.js @@ -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 } diff --git a/lib/util/add-hook.js b/lib/util/add-hook.js index 6410df0a..0e5d4cc4 100644 --- a/lib/util/add-hook.js +++ b/lib/util/add-hook.js @@ -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 = [] @@ -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 } } } diff --git a/lib/util/definitions.js b/lib/util/definitions.js index 860d239c..33bf3ab2 100644 --- a/lib/util/definitions.js +++ b/lib/util/definitions.js @@ -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} [ignore] - Names of the definitions to leave as they are. + * @returns {Record} 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, diff --git a/test/spec/openapi/refs.test.js b/test/spec/openapi/refs.test.js index f0ac5314..dae4409c 100644 --- a/test/spec/openapi/refs.test.js +++ b/test/spec/openapi/refs.test.js @@ -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' } }) +}) diff --git a/test/spec/swagger/refs.test.js b/test/spec/swagger/refs.test.js index 5bf645ab..fe02c786 100644 --- a/test/spec/swagger/refs.test.js +++ b/test/spec/swagger/refs.test.js @@ -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' } }) +}) diff --git a/test/util.test.js b/test/util.test.js index 047c5423..16998a22 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -166,6 +166,7 @@ describe('definitions', () => { const { prepareSharedSchemas, hoistDefinitions, + referenceInlineDefinitions, rewriteAnchorRefs, rewriteHoistedRefs } = require('../lib/util/definitions') @@ -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' } + } + }) + }) })