diff --git a/lib/spec/openapi/index.js b/lib/spec/openapi/index.js index c290a1b0..b6124dbc 100644 --- a/lib/spec/openapi/index.js +++ b/lib/spec/openapi/index.js @@ -1,7 +1,7 @@ 'use strict' const yaml = require('yaml') -const { shouldRouteHide } = require('../../util/common') +const { shouldRouteHide, patchDefinitionsKeywordInSchema } = require('../../util/common') const { prepareDefaultOptions, prepareOpenapiObject, prepareOpenapiMethod, prepareOpenapiSchemas, normalizeUrl, resolveServerUrls } = require('./utils') module.exports = function (opts, cache, routes, Ref, done) { @@ -20,14 +20,19 @@ module.exports = function (opts, cache, routes, Ref, done) { const openapiObject = prepareOpenapiObject(defOpts, done) ref = Ref() + const resolvedDefs = ref.definitions().definitions + + const schemaFromOptions = openapiObject.components.schemas openapiObject.components.schemas = prepareOpenapiSchemas({ - ...openapiObject.components.schemas, - ...(ref.definitions().definitions) + ...schemaFromOptions, + ...resolvedDefs }, ref) const serverUrls = resolveServerUrls(defOpts.servers) for (const route of routes) { + route.schema = patchDefinitionsKeywordInSchema(route.schema) + const transformResult = defOpts.transform ? defOpts.transform({ schema: route.schema, url: route.url }) : {} diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index 8e44a21b..5fdc29f5 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -259,7 +259,7 @@ function resolveBodyParams (body, schema, consumes, ref) { } } -function resolveCommonParams (container, parameters, schema, ref, sharedSchemas, securityIgnores) { +function resolveCommonParams (container, parameters, schema, ref, securityIgnores) { const schemasPath = '#/components/schemas/' let resolved = transformDefsToComponents(ref.resolve(schema)) @@ -270,7 +270,7 @@ function resolveCommonParams (container, parameters, schema, ref, sharedSchemas, resolved = pathParts.reduce((resolved, pathPart) => resolved[pathPart], ref.definitions().definitions) } - const arr = plainJsonObjectToOpenapi3(container, resolved, { ...sharedSchemas, ...ref.definitions().definitions }, securityIgnores) + const arr = plainJsonObjectToOpenapi3(container, resolved, { ...ref.definitions().definitions }, securityIgnores) arr.forEach(swaggerSchema => parameters.push(swaggerSchema)) } @@ -377,16 +377,16 @@ function prepareOpenapiMethod (schema, ref, openapiObject) { if (schema.tags) openapiMethod.tags = schema.tags if (schema.description) openapiMethod.description = schema.description if (schema.externalDocs) openapiMethod.externalDocs = schema.externalDocs - if (schema.querystring) resolveCommonParams('query', parameters, schema.querystring, ref, openapiObject.definitions, securityIgnores.query) + if (schema.querystring) resolveCommonParams('query', parameters, schema.querystring, ref, securityIgnores.query) if (schema.body) { openapiMethod.requestBody = { content: {} } resolveBodyParams(openapiMethod.requestBody, schema.body, schema.consumes, ref) } - if (schema.params) resolveCommonParams('path', parameters, schema.params, ref, openapiObject.definitions) - if (schema.headers) resolveCommonParams('header', parameters, schema.headers, ref, openapiObject.definitions, securityIgnores.header) + if (schema.params) resolveCommonParams('path', parameters, schema.params, ref) + if (schema.headers) resolveCommonParams('header', parameters, schema.headers, ref, securityIgnores.header) // TODO: need to documentation, we treat it same as the querystring // fastify do not support cookies schema in first place - if (schema.cookies) resolveCommonParams('cookie', parameters, schema.cookies, ref, openapiObject.definitions, securityIgnores.cookie) + if (schema.cookies) resolveCommonParams('cookie', parameters, schema.cookies, ref, securityIgnores.cookie) if (parameters.length > 0) openapiMethod.parameters = parameters if (schema.deprecated) openapiMethod.deprecated = schema.deprecated if (schema.security) openapiMethod.security = schema.security @@ -407,16 +407,18 @@ function prepareOpenapiSchemas (schemas, ref) { return Object.entries(schemas) .reduce((res, [name, schema]) => { const _ = { ...schema } - const resolved = transformDefsToComponents(ref.resolve(_, { externalSchemas: [schemas] })) + + const resolved = ref.resolve(_, { externalSchemas: [schemas] }) + const transformed = transformDefsToComponents(resolved) // 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 // definitions are added by resolve but they are replace by components.schemas - delete resolved.$id - delete resolved.definitions + delete transformed.$id + delete transformed.definitions - res[name] = resolved + res[name] = transformed return res }, {}) } diff --git a/lib/spec/swagger/index.js b/lib/spec/swagger/index.js index 2b266fe1..050e290e 100644 --- a/lib/spec/swagger/index.js +++ b/lib/spec/swagger/index.js @@ -1,7 +1,7 @@ 'use strict' const yaml = require('yaml') -const { shouldRouteHide } = require('../../util/common') +const { shouldRouteHide, patchDefinitionsKeywordInSchema } = require('../../util/common') const { prepareDefaultOptions, prepareSwaggerObject, prepareSwaggerMethod, normalizeUrl, prepareSwaggerDefinitions } = require('./utils') module.exports = function (opts, cache, routes, Ref, done) { @@ -26,6 +26,8 @@ module.exports = function (opts, cache, routes, Ref, done) { swaggerObject.paths = {} for (const route of routes) { + route.schema = patchDefinitionsKeywordInSchema(route.schema) + const transformResult = defOpts.transform ? defOpts.transform({ schema: route.schema, url: route.url }) : {} diff --git a/lib/util/common.js b/lib/util/common.js index 6b188537..c05bba89 100644 --- a/lib/util/common.js +++ b/lib/util/common.js @@ -47,10 +47,13 @@ function addHook (fastify, pluginOptions) { routes, Ref () { const externalSchemas = cloner(Array.from(sharedSchemasMap.values())) + const resolvedToAbsolute = externalSchemas.map(e => localSchemaRefToAbs(e)) + const withPatchedDefinitions = resolvedToAbsolute.map(e => patchDefinitionsKeywordInSchema(e)) + return Ref(Object.assign( { applicationUri: 'todo.com' }, pluginOptions.refResolver, - { clone: true, externalSchemas }) + { clone: true, externalSchemas: withPatchedDefinitions }) ) } } @@ -210,11 +213,76 @@ function resolveSwaggerFunction (opts, cache, routes, Ref, done) { } } +function localSchemaRefToAbs (schema, fullPath, pathFromLastId) { + if (schema.$id) { + if (!fullPath) { + fullPath = schema.$id + '#' + } + // start from the last obj with $id + pathFromLastId = '' + } + + Object.keys(schema).forEach(key => { + if (key === '$ref' && schema[key] && schema[key].startsWith('#')) { + // #/something/... -> something/... + const woLocalSymbol = schema[key].substring(2) + + // where ObjectA is last obj with #id + // where pathFromLastId is the path from ObjectA to the current object + + // root#/properties/ObjectA/properties -> root#/properties/ObjectA + // todo: cov fail ??? + /* istanbul ignore else */ + if (pathFromLastId) { + const pathIdxForReplace = fullPath.lastIndexOf(pathFromLastId) + fullPath = fullPath.substring(0, pathIdxForReplace) + } + + // handle cases like '#' or '#/something' + const absPath = woLocalSymbol ? fullPath + '/' + woLocalSymbol : fullPath + schema[key] = absPath + } else if (schema[key] && typeof schema[key] === 'object') { + const isRefObj = schema[key].$ref + schema[key] = localSchemaRefToAbs(schema[key], + // don't extend paths if we are going to $ref object + isRefObj ? fullPath : fullPath + '/' + key, + isRefObj ? pathFromLastId : pathFromLastId + '/' + key + ) + } + }) + + return schema +} + +function patchDefinitionsKeywordInSchema (schema) { + if (!schema) return schema + + Object.keys(schema).forEach(key => { + if (key === '$ref' && schema[key]) { + schema[key] = schema[key].split('definitions').join('properties') + } else if (schema[key] && typeof schema[key] === 'object') { + if (key === 'definitions') { + schema.properties = { + ...schema[key], + ...schema.properties + } + delete schema[key] + key = 'properties' + } + schema[key] = patchDefinitionsKeywordInSchema(schema[key]) + } + }) + + return schema +} + module.exports = { addHook, shouldRouteHide, readPackageJson, formatParamUrl, resolveLocalRef, - resolveSwaggerFunction + resolveSwaggerFunction, + localSchemaRefToAbs, + patchDefinitionsKeywordInSchema } diff --git a/test/spec/openapi/refs.js b/test/spec/openapi/refs.js index fc8cabf5..7fab9dbc 100644 --- a/test/spec/openapi/refs.js +++ b/test/spec/openapi/refs.js @@ -352,3 +352,155 @@ test('renders $ref schema with additional keywords', async (t) => { t.match(res.statusCode, 400) t.match(openapiObject.paths['/url1'].get.parameters[0].schema, cookie) }) + +test('support absolute refs in schema', async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, { openapi: {} }) + fastify.register(async (instance) => { + instance.addSchema( + { + $id: 'ObjectA', + type: 'object', + properties: { + example: { + type: 'string' + } + } + } + ) + instance.addSchema( + { + $id: 'ObjectC', + type: 'object', + properties: { + referencedObjA: { + $ref: 'ObjectA#' + }, + referencedObjC: { + $ref: 'ObjectC#/properties/ObjectD' + }, + ObjectD: { + type: 'object', + properties: { + d: { + type: 'string' + } + } + } + } + } + ) + instance.post('/third/:sample', { + schema: { + body: { + $ref: 'ObjectC#' + }, + params: { + $ref: 'ObjectC#' + }, + response: { 200: { $ref: 'ObjectC#' } } + } + }, async () => ({ result: true })) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + t.equal(typeof openapiObject, 'object') + + // if validation is passed = success + await Swagger.validate(openapiObject) +}) + +test('support relative refs in schema', async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, { openapi: {} }) + fastify.register(async (instance) => { + instance.addSchema({ + $id: 'ObjectA', + type: 'object', + properties: { + sample: { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'object', properties: { d: { type: 'string' } } } + } + }, + someValue: { type: 'string' }, + relativeExample: { + $ref: '#/properties/sample' + } + } + }) + + instance.post('/first/:sample', { + schema: { + body: { + $ref: 'ObjectA#/properties/relativeExample' + }, + params: { + $ref: 'ObjectA#/properties/relativeExample' + }, + response: { 200: { $ref: 'ObjectA#/properties/relativeExample' } } + } + }, async () => ({ result: true })) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + t.equal(typeof openapiObject, 'object') + + // if validation is passed = success + await Swagger.validate(openapiObject) +}) + +test('support definitions keyword in schema', async (t) => { + const fastify = Fastify() + await fastify.register(fastifySwagger, { openapi: {} }) + + fastify.register(async (instance) => { + instance.addSchema({ + $id: 'ObjectA', + type: 'object', + definitions: { + sample: { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'object', properties: { d: { type: 'string' } } } + } + }, + someValue: { type: 'string' }, + relativeExample: { + $ref: '#/definitions/sample' + } + } + }) + + instance.post('/first/:sample', { + schema: { + body: { + $ref: 'ObjectA#/definitions/relativeExample' + }, + params: { + $ref: 'ObjectA#/definitions/relativeExample' + }, + response: { 200: { $ref: 'ObjectA#/definitions/relativeExample' } } + } + }, async () => ({ result: true })) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + t.equal(typeof openapiObject, 'object') + + // definitions are transformed to properties + // previous properties obj take precedence over definitions obj + t.equal(openapiObject.paths['/first/{sample}'].post.requestBody.content['application/json'].schema.$ref, '#/components/schemas/def-0/properties/relativeExample') + t.equal(openapiObject.paths['/first/{sample}'].post.responses['200'].content['application/json'].schema.$ref, '#/components/schemas/def-0/properties/relativeExample') + + await Swagger.validate(openapiObject) +}) diff --git a/test/util.js b/test/util.js index 56acef37..7d459892 100644 --- a/test/util.js +++ b/test/util.js @@ -1,7 +1,7 @@ 'use strict' const { test } = require('tap') -const { formatParamUrl } = require('../lib/util/common') +const { formatParamUrl, localSchemaRefToAbs, patchDefinitionsKeywordInSchema } = require('../lib/util/common') const cases = [ ['/example/:userId', '/example/{userId}'], @@ -25,3 +25,209 @@ test('formatParamUrl', async (t) => { t.equal(formatParamUrl(kase[0]), kase[1]) } }) + +test('local schema references to absolute schema references', async (t) => { + const input = { + $id: 'root', + properties: { + ObjectA: { + $id: 'ObjectA', + type: 'object', + properties: { + cat: { + $ref: 'Cat#' + }, + foo: { + type: 'string' + }, + bar: { + $ref: '#/properties/foo' + }, + barbar: { + $id: 'Barbar', + type: 'object', + properties: { + a: { + type: 'string' + }, + b: { + $ref: '#/properties/a' + } + } + }, + foofoo: { + $ref: '#' + } + } + } + } + } + + const expected = { + $id: 'root', + properties: { + ObjectA: { + $id: 'ObjectA', + type: 'object', + properties: { + cat: { + $ref: 'Cat#' + }, + foo: { + type: 'string' + }, + bar: { + $ref: 'root#/properties/ObjectA/properties/foo' + }, + barbar: { + $id: 'Barbar', + type: 'object', + properties: { + a: { + type: 'string' + }, + b: { + $ref: 'root#/properties/ObjectA/properties/barbar/properties/a' + } + } + }, + foofoo: { + $ref: 'root#/properties/ObjectA' + } + } + } + } + } + + const res = localSchemaRefToAbs(input) + t.match(expected, res) +}) + +test('definitions to properties keyword in schema', async (t) => { + const input = { + $id: 'root', + definitions: { + ObjectA: { + type: 'object', + properties: { + cat: { + $ref: 'root#/definitions/ObjectB' + }, + box: { + type: 'object', + properties: { + foo: { + $ref: 'root#/definitions/ObjectA/definitions/ObjectC' + } + } + } + }, + definitions: { + ObjectC: { + type: 'object', + properties: { + foo: { + type: 'string' + } + } + } + } + }, + ObjectB: { + type: 'object', + properties: { + sample: { + type: 'string' + } + } + } + } + } + + const expected = { + $id: 'root', + properties: { + ObjectA: { + type: 'object', + properties: { + cat: { + $ref: 'root#/properties/ObjectB' + }, + box: { + type: 'object', + properties: { + foo: { + $ref: 'root#/properties/ObjectA/properties/ObjectC' + } + } + }, + ObjectC: { + type: 'object', + properties: { + foo: { + type: 'string' + } + } + } + } + }, + ObjectB: { + type: 'object', + properties: { + sample: { + type: 'string' + } + } + } + } + } + + const res = patchDefinitionsKeywordInSchema(input) + t.match(expected, res) +}) + +test('properties precedence on definitions->properties merge)', async (t) => { + const input = { + $id: 'root', + definitions: { + ObjectA: { + type: 'object', + properties: { + foo: { + type: 'string' + }, + bar: { + type: 'string' + } + } + } + }, + properties: { + ObjectA: { + type: 'object', + properties: { + foobar: { + type: 'string' + } + } + } + } + } + + const expected = { + $id: 'root', + properties: { + ObjectA: { + type: 'object', + properties: { + foobar: { + type: 'string' + } + } + } + } + } + + const res = patchDefinitionsKeywordInSchema(input) + t.match(expected, res) +})