From a9d73ac808fd4395b4d9da9d3958184ca9ed299b Mon Sep 17 00:00:00 2001 From: Tony133 Date: Sun, 20 Sep 2026 11:33:20 +0200 Subject: [PATCH 1/3] fix: support $ref to definitions of shared schemas --- README.md | 23 +++ lib/spec/openapi/index.js | 6 +- lib/spec/openapi/utils.js | 17 +- lib/spec/swagger/index.js | 6 +- lib/spec/swagger/utils.js | 13 +- lib/util/add-hook.js | 17 +- lib/util/definitions.js | 243 +++++++++++++++++++++++++++ lib/util/resolve-schema-reference.js | 4 +- test/spec/openapi/refs.test.js | 183 ++++++++++++++++++++ test/spec/swagger/refs.test.js | 183 ++++++++++++++++++++ test/util.test.js | 131 +++++++++++++++ 11 files changed, 812 insertions(+), 14 deletions(-) create mode 100644 lib/util/definitions.js diff --git a/README.md b/README.md index 7f06a35f..d1701d3a 100644 --- a/README.md +++ b/README.md @@ -454,6 +454,29 @@ await fastify.register(require('@fastify/swagger'), { For details on `buildLocalReference` arguments, see the [documentation](https://github.com/Eomm/json-schema-resolver#usage-resolve-one-schema-against-external-schemas). +##### `definitions` and `$defs` of a shared schema + +Swagger and OpenAPI do not allow the `definitions` and `$defs` keywords inside a schema object. +The definitions nested into a schema added with `fastify.addSchema()` are moved next to it, named `-` +(a numeric suffix is appended when the name is already taken), and the `$ref`s pointing to them are updated: + +```js +fastify.addSchema({ + $id: 'http://example.com/common.json', + type: 'object', + definitions: { + address: { $id: '#address', type: 'object', properties: { city: { type: 'string' } } } + } +}) + +// both are rendered as `#/components/schemas/def-0-address` (`#/definitions/def-0-address` with Swagger) +{ $ref: 'http://example.com/common.json#/definitions/address' } +{ $ref: 'http://example.com/common.json#address' } +``` + +A reference to an anchor (a fragment-only `$id` like `#address`) must be written as the `$id` of the shared schema +followed by the anchor, or as `#address` from inside the shared schema itself. + #### Decorator diff --git a/lib/spec/openapi/index.js b/lib/spec/openapi/index.js index bfc37c6a..0ad6ad3f 100644 --- a/lib/spec/openapi/index.js +++ b/lib/spec/openapi/index.js @@ -2,6 +2,7 @@ const yaml = require('yaml') const { shouldRouteHide } = require('../../util/should-route-hide') +const { rewriteHoistedRefs } = require('../../util/definitions') const { prepareDefaultOptions, prepareOpenapiObject, prepareOpenapiMethod, prepareOpenapiSchemas, normalizeUrl, resolveServerUrls } = require('./utils') module.exports = function (opts, cache, routes, Ref) { @@ -20,10 +21,11 @@ module.exports = function (opts, cache, routes, Ref) { const openapiObject = prepareOpenapiObject(defOpts) ref = Ref() + const hoisted = new Map() openapiObject.components.schemas = prepareOpenapiSchemas(defOpts, { ...openapiObject.components.schemas, ...(ref.definitions().definitions) - }, ref) + }, ref, hoisted) const serverUrls = resolveServerUrls(defOpts.servers) @@ -70,6 +72,8 @@ module.exports = function (opts, cache, routes, Ref) { openapiObject.paths[url] = openapiRoute } + rewriteHoistedRefs(openapiObject, '#/components/schemas/', hoisted) + const transformObjectResult = defOpts.transformObject ? defOpts.transformObject({ openapiObject }) : openapiObject diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index 4d617e6d..84872906 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -3,6 +3,7 @@ const { readPackageJson } = require('../../util/read-package-json') const { formatParamUrl } = require('../../util/format-param-url') const { resolveLocalRef } = require('../../util/resolve-local-ref') +const { hoistDefinitions, unknownSchemas } = require('../../util/definitions') const { resolveSchemaReference } = require('../../util/resolve-schema-reference') const { xResponseDescription, xConsume, xExamples } = require('../../constants') const { rawRequired } = require('../../symbols') @@ -587,17 +588,23 @@ function convertJsonSchemaToOpenapi3 (opts, jsonSchema) { return openapiSchema } -function prepareOpenapiSchemas (opts, jsonSchemas, ref) { +function prepareOpenapiSchemas (opts, jsonSchemas, ref, hoisted = new Map()) { const openapiSchemas = {} + const externalSchemas = [unknownSchemas(jsonSchemas, ref)] for (const schemaName of Object.keys(jsonSchemas)) { const jsonSchema = { ...jsonSchemas[schemaName] } - const resolvedJsonSchema = ref.resolve(jsonSchema, { externalSchemas: [jsonSchemas] }) - const openapiSchema = convertJsonSchemaToOpenapi3(opts, resolvedJsonSchema) - resolveSchemaExamplesRecursive(openapiSchema) + 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) - openapiSchemas[schemaName] = openapiSchema + for (const [name, schema] of [[schemaName, resolvedJsonSchema], ...hoistedSchemas]) { + const openapiSchema = convertJsonSchemaToOpenapi3(opts, schema) + resolveSchemaExamplesRecursive(openapiSchema) + openapiSchemas[name] = openapiSchema + } } return openapiSchemas } diff --git a/lib/spec/swagger/index.js b/lib/spec/swagger/index.js index c81f5d44..cdf722f4 100644 --- a/lib/spec/swagger/index.js +++ b/lib/spec/swagger/index.js @@ -2,6 +2,7 @@ const yaml = require('yaml') const { shouldRouteHide } = require('../../util/should-route-hide') +const { rewriteHoistedRefs } = require('../../util/definitions') const { prepareDefaultOptions, prepareSwaggerObject, prepareSwaggerMethod, normalizeUrl, prepareSwaggerDefinitions } = require('./utils') module.exports = function (opts, cache, routes, Ref) { @@ -19,10 +20,11 @@ module.exports = function (opts, cache, routes, Ref) { const swaggerObject = prepareSwaggerObject(defOpts) ref = Ref() + const hoisted = new Map() swaggerObject.definitions = prepareSwaggerDefinitions({ ...swaggerObject.definitions, ...(ref.definitions().definitions) - }, ref) + }, ref, hoisted) for (const route of routes) { const transformResult = route.config?.swaggerTransform !== undefined @@ -62,6 +64,8 @@ module.exports = function (opts, cache, routes, Ref) { swaggerObject.paths[url] = swaggerRoute } + rewriteHoistedRefs(swaggerObject, '#/definitions/', hoisted) + const transformObjectResult = defOpts.transformObject ? defOpts.transformObject({ swaggerObject }) : swaggerObject diff --git a/lib/spec/swagger/utils.js b/lib/spec/swagger/utils.js index 879a6842..af9db7ad 100644 --- a/lib/spec/swagger/utils.js +++ b/lib/spec/swagger/utils.js @@ -3,6 +3,7 @@ const { readPackageJson } = require('../../util/read-package-json') const { formatParamUrl } = require('../../util/format-param-url') const { resolveLocalRef } = require('../../util/resolve-local-ref') +const { hoistDefinitions, unknownSchemas } = require('../../util/definitions') const { resolveSchemaReference } = require('../../util/resolve-schema-reference') const { xResponseDescription, xConsume } = require('../../constants') const { generateParamsSchema } = require('../../util/generate-params-schema') @@ -329,19 +330,25 @@ function prepareSwaggerMethod (schema, ref, swaggerObject, url) { return swaggerMethod } -function prepareSwaggerDefinitions (definitions, ref) { +function prepareSwaggerDefinitions (definitions, ref, hoisted = new Map()) { + const externalSchemas = [unknownSchemas(definitions, ref)] return Object.entries(definitions) .reduce((res, [name, definition]) => { const _ = { ...definition } - const resolved = ref.resolve(_, { externalSchemas: [definitions] }) + const resolved = ref.resolve(_, { externalSchemas }) // Swagger doesn't accept $id on /definitions schemas. // The $ids are needed by Ref() to check the URI so we need // to remove them at the end of the process delete resolved.$id - delete resolved.definitions + // 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)) { + delete schema.$id + res[hoistedName] = schema + } return res }, {}) } diff --git a/lib/util/add-hook.js b/lib/util/add-hook.js index 346d063c..deb119c8 100644 --- a/lib/util/add-hook.js +++ b/lib/util/add-hook.js @@ -2,6 +2,7 @@ const Ref = require('json-schema-resolver') const cloner = require('rfdc')({ proto: true, circles: false }) +const { absolutizeLocalRefs, collectAnchors, rewriteAnchorRefs } = require('./definitions') function addHook (fastify, pluginOptions) { const routes = [] @@ -68,12 +69,24 @@ function addHook (fastify, pluginOptions) { if (hookRun === false) { throw new Error('.swagger() must be called after .ready()') } - const externalSchemas = cloner(Array.from(sharedSchemasMap.values())) - return Ref(Object.assign( + const externalSchemas = cloner(Array.from(sharedSchemasMap.values())).map(absolutizeLocalRefs) + const anchors = collectAnchors(externalSchemas) + rewriteAnchorRefs(externalSchemas, anchors) + + const ref = Ref(Object.assign( { applicationUri: 'todo.com' }, 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. + return { + definitions: ref.definitions, + resolve: (schema, opts) => ref.resolve(rewriteAnchorRefs(cloner(schema), anchors), opts) + } } } } diff --git a/lib/util/definitions.js b/lib/util/definitions.js new file mode 100644 index 00000000..3238b80c --- /dev/null +++ b/lib/util/definitions.js @@ -0,0 +1,243 @@ +'use strict' + +// Keywords whose value is a map of `name -> schema`. +// The keys of these maps are user defined names, not JSON Schema keywords. +const SCHEMA_MAP_KEYWORDS = new Set([ + 'properties', + 'patternProperties', + 'definitions', + '$defs', + 'dependencies' +]) + +// Keywords holding reusable schemas: not allowed by Swagger and OpenAPI 3.0 +const DEFINITIONS_KEYWORDS = ['definitions', '$defs'] + +// Keywords whose value is a schema or an array of schemas. +const SCHEMA_KEYWORDS = new Set([ + 'items', + 'additionalItems', + 'additionalProperties', + 'contains', + 'propertyNames', + 'not', + 'if', + 'then', + 'else', + 'allOf', + 'anyOf', + 'oneOf' +]) + +function isObject (value) { + return typeof value === 'object' && value !== null +} + +function escapeToken (token) { + return token.replace(/~/g, '~0').replace(/\//g, '~1') +} + +/** + * Visits a schema and all its subschemas. Differently from a plain deep walk, + * it does not mistake data (`enum`, `default`, `examples`...) or property + * names (a property called `definitions`) for JSON Schema keywords. + */ +function walkSchema (schema, visit, pointer = [], baseId, baseDepth = 0) { + if (!isObject(schema)) return + + if (Array.isArray(schema)) { + schema.forEach((item, i) => walkSchema(item, visit, [...pointer, `${i}`], baseId, baseDepth)) + return + } + + // A fragment-only $id (draft-07 anchor) does not change the base URI + if (typeof schema.$id === 'string' && schema.$id[0] !== '#') { + baseId = schema.$id.split('#', 1)[0] + // JSON pointers are relative to the closest schema resource + baseDepth = pointer.length + } + + visit(schema, pointer, baseId, baseDepth) + + for (const key of Object.keys(schema)) { + const value = schema[key] + if (SCHEMA_KEYWORDS.has(key)) { + walkSchema(value, visit, [...pointer, key], baseId, baseDepth) + } else if (SCHEMA_MAP_KEYWORDS.has(key) && isObject(value)) { + for (const name of Object.keys(value)) { + walkSchema(value[name], visit, [...pointer, key, name], baseId, baseDepth) + } + } + } +} + +/** + * In JSON Schema a local reference (`#/definitions/foo`) is relative to the + * closest schema resource. When a shared schema lands into the Swagger/OpenAPI + * document, `#` becomes the root of the whole document instead, so the + * reference would point to nowhere. Turning it into `<$id>#/definitions/foo` + * lets the ref resolver handle it as any other reference to a shared schema. + */ +function absolutizeLocalRefs (schema) { + walkSchema(schema, (subschema, _pointer, baseId) => { + if (baseId !== undefined && typeof subschema.$ref === 'string' && subschema.$ref[0] === '#') { + subschema.$ref = baseId + subschema.$ref + } + }) + return schema +} + +/** + * A fragment-only `$id` (`{ $id: '#address' }`) is a draft-07 anchor: the + * subschema can be referenced as `#address`. The ref resolver does not + * support it: it appends the fragment to the definition name as it is, + * producing `#/definitions/def-0address`. Since an anchor is nothing more than + * an alias of a JSON pointer, this function returns the map + * `#anchor -> #/json/pointer` to convert those references to the + * form the ref resolver (and then the hoisting) understands. + * + * The anchors are removed from the (cloned) schemas: once the references are + * converted nothing points to them anymore, the ref resolver would list each + * of them as a duplicated definition and Swagger does not accept `$id` at all. + */ +function collectAnchors (schemas) { + const anchors = new Map() + for (const schema of schemas) { + walkSchema(schema, (subschema, pointer, baseId, baseDepth) => { + const { $id } = subschema + if (baseId === undefined || typeof $id !== 'string' || $id[0] !== '#') return + + delete subschema.$id + const anchor = baseId + $id + // `#` is not an anchor and, as for the ref resolver, the first one wins + if ($id.length === 1 || anchors.has(anchor)) return + anchors.set(anchor, `${baseId}#${pointer.slice(baseDepth).map(token => `/${escapeToken(token)}`).join('')}`) + }) + } + return anchors +} + +/** + * Converts the references to an anchor into references to its JSON pointer. + * The input is not always a schema (eg: the map of the responses), so this is + * a plain deep walk: only the `$ref`s equal to a known anchor are touched. + */ +function rewriteAnchorRefs (node, anchors) { + if (!isObject(node)) return node + + if (Array.isArray(node)) { + for (const item of node) rewriteAnchorRefs(item, anchors) + return node + } + + if (typeof node.$ref === 'string' && anchors.has(node.$ref)) { + node.$ref = anchors.get(node.$ref) + } + + for (const key of Object.keys(node)) { + rewriteAnchorRefs(node[key], anchors) + } + return node +} + +/** + * The ref resolver already knows the shared schemas: mapping them again as + * external schemas of a single `resolve()` call evaluates their subschemas + * against the wrong base URI, and it throws when one of those has a + * fragment-only `$id` (`{ $id: '#address' }`). Only the schemas set by the + * user in the plugin options must be provided. + */ +function unknownSchemas (schemas, ref) { + const known = ref.definitions().definitions + const unknown = {} + for (const name of Object.keys(schemas)) { + if (schemas[name] !== known[name]) unknown[name] = schemas[name] + } + return unknown +} + +/** + * Swagger and OpenAPI do not allow the `definitions` and `$defs` keywords into + * a schema object. Every definition is moved out of the (already resolved and cloned) + * schema and returned, so it can be added to the top-level definitions. + * The `hoisted` map tracks `old pointer -> new name` to fix the references. + */ +function hoistDefinitions (schemaName, schema, sharedSchemas, hoisted) { + const result = [] + const owners = [] + const taken = new Set(hoisted.values()) + + // The ref resolver adds the referenced shared schemas to the root + // `definitions`: those are top-level definitions already. + if (isObject(schema.definitions)) { + for (const key of Object.keys(schema.definitions)) { + if (schema.definitions[key] === sharedSchemas[key]) { + delete schema.definitions[key] + } + } + } + + walkSchema(schema, (subschema, pointer) => { + for (const keyword of DEFINITIONS_KEYWORDS) { + if (!isObject(subschema[keyword])) continue + owners.push([subschema, keyword]) + + for (const key of Object.keys(subschema[keyword])) { + const from = [schemaName, ...pointer.map(escapeToken), keyword, escapeToken(key)].join('/') + + let name = `${schemaName}-${key}` + for (let i = 1; Object.hasOwn(sharedSchemas, name) || taken.has(name); i++) { + name = `${schemaName}-${key}-${i}` + } + + taken.add(name) + hoisted.set(from, name) + result.push([name, subschema[keyword][key]]) + } + } + }) + + for (const [owner, keyword] of owners) { + delete owner[keyword] + } + + return result +} + +/** + * Rewrites all the references to a hoisted definition. + * `prefix` is `#/definitions/` for Swagger and `#/components/schemas/` for OpenAPI. + */ +function rewriteHoistedRefs (node, prefix, hoisted) { + if (hoisted.size === 0 || !isObject(node)) return + + if (Array.isArray(node)) { + for (const item of node) rewriteHoistedRefs(item, prefix, hoisted) + return + } + + if (typeof node.$ref === 'string' && node.$ref.startsWith(prefix)) { + const pointer = node.$ref.slice(prefix.length) + // longest match first, so definitions nested in definitions are supported + for (let end = pointer.length; end > 0; end = pointer.lastIndexOf('/', end - 1)) { + const name = hoisted.get(pointer.slice(0, end)) + if (name !== undefined) { + node.$ref = prefix + name + pointer.slice(end) + break + } + } + } + + for (const key of Object.keys(node)) { + rewriteHoistedRefs(node[key], prefix, hoisted) + } +} + +module.exports = { + absolutizeLocalRefs, + collectAnchors, + rewriteAnchorRefs, + hoistDefinitions, + unknownSchemas, + rewriteHoistedRefs +} diff --git a/lib/util/resolve-schema-reference.js b/lib/util/resolve-schema-reference.js index 80189941..04cdce35 100644 --- a/lib/util/resolve-schema-reference.js +++ b/lib/util/resolve-schema-reference.js @@ -1,7 +1,7 @@ 'use strict' function resolveSchemaReference (rawSchema, ref) { - const resolvedReference = ref.resolve(rawSchema, { externalSchemas: [ref.definitions().definitions] }) + const resolvedReference = ref.resolve(rawSchema) // Ref has format `#/definitions/id` const schemaId = resolvedReference?.$ref?.split('/', 3)[2] @@ -10,7 +10,7 @@ function resolveSchemaReference (rawSchema, ref) { return undefined } - return resolvedReference.definitions?.[schemaId] + return ref.definitions().definitions[schemaId] } module.exports = { diff --git a/test/spec/openapi/refs.test.js b/test/spec/openapi/refs.test.js index 4e25c551..f0ac5314 100644 --- a/test/spec/openapi/refs.test.js +++ b/test/spec/openapi/refs.test.js @@ -605,3 +605,186 @@ test('should return only ref if defs and ref is defined', async (t) => { await Swagger.validate(openapiObject) }) + +// https://github.com/fastify/fastify-swagger/issues/639 +const definitionsCases = [ + ['openapi', { openapi: {} }, (document) => document.components.schemas, '#/components/schemas/'] +] + +for (const [name, option, getSchemas, prefix] of definitionsCases) { + test(`${name}: support $ref to the definitions of a shared schema`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + fastify.addSchema({ + $id: 'http://foo/common.json', + type: 'object', + definitions: { + foo: { + $id: '#address', + type: 'object', + properties: { city: { type: 'string' } } + } + } + }) + fastify.post('/', { + schema: { + body: { $ref: 'http://foo/common.json#/definitions/foo' }, + response: { 200: { $ref: 'http://foo/common.json#/definitions/foo/properties/city' } } + } + }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + t.assert.strictEqual(schemas['def-0'].definitions, undefined) + t.assert.deepStrictEqual(schemas['def-0-foo'].properties, { city: { type: 'string' } }) + t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0-foo"`)) + t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0-foo/properties/city"`)) + }) + + test(`${name}: support local $ref and nested definitions in a shared schema`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + fastify.addSchema({ + $id: 'tree', + type: 'object', + definitions: { + node: { + type: 'object', + definitions: { + leaf: { type: 'string', enum: ['a', 'b'], default: 'a' } + }, + properties: { + // a property named as a keyword must not be hoisted + definitions: { type: 'string' }, + value: { $ref: '#/definitions/node/definitions/leaf' }, + children: { type: 'array', items: { $ref: '#/definitions/node' } } + } + } + }, + properties: { + self: { $ref: '#' }, + root: { $ref: '#/definitions/node' }, + nested: { + type: 'object', + $defs: { node: { type: 'integer' } }, + properties: { id: { $ref: '#/properties/nested/$defs/node' } } + }, + sibling: { $ref: '#/properties/nested' } + } + }) + // takes the name that would be assigned to the hoisted definition + fastify.addSchema({ $id: 'other', type: 'object', properties: { tree: { $ref: 'tree#' } } }) + fastify.get('/', { schema: { response: { 200: { $ref: 'tree#' } } } }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + t.assert.deepStrictEqual(Object.keys(schemas).sort(), [ + 'def-0', 'def-0-leaf', 'def-0-node', 'def-0-node-1', 'def-1' + ]) + t.assert.deepStrictEqual(schemas['def-0'].properties, { + self: { $ref: `${prefix}def-0` }, + root: { $ref: `${prefix}def-0-node` }, + nested: { type: 'object', properties: { id: { $ref: `${prefix}def-0-node-1` } } }, + sibling: { $ref: `${prefix}def-0/properties/nested` } + }) + t.assert.deepStrictEqual(schemas['def-0-node'].properties, { + definitions: { type: 'string' }, + value: { $ref: `${prefix}def-0-leaf` }, + children: { type: 'array', items: { $ref: `${prefix}def-0-node` } } + }) + t.assert.deepStrictEqual(schemas['def-0-node-1'], { type: 'integer' }) + }) +} + +for (const [name, option, getSchemas, prefix] of definitionsCases) { + test(`${name}: support $ref to the anchor of a shared schema`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + // same shape of the schemas of the Fastify "Fluent Schema" guide + fastify.addSchema({ + $id: 'https://fastify/demo', + type: 'object', + definitions: { + addressSchema: { + $id: '#address', + type: 'object', + properties: { city: { type: 'string' } } + }, + userSchema: { + $id: '#user', + type: 'object', + properties: { home: { $ref: '#address' } } + } + } + }) + + const body = { + type: 'object', + properties: { + residence: { $ref: 'https://fastify/demo#address' }, + office: { $ref: 'https://fastify/demo#/definitions/addressSchema' }, + owner: { $ref: 'https://fastify/demo#user' } + } + } + fastify.post('/', { + schema: { + body, + response: { 200: { $ref: 'https://fastify/demo#address' } } + } + }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + // the anchored subschemas are not listed twice + t.assert.deepStrictEqual(Object.keys(schemas), ['def-0', 'def-0-addressSchema', 'def-0-userSchema']) + t.assert.deepStrictEqual(schemas['def-0-userSchema'].properties.home, { $ref: `${prefix}def-0-addressSchema` }) + + const operation = JSON.stringify(document.paths['/'].post) + t.assert.doesNotMatch(operation, /def-0address/) + t.assert.strictEqual(operation.split(`"$ref":"${prefix}def-0-addressSchema"`).length - 1, 3) + t.assert.match(operation, new RegExp(`"\\$ref":"${prefix}def-0-userSchema"`)) + + // the schema of the route is left untouched + t.assert.strictEqual(body.properties.residence.$ref, 'https://fastify/demo#address') + }) + + test(`${name}: support $ref to an anchor outside of the definitions`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + fastify.addSchema({ + $id: 'order', + type: 'object', + properties: { + shipping: { $id: '#shipping', type: 'object', properties: { city: { type: 'string' } } }, + billing: { $ref: '#shipping' } + } + }) + fastify.post('/', { schema: { body: { $ref: 'order#shipping' } } }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + t.assert.deepStrictEqual(Object.keys(schemas), ['def-0']) + t.assert.deepStrictEqual(schemas['def-0'].properties.billing, { $ref: `${prefix}def-0/properties/shipping` }) + t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0/properties/shipping"`)) + }) +} diff --git a/test/spec/swagger/refs.test.js b/test/spec/swagger/refs.test.js index d2773230..5bf645ab 100644 --- a/test/spec/swagger/refs.test.js +++ b/test/spec/swagger/refs.test.js @@ -162,3 +162,186 @@ test('renders $ref schema with enum in headers', async (t) => { } ) }) + +// https://github.com/fastify/fastify-swagger/issues/639 +const definitionsCases = [ + ['swagger', { swagger: {} }, (document) => document.definitions, '#/definitions/'] +] + +for (const [name, option, getSchemas, prefix] of definitionsCases) { + test(`${name}: support $ref to the definitions of a shared schema`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + fastify.addSchema({ + $id: 'http://foo/common.json', + type: 'object', + definitions: { + foo: { + $id: '#address', + type: 'object', + properties: { city: { type: 'string' } } + } + } + }) + fastify.post('/', { + schema: { + body: { $ref: 'http://foo/common.json#/definitions/foo' }, + response: { 200: { $ref: 'http://foo/common.json#/definitions/foo/properties/city' } } + } + }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + t.assert.strictEqual(schemas['def-0'].definitions, undefined) + t.assert.deepStrictEqual(schemas['def-0-foo'].properties, { city: { type: 'string' } }) + t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0-foo"`)) + t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0-foo/properties/city"`)) + }) + + test(`${name}: support local $ref and nested definitions in a shared schema`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + fastify.addSchema({ + $id: 'tree', + type: 'object', + definitions: { + node: { + type: 'object', + definitions: { + leaf: { type: 'string', enum: ['a', 'b'], default: 'a' } + }, + properties: { + // a property named as a keyword must not be hoisted + definitions: { type: 'string' }, + value: { $ref: '#/definitions/node/definitions/leaf' }, + children: { type: 'array', items: { $ref: '#/definitions/node' } } + } + } + }, + properties: { + self: { $ref: '#' }, + root: { $ref: '#/definitions/node' }, + nested: { + type: 'object', + $defs: { node: { type: 'integer' } }, + properties: { id: { $ref: '#/properties/nested/$defs/node' } } + }, + sibling: { $ref: '#/properties/nested' } + } + }) + // takes the name that would be assigned to the hoisted definition + fastify.addSchema({ $id: 'other', type: 'object', properties: { tree: { $ref: 'tree#' } } }) + fastify.get('/', { schema: { response: { 200: { $ref: 'tree#' } } } }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + t.assert.deepStrictEqual(Object.keys(schemas).sort(), [ + 'def-0', 'def-0-leaf', 'def-0-node', 'def-0-node-1', 'def-1' + ]) + t.assert.deepStrictEqual(schemas['def-0'].properties, { + self: { $ref: `${prefix}def-0` }, + root: { $ref: `${prefix}def-0-node` }, + nested: { type: 'object', properties: { id: { $ref: `${prefix}def-0-node-1` } } }, + sibling: { $ref: `${prefix}def-0/properties/nested` } + }) + t.assert.deepStrictEqual(schemas['def-0-node'].properties, { + definitions: { type: 'string' }, + value: { $ref: `${prefix}def-0-leaf` }, + children: { type: 'array', items: { $ref: `${prefix}def-0-node` } } + }) + t.assert.deepStrictEqual(schemas['def-0-node-1'], { type: 'integer' }) + }) +} + +for (const [name, option, getSchemas, prefix] of definitionsCases) { + test(`${name}: support $ref to the anchor of a shared schema`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + // same shape of the schemas of the Fastify "Fluent Schema" guide + fastify.addSchema({ + $id: 'https://fastify/demo', + type: 'object', + definitions: { + addressSchema: { + $id: '#address', + type: 'object', + properties: { city: { type: 'string' } } + }, + userSchema: { + $id: '#user', + type: 'object', + properties: { home: { $ref: '#address' } } + } + } + }) + + const body = { + type: 'object', + properties: { + residence: { $ref: 'https://fastify/demo#address' }, + office: { $ref: 'https://fastify/demo#/definitions/addressSchema' }, + owner: { $ref: 'https://fastify/demo#user' } + } + } + fastify.post('/', { + schema: { + body, + response: { 200: { $ref: 'https://fastify/demo#address' } } + } + }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + // the anchored subschemas are not listed twice + t.assert.deepStrictEqual(Object.keys(schemas), ['def-0', 'def-0-addressSchema', 'def-0-userSchema']) + t.assert.deepStrictEqual(schemas['def-0-userSchema'].properties.home, { $ref: `${prefix}def-0-addressSchema` }) + + const operation = JSON.stringify(document.paths['/'].post) + t.assert.doesNotMatch(operation, /def-0address/) + t.assert.strictEqual(operation.split(`"$ref":"${prefix}def-0-addressSchema"`).length - 1, 3) + t.assert.match(operation, new RegExp(`"\\$ref":"${prefix}def-0-userSchema"`)) + + // the schema of the route is left untouched + t.assert.strictEqual(body.properties.residence.$ref, 'https://fastify/demo#address') + }) + + test(`${name}: support $ref to an anchor outside of the definitions`, async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, option) + + fastify.addSchema({ + $id: 'order', + type: 'object', + properties: { + shipping: { $id: '#shipping', type: 'object', properties: { city: { type: 'string' } } }, + billing: { $ref: '#shipping' } + } + }) + fastify.post('/', { schema: { body: { $ref: 'order#shipping' } } }, () => {}) + + await fastify.ready() + + const document = fastify.swagger() + const schemas = getSchemas(document) + await Swagger.validate(JSON.parse(JSON.stringify(document))) + + t.assert.deepStrictEqual(Object.keys(schemas), ['def-0']) + t.assert.deepStrictEqual(schemas['def-0'].properties.billing, { $ref: `${prefix}def-0/properties/shipping` }) + t.assert.match(JSON.stringify(document.paths['/'].post), new RegExp(`"\\$ref":"${prefix}def-0/properties/shipping"`)) + }) +} diff --git a/test/util.test.js b/test/util.test.js index d17485e8..953a40b5 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -161,3 +161,134 @@ describe('shouldRouteHide', () => { t.assert.equal(shouldRouteHide({}, {}), false) }) }) + +describe('definitions', () => { + const { + absolutizeLocalRefs, + collectAnchors, + hoistDefinitions, + rewriteAnchorRefs, + rewriteHoistedRefs + } = require('../lib/util/definitions') + + test('absolutizeLocalRefs uses the closest non-fragment $id', (t) => { + const schema = absolutizeLocalRefs({ + $id: 'http://example.com/root.json#', + properties: { + a: { $ref: '#/definitions/a' }, + b: { $id: '#anchor', properties: { c: { $ref: '#' } } }, + d: { $id: 'other.json', items: [{ $ref: '#/items/1' }, { type: 'string' }] }, + e: { $ref: 'external#' }, + f: { enum: [{ $ref: '#/not/a/schema' }], dependencies: { a: ['b'] } } + } + }) + + t.assert.strictEqual(schema.properties.a.$ref, 'http://example.com/root.json#/definitions/a') + t.assert.strictEqual(schema.properties.b.properties.c.$ref, 'http://example.com/root.json#') + t.assert.strictEqual(schema.properties.d.items[0].$ref, 'other.json#/items/1') + t.assert.strictEqual(schema.properties.e.$ref, 'external#') + t.assert.strictEqual(schema.properties.f.enum[0].$ref, '#/not/a/schema') + }) + + test('absolutizeLocalRefs ignores schemas without $id', (t) => { + t.assert.deepStrictEqual(absolutizeLocalRefs({ $ref: '#/definitions/a' }), { $ref: '#/definitions/a' }) + t.assert.strictEqual(absolutizeLocalRefs(true), true) + }) + + test('hoistDefinitions escapes the JSON pointer tokens', (t) => { + const hoisted = new Map() + const shared = { type: 'string' } + const schema = { + definitions: { shared, 'a/b~c': { type: 'integer' } }, + properties: { 'x/y': { definitions: { z: { type: 'boolean' } } } } + } + + const result = hoistDefinitions('root', schema, { shared }, hoisted) + + t.assert.deepStrictEqual(result, [ + ['root-a/b~c', { type: 'integer' }], + ['root-z', { type: 'boolean' }] + ]) + t.assert.deepStrictEqual(Object.fromEntries(hoisted), { + 'root/definitions/a~1b~0c': 'root-a/b~c', + 'root/properties/x~1y/definitions/z': 'root-z' + }) + t.assert.deepStrictEqual(schema, { properties: { 'x/y': {} } }) + }) + + test('rewriteHoistedRefs rewrites only the hoisted references', (t) => { + const hoisted = new Map([['a/definitions/b', 'a-b'], ['a/definitions/b/definitions/c', 'a-c']]) + const document = { + list: [ + { $ref: '#/definitions/a/definitions/b/definitions/c/properties/d' }, + { $ref: '#/definitions/a/definitions/b' }, + { $ref: '#/definitions/a/properties/b' }, + { $ref: '#/parameters/a/definitions/b' }, + { $ref: 42 }, + null + ] + } + + rewriteHoistedRefs(document, '#/definitions/', hoisted) + t.assert.deepStrictEqual(document.list, [ + { $ref: '#/definitions/a-c/properties/d' }, + { $ref: '#/definitions/a-b' }, + { $ref: '#/definitions/a/properties/b' }, + { $ref: '#/parameters/a/definitions/b' }, + { $ref: 42 }, + null + ]) + + const untouched = { $ref: '#/definitions/a/definitions/b' } + rewriteHoistedRefs(untouched, '#/definitions/', new Map()) + t.assert.deepStrictEqual(untouched, { $ref: '#/definitions/a/definitions/b' }) + }) + + test('collectAnchors maps the anchors to the JSON pointer of their schema resource', (t) => { + const orphan = { definitions: { noBase: { $id: '#orphan' } } } + const root = { + $id: 'http://example.com/root.json', + definitions: { + 'a/b': { $id: '#escaped' }, + first: { $id: '#twice' }, + second: { $id: '#twice' }, + empty: { $id: '#' }, + nested: { + $id: 'http://example.com/nested.json', + properties: { c: { $id: '#inner' } } + } + }, + enum: [{ $id: '#data' }] + } + const anchors = collectAnchors([root, orphan, true]) + + t.assert.deepStrictEqual(Object.fromEntries(anchors), { + 'http://example.com/root.json#escaped': 'http://example.com/root.json#/definitions/a~1b', + 'http://example.com/root.json#twice': 'http://example.com/root.json#/definitions/first', + 'http://example.com/nested.json#inner': 'http://example.com/nested.json#/properties/c' + }) + + // the anchors are removed, data and schemas without a base URI are left untouched + t.assert.deepStrictEqual(root.definitions.first, {}) + t.assert.deepStrictEqual(root.definitions.empty, {}) + t.assert.strictEqual(root.definitions.nested.$id, 'http://example.com/nested.json') + t.assert.deepStrictEqual(root.definitions.nested.properties.c, {}) + t.assert.deepStrictEqual(root.enum, [{ $id: '#data' }]) + t.assert.strictEqual(orphan.definitions.noBase.$id, '#orphan') + }) + + test('rewriteAnchorRefs only touches the references to a known anchor', (t) => { + const anchors = new Map([['common#address', 'common#/definitions/foo']]) + const responses = { + 200: { $ref: 'common#address' }, + 404: { oneOf: [{ $ref: 'common#address' }, { $ref: 'common#unknown' }, { $ref: 42 }, null] } + } + + t.assert.strictEqual(rewriteAnchorRefs(responses, anchors), responses) + t.assert.deepStrictEqual(responses, { + 200: { $ref: 'common#/definitions/foo' }, + 404: { oneOf: [{ $ref: 'common#/definitions/foo' }, { $ref: 'common#unknown' }, { $ref: 42 }, null] } + }) + t.assert.strictEqual(rewriteAnchorRefs(true, anchors), true) + }) +}) From 4f58af8ca2d916b23a717a345263ece4b7c9d6cc Mon Sep 17 00:00:00 2001 From: Tony133 Date: Tue, 22 Sep 2026 15:21:08 +0200 Subject: [PATCH 2/3] docs: shorten JSDoc of unknownSchemas and hoistDefinitions --- lib/util/definitions.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/util/definitions.js b/lib/util/definitions.js index 3238b80c..1678b186 100644 --- a/lib/util/definitions.js +++ b/lib/util/definitions.js @@ -141,11 +141,12 @@ function rewriteAnchorRefs (node, anchors) { } /** - * The ref resolver already knows the shared schemas: mapping them again as - * external schemas of a single `resolve()` call evaluates their subschemas - * against the wrong base URI, and it throws when one of those has a - * fragment-only `$id` (`{ $id: '#address' }`). Only the schemas set by the - * user in the plugin options must be provided. + * Returns the schemas the ref resolver does not know yet (i.e. the user ones, + * not the shared schemas), since passing shared schemas as external ones + * resolves them against the wrong base URI. + * @param {Record} schemas + * @param {object} ref - Ref resolver instance. + * @returns {Record} */ function unknownSchemas (schemas, ref) { const known = ref.definitions().definitions @@ -157,10 +158,13 @@ function unknownSchemas (schemas, ref) { } /** - * Swagger and OpenAPI do not allow the `definitions` and `$defs` keywords into - * a schema object. Every definition is moved out of the (already resolved and cloned) - * schema and returned, so it can be added to the top-level definitions. - * The `hoisted` map tracks `old pointer -> new name` to fix the references. + * Moves nested `definitions`/`$defs` (not allowed by Swagger/OpenAPI) out of + * a resolved and cloned schema, so they can become top-level definitions. + * @param {string} schemaName + * @param {object} schema - Resolved and cloned schema; mutated in place. + * @param {Record} sharedSchemas + * @param {Map} hoisted - Tracks `old pointer -> new name`; mutated. + * @returns {Array<[string, object]>} Hoisted `[name, schema]` pairs. */ function hoistDefinitions (schemaName, schema, sharedSchemas, hoisted) { const result = [] From 019e6404882b192228f7dd581a8d31652a371bd7 Mon Sep 17 00:00:00 2001 From: Tony133 Date: Fri, 25 Sep 2026 13:50:34 +0200 Subject: [PATCH 3/3] perf: walk the shared schemas once, use for loops and push/pop in walkSchema --- lib/util/add-hook.js | 7 +-- lib/util/definitions.js | 130 +++++++++++++++++++++++++++------------- test/util.test.js | 39 ++++++++---- 3 files changed, 120 insertions(+), 56 deletions(-) diff --git a/lib/util/add-hook.js b/lib/util/add-hook.js index deb119c8..6410df0a 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 { absolutizeLocalRefs, collectAnchors, rewriteAnchorRefs } = require('./definitions') +const { prepareSharedSchemas, rewriteAnchorRefs } = require('./definitions') function addHook (fastify, pluginOptions) { const routes = [] @@ -69,9 +69,8 @@ function addHook (fastify, pluginOptions) { if (hookRun === false) { throw new Error('.swagger() must be called after .ready()') } - const externalSchemas = cloner(Array.from(sharedSchemasMap.values())).map(absolutizeLocalRefs) - const anchors = collectAnchors(externalSchemas) - rewriteAnchorRefs(externalSchemas, anchors) + const externalSchemas = cloner(Array.from(sharedSchemasMap.values())) + const anchors = prepareSharedSchemas(externalSchemas) const ref = Ref(Object.assign( { applicationUri: 'todo.com' }, diff --git a/lib/util/definitions.js b/lib/util/definitions.js index 1678b186..860d239c 100644 --- a/lib/util/definitions.js +++ b/lib/util/definitions.js @@ -41,12 +41,19 @@ function escapeToken (token) { * Visits a schema and all its subschemas. Differently from a plain deep walk, * it does not mistake data (`enum`, `default`, `examples`...) or property * names (a property called `definitions`) for JSON Schema keywords. + * + * The `pointer` passed to `visit` is the same array reused for the whole walk + * (tokens are pushed and popped): the visitor must copy it to retain it. */ function walkSchema (schema, visit, pointer = [], baseId, baseDepth = 0) { if (!isObject(schema)) return if (Array.isArray(schema)) { - schema.forEach((item, i) => walkSchema(item, visit, [...pointer, `${i}`], baseId, baseDepth)) + for (let i = 0; i < schema.length; i++) { + pointer.push(`${i}`) + walkSchema(schema[i], visit, pointer, baseId, baseDepth) + pointer.pop() + } return } @@ -59,61 +66,92 @@ function walkSchema (schema, visit, pointer = [], baseId, baseDepth = 0) { visit(schema, pointer, baseId, baseDepth) - for (const key of Object.keys(schema)) { + const keys = Object.keys(schema) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] const value = schema[key] if (SCHEMA_KEYWORDS.has(key)) { - walkSchema(value, visit, [...pointer, key], baseId, baseDepth) + pointer.push(key) + walkSchema(value, visit, pointer, baseId, baseDepth) + pointer.pop() } else if (SCHEMA_MAP_KEYWORDS.has(key) && isObject(value)) { - for (const name of Object.keys(value)) { - walkSchema(value[name], visit, [...pointer, key, name], baseId, baseDepth) + const names = Object.keys(value) + pointer.push(key) + for (let j = 0; j < names.length; j++) { + pointer.push(names[j]) + walkSchema(value[names[j]], visit, pointer, baseId, baseDepth) + pointer.pop() } + pointer.pop() } } } /** - * In JSON Schema a local reference (`#/definitions/foo`) is relative to the - * closest schema resource. When a shared schema lands into the Swagger/OpenAPI - * document, `#` becomes the root of the whole document instead, so the - * reference would point to nowhere. Turning it into `<$id>#/definitions/foo` - * lets the ref resolver handle it as any other reference to a shared schema. - */ -function absolutizeLocalRefs (schema) { - walkSchema(schema, (subschema, _pointer, baseId) => { - if (baseId !== undefined && typeof subschema.$ref === 'string' && subschema.$ref[0] === '#') { - subschema.$ref = baseId + subschema.$ref - } - }) - return schema -} - -/** - * A fragment-only `$id` (`{ $id: '#address' }`) is a draft-07 anchor: the - * subschema can be referenced as `#address`. The ref resolver does not - * support it: it appends the fragment to the definition name as it is, - * producing `#/definitions/def-0address`. Since an anchor is nothing more than - * an alias of a JSON pointer, this function returns the map - * `#anchor -> #/json/pointer` to convert those references to the - * form the ref resolver (and then the hoisting) understands. + * Prepares the (cloned) shared schemas for the ref resolver in a single walk: + * + * 1. In JSON Schema a local reference (`#/definitions/foo`) is relative to + * the closest schema resource. When a shared schema lands into the + * Swagger/OpenAPI document, `#` becomes the root of the whole document + * instead, so the reference would point to nowhere. Turning it into + * `<$id>#/definitions/foo` lets the ref resolver handle it as any other + * reference to a shared schema. * - * The anchors are removed from the (cloned) schemas: once the references are - * converted nothing points to them anymore, the ref resolver would list each - * of them as a duplicated definition and Swagger does not accept `$id` at all. + * 2. A fragment-only `$id` (`{ $id: '#address' }`) is a draft-07 anchor: the + * subschema can be referenced as `#address`. The ref resolver does + * not support it: it appends the fragment to the definition name as it is, + * producing `#/definitions/def-0address`. Since an anchor is nothing more + * than an alias of a JSON pointer, the returned map + * `#anchor -> #/json/pointer` allows to convert those + * references to the form the ref resolver (and then the hoisting) + * understands. The anchors are removed from the schemas: once the + * references are converted nothing points to them anymore, the ref + * resolver would list each of them as a duplicated definition and Swagger + * does not accept `$id` at all. + * + * 3. The references to an anchor found in the shared schemas themselves are + * converted to their JSON pointer. + * + * @param {object[]} schemas - Cloned shared schemas; mutated in place. + * @returns {Map} The anchors map. */ -function collectAnchors (schemas) { +function prepareSharedSchemas (schemas) { const anchors = new Map() - for (const schema of schemas) { - walkSchema(schema, (subschema, pointer, baseId, baseDepth) => { - const { $id } = subschema - if (baseId === undefined || typeof $id !== 'string' || $id[0] !== '#') return + // The references are fixed at the end, since an anchor may be declared + // after the reference to it + const refs = [] + + for (let i = 0; i < schemas.length; i++) { + walkSchema(schemas[i], (subschema, pointer, baseId, baseDepth) => { + if (baseId === undefined) return + + const { $id, $ref } = subschema + + if (typeof $ref === 'string') { + if ($ref[0] === '#') subschema.$ref = baseId + $ref + refs.push(subschema) + } + + if (typeof $id !== 'string' || $id[0] !== '#') return delete subschema.$id const anchor = baseId + $id // `#` is not an anchor and, as for the ref resolver, the first one wins if ($id.length === 1 || anchors.has(anchor)) return - anchors.set(anchor, `${baseId}#${pointer.slice(baseDepth).map(token => `/${escapeToken(token)}`).join('')}`) + + let jsonPointer = '' + for (let j = baseDepth; j < pointer.length; j++) { + jsonPointer += `/${escapeToken(pointer[j])}` + } + anchors.set(anchor, `${baseId}#${jsonPointer}`) }) } + + for (let i = 0; i < refs.length; i++) { + const target = anchors.get(refs[i].$ref) + if (target !== undefined) refs[i].$ref = target + } + return anchors } @@ -182,12 +220,21 @@ function hoistDefinitions (schemaName, schema, sharedSchemas, hoisted) { } walkSchema(schema, (subschema, pointer) => { - for (const keyword of DEFINITIONS_KEYWORDS) { + let prefix + for (let i = 0; i < DEFINITIONS_KEYWORDS.length; i++) { + const keyword = DEFINITIONS_KEYWORDS[i] if (!isObject(subschema[keyword])) continue owners.push([subschema, keyword]) - for (const key of Object.keys(subschema[keyword])) { - const from = [schemaName, ...pointer.map(escapeToken), keyword, escapeToken(key)].join('/') + if (prefix === undefined) { + prefix = schemaName + for (let j = 0; j < pointer.length; j++) prefix += `/${escapeToken(pointer[j])}` + } + + const keys = Object.keys(subschema[keyword]) + for (let j = 0; j < keys.length; j++) { + const key = keys[j] + const from = `${prefix}/${keyword}/${escapeToken(key)}` let name = `${schemaName}-${key}` for (let i = 1; Object.hasOwn(sharedSchemas, name) || taken.has(name); i++) { @@ -238,8 +285,7 @@ function rewriteHoistedRefs (node, prefix, hoisted) { } module.exports = { - absolutizeLocalRefs, - collectAnchors, + prepareSharedSchemas, rewriteAnchorRefs, hoistDefinitions, unknownSchemas, diff --git a/test/util.test.js b/test/util.test.js index 953a40b5..047c5423 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -164,15 +164,14 @@ describe('shouldRouteHide', () => { describe('definitions', () => { const { - absolutizeLocalRefs, - collectAnchors, + prepareSharedSchemas, hoistDefinitions, rewriteAnchorRefs, rewriteHoistedRefs } = require('../lib/util/definitions') - test('absolutizeLocalRefs uses the closest non-fragment $id', (t) => { - const schema = absolutizeLocalRefs({ + test('prepareSharedSchemas absolutizes the local refs using the closest non-fragment $id', (t) => { + const schema = { $id: 'http://example.com/root.json#', properties: { a: { $ref: '#/definitions/a' }, @@ -181,7 +180,8 @@ describe('definitions', () => { e: { $ref: 'external#' }, f: { enum: [{ $ref: '#/not/a/schema' }], dependencies: { a: ['b'] } } } - }) + } + prepareSharedSchemas([schema]) t.assert.strictEqual(schema.properties.a.$ref, 'http://example.com/root.json#/definitions/a') t.assert.strictEqual(schema.properties.b.properties.c.$ref, 'http://example.com/root.json#') @@ -190,9 +190,10 @@ describe('definitions', () => { t.assert.strictEqual(schema.properties.f.enum[0].$ref, '#/not/a/schema') }) - test('absolutizeLocalRefs ignores schemas without $id', (t) => { - t.assert.deepStrictEqual(absolutizeLocalRefs({ $ref: '#/definitions/a' }), { $ref: '#/definitions/a' }) - t.assert.strictEqual(absolutizeLocalRefs(true), true) + test('prepareSharedSchemas ignores schemas without $id', (t) => { + const schema = { $ref: '#/definitions/a' } + t.assert.deepStrictEqual(prepareSharedSchemas([schema, true]), new Map()) + t.assert.deepStrictEqual(schema, { $ref: '#/definitions/a' }) }) test('hoistDefinitions escapes the JSON pointer tokens', (t) => { @@ -244,7 +245,7 @@ describe('definitions', () => { t.assert.deepStrictEqual(untouched, { $ref: '#/definitions/a/definitions/b' }) }) - test('collectAnchors maps the anchors to the JSON pointer of their schema resource', (t) => { + test('prepareSharedSchemas maps the anchors to the JSON pointer of their schema resource', (t) => { const orphan = { definitions: { noBase: { $id: '#orphan' } } } const root = { $id: 'http://example.com/root.json', @@ -260,7 +261,7 @@ describe('definitions', () => { }, enum: [{ $id: '#data' }] } - const anchors = collectAnchors([root, orphan, true]) + const anchors = prepareSharedSchemas([root, orphan, true]) t.assert.deepStrictEqual(Object.fromEntries(anchors), { 'http://example.com/root.json#escaped': 'http://example.com/root.json#/definitions/a~1b', @@ -277,6 +278,24 @@ describe('definitions', () => { t.assert.strictEqual(orphan.definitions.noBase.$id, '#orphan') }) + test('prepareSharedSchemas rewrites the refs to an anchor declared later', (t) => { + const schema = { + $id: 'http://example.com/root.json', + properties: { + a: { $ref: '#address' }, + b: { $ref: 'http://example.com/root.json#address' }, + c: { $ref: '#missing' } + }, + definitions: { address: { $id: '#address', type: 'object' } } + } + prepareSharedSchemas([schema]) + + t.assert.strictEqual(schema.properties.a.$ref, 'http://example.com/root.json#/definitions/address') + t.assert.strictEqual(schema.properties.b.$ref, 'http://example.com/root.json#/definitions/address') + t.assert.strictEqual(schema.properties.c.$ref, 'http://example.com/root.json#missing') + t.assert.deepStrictEqual(schema.definitions.address, { type: 'object' }) + }) + test('rewriteAnchorRefs only touches the references to a known anchor', (t) => { const anchors = new Map([['common#address', 'common#/definitions/foo']]) const responses = {