From 516b53894675949da514d2eb949c6761befaeae9 Mon Sep 17 00:00:00 2001 From: Peter Mouland Date: Sun, 26 Sep 2021 11:18:47 +0100 Subject: [PATCH] fix: nested refs or swagger spec --- lib/spec/openapi/index.js | 7 ++-- lib/spec/openapi/utils.js | 21 ++++-------- lib/spec/swagger/index.js | 7 ++-- lib/spec/swagger/utils.js | 29 ++++++++++++++--- lib/util/common.js | 15 ++++++++- test/spec/openapi/refs.js | 64 +++++++++++++++++++++++++++++++++++++ test/spec/swagger/refs.js | 67 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 25 deletions(-) diff --git a/lib/spec/openapi/index.js b/lib/spec/openapi/index.js index 0f6a03b5..ef219284 100644 --- a/lib/spec/openapi/index.js +++ b/lib/spec/openapi/index.js @@ -2,7 +2,7 @@ const yaml = require('js-yaml') const { shouldRouteHide } = require('../../util/common') -const { prepareDefaultOptions, prepareOpenapiObject, prepareOpenapiMethod, normalizeUrl } = require('./utils') +const { prepareDefaultOptions, prepareOpenapiObject, prepareOpenapiMethod, normalizeUrl, transformDefsToComponents } = require('./utils') module.exports = function (opts, cache, routes, Ref, done) { let ref @@ -29,7 +29,10 @@ module.exports = function (opts, cache, routes, Ref, done) { // The $ids are needed by Ref() to check the URI so we need // to remove them at the end of the process Object.values(openapiObject.components.schemas) - .forEach((_) => { delete _.$id }) + .forEach((_) => { + delete _.$id + if (_.properties) transformDefsToComponents(_.properties) + }) for (const route of routes) { const schema = defOpts.transform diff --git a/lib/spec/openapi/utils.js b/lib/spec/openapi/utils.js index 79761295..ddf6e6d0 100644 --- a/lib/spec/openapi/utils.js +++ b/lib/spec/openapi/utils.js @@ -1,6 +1,6 @@ 'use strict' -const { readPackageJson, formatParamUrl, resolveLocalRef } = require('../../util/common') +const { readPackageJson, formatParamUrl, resolveLocalRef, convertExamplesArrayToObject } = require('../../util/common') const { xResponseDescription, xConsume } = require('../../constants') const { rawRequired } = require('../../symbols') @@ -81,8 +81,10 @@ function normalizeUrl (url, servers, stripBasePath) { function transformDefsToComponents (jsonSchema) { if (typeof jsonSchema === 'object' && jsonSchema !== null) { Object.keys(jsonSchema).forEach(function (key) { - if (key === '$ref') { + if (key === '$ref' && jsonSchema[key].indexOf('definitions') > -1) { jsonSchema[key] = jsonSchema[key].replace('definitions', 'components/schemas') + } else if (key === '$ref') { + jsonSchema[key] = `#/components/schemas/${jsonSchema[key]}` } else if (key === 'examples' && Array.isArray(jsonSchema[key]) && (jsonSchema[key].length > 1)) { jsonSchema.examples = convertExamplesArrayToObject(jsonSchema.examples) } else if (key === 'examples' && Array.isArray(jsonSchema[key]) && (jsonSchema[key].length === 1)) { @@ -96,18 +98,6 @@ function transformDefsToComponents (jsonSchema) { return jsonSchema } -function convertExamplesArrayToObject (examples) { - return examples.reduce((examplesObject, example, index) => { - if (typeof example === 'object') { - examplesObject['example' + (index + 1)] = { value: example } - } else { - examplesObject[example] = { value: example } - } - - return examplesObject - }, {}) -} - // For supported keys read: // https://swagger.io/docs/specification/describing-parameters/ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas) { @@ -323,5 +313,6 @@ module.exports = { prepareDefaultOptions, prepareOpenapiObject, prepareOpenapiMethod, - normalizeUrl + normalizeUrl, + transformDefsToComponents } diff --git a/lib/spec/swagger/index.js b/lib/spec/swagger/index.js index 485a58dc..0f29c5b5 100644 --- a/lib/spec/swagger/index.js +++ b/lib/spec/swagger/index.js @@ -2,7 +2,7 @@ const yaml = require('js-yaml') const { shouldRouteHide } = require('../../util/common') -const { prepareDefaultOptions, prepareSwaggerObject, prepareSwaggerMethod, normalizeUrl } = require('./utils') +const { prepareDefaultOptions, prepareSwaggerObject, prepareSwaggerMethod, normalizeUrl, transformDefs } = require('./utils') module.exports = function (opts, cache, routes, Ref, done) { let ref @@ -28,7 +28,10 @@ module.exports = function (opts, cache, routes, Ref, done) { // The $ids are needed by Ref() to check the URI so we need // to remove them at the end of the process Object.values(swaggerObject.definitions) - .forEach(_ => { delete _.$id }) + .forEach(_ => { + delete _.$id + if (_.properties) transformDefs(_.properties) + }) swaggerObject.paths = {} for (const route of routes) { diff --git a/lib/spec/swagger/utils.js b/lib/spec/swagger/utils.js index 2a465d6a..b560dd36 100644 --- a/lib/spec/swagger/utils.js +++ b/lib/spec/swagger/utils.js @@ -1,6 +1,6 @@ 'use strict' -const { readPackageJson, formatParamUrl, resolveLocalRef } = require('../../util/common') +const { readPackageJson, formatParamUrl, resolveLocalRef, convertExamplesArrayToObject } = require('../../util/common') const { xResponseDescription, xConsume } = require('../../constants') function prepareDefaultOptions (opts) { @@ -157,9 +157,9 @@ function isConsumesFormOnly (schema) { const consumes = schema.consumes return ( consumes && - consumes.length === 1 && - (consumes[0] === 'application/x-www-form-urlencoded' || - consumes[0] === 'multipart/form-data') + consumes.length === 1 && + (consumes[0] === 'application/x-www-form-urlencoded' || + consumes[0] === 'multipart/form-data') ) } @@ -266,9 +266,28 @@ function prepareSwaggerMethod (schema, ref, swaggerObject) { return swaggerMethod } +function transformDefs (jsonSchema) { + if (typeof jsonSchema === 'object' && jsonSchema !== null) { + Object.keys(jsonSchema).forEach(function (key) { + if (key === '$ref') { + jsonSchema[key] = `#/definitions/${jsonSchema[key]}` + } else if (key === 'examples' && Array.isArray(jsonSchema[key]) && (jsonSchema[key].length > 1)) { + jsonSchema.examples = convertExamplesArrayToObject(jsonSchema.examples) + } else if (key === 'examples' && Array.isArray(jsonSchema[key]) && (jsonSchema[key].length === 1)) { + jsonSchema.example = jsonSchema[key][0] + delete jsonSchema[key] + } else { + jsonSchema[key] = transformDefs(jsonSchema[key]) + } + }) + } + return jsonSchema +} + module.exports = { prepareDefaultOptions, prepareSwaggerObject, prepareSwaggerMethod, - normalizeUrl + normalizeUrl, + transformDefs } diff --git a/lib/util/common.js b/lib/util/common.js index e0ba2337..84f73aa0 100644 --- a/lib/util/common.js +++ b/lib/util/common.js @@ -129,11 +129,24 @@ function resolveSwaggerFunction (opts, cache, routes, Ref, done) { } } +function convertExamplesArrayToObject (examples) { + return examples.reduce((examplesObject, example, index) => { + if (typeof example === 'object') { + examplesObject['example' + (index + 1)] = { value: example } + } else { + examplesObject[example] = { value: example } + } + + return examplesObject + }, {}) +} + module.exports = { addHook, shouldRouteHide, readPackageJson, formatParamUrl, resolveLocalRef, - resolveSwaggerFunction + resolveSwaggerFunction, + convertExamplesArrayToObject } diff --git a/test/spec/openapi/refs.js b/test/spec/openapi/refs.js index 7ed5cff6..a496f3e0 100644 --- a/test/spec/openapi/refs.js +++ b/test/spec/openapi/refs.js @@ -32,3 +32,67 @@ test('support $ref schema', t => { }) }) }) + +test('support nested $ref schema : complex case', async (t) => { + const fastify = Fastify() + const openapiOption = { + openapi: {}, + refResolver: { + buildLocalReference: (json, baseUri, fragment, i) => { + return json.$id || `def-${i}` + } + } + } + fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.addSchema({ $id: 'schemaA', type: 'object', properties: { id: { type: 'integer' } } }) + instance.addSchema({ $id: 'schemaB', type: 'object', properties: { id: { type: 'string' } } }) + instance.addSchema({ $id: 'schemaC', type: 'object', properties: { a: { type: 'array', items: { $ref: 'schemaA' } } } }) + instance.addSchema({ $id: 'schemaD', type: 'object', properties: { b: { $ref: 'schemaB' }, c: { $ref: 'schemaC' } } }) + instance.post('/url1', { schema: { body: { $ref: 'schemaD' }, response: { 200: { $ref: 'schemaB' } } } }, () => {}) + instance.post('/url2', { schema: { body: { $ref: 'schemaC' }, response: { 200: { $ref: 'schemaA' } } } }, () => {}) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + t.equal(typeof openapiObject, 'object') + + const schemas = openapiObject.components.schemas + t.match(Object.keys(schemas), ['schemaA', 'schemaB', 'schemaC', 'schemaD']) + + // ref must be prefixed by '#/components/schemas/' + t.equal(schemas.schemaC.properties.a.items.$ref, '#/components/schemas/schemaA') + t.equal(schemas.schemaD.properties.b.$ref, '#/components/schemas/schemaB') + t.equal(schemas.schemaD.properties.c.$ref, '#/components/schemas/schemaC') + + await Swagger.validate(openapiObject) +}) + +test('support nested $ref schema : complex case without modifying buildLocalReference', async (t) => { + const fastify = Fastify() + fastify.register(fastifySwagger, openapiOption) + fastify.register(async (instance) => { + instance.addSchema({ $id: 'schemaA', type: 'object', properties: { id: { type: 'integer' } } }) + instance.addSchema({ $id: 'schemaB', type: 'object', properties: { id: { type: 'string' } } }) + instance.addSchema({ $id: 'schemaC', type: 'object', properties: { a: { type: 'array', items: { $ref: 'schemaA' } } } }) + instance.addSchema({ $id: 'schemaD', type: 'object', properties: { b: { $ref: 'schemaB' }, c: { $ref: 'schemaC' } } }) + instance.post('/url1', { schema: { body: { $ref: 'schemaD' }, response: { 200: { $ref: 'schemaB' } } } }, () => {}) + instance.post('/url2', { schema: { body: { $ref: 'schemaC' }, response: { 200: { $ref: 'schemaA' } } } }, () => {}) + }) + + await fastify.ready() + + const openapiObject = fastify.swagger() + t.equal(typeof openapiObject, 'object') + + const schemas = openapiObject.components.schemas + t.match(Object.keys(schemas), ['def-0', 'def-1', 'def-2', 'def-3']) + + // ref must be prefixed by '#/components/schemas/' + t.equal(schemas['def-2'].properties.a.items.$ref, '#/components/schemas/def-0') + t.equal(schemas['def-3'].properties.b.$ref, '#/components/schemas/def-1') + t.equal(schemas['def-3'].properties.c.$ref, '#/components/schemas/def-2') + + await Swagger.validate(openapiObject) +}) diff --git a/test/spec/swagger/refs.js b/test/spec/swagger/refs.js index 9acc349d..ea35bc15 100644 --- a/test/spec/swagger/refs.js +++ b/test/spec/swagger/refs.js @@ -64,3 +64,70 @@ test('support $ref schema', async t => { await Swagger.validate(res.json()) t.pass('valid swagger object') }) + +test('support nested $ref schema : complex case', async (t) => { + const fastify = Fastify() + fastify.register(fastifySwagger, { + refResolver: { + buildLocalReference (json) { + return json.$id + } + } + }) + fastify.register(async (instance) => { + instance.addSchema({ $id: 'schemaA', type: 'object', properties: { id: { type: 'integer' } } }) + instance.addSchema({ $id: 'schemaB', type: 'object', properties: { id: { type: 'string' } } }) + instance.addSchema({ $id: 'schemaC', type: 'object', properties: { a: { type: 'array', items: { $ref: 'schemaA' } } } }) + instance.addSchema({ $id: 'schemaD', type: 'object', properties: { b: { $ref: 'schemaB' }, c: { $ref: 'schemaC' } } }) + instance.post('/url1', { schema: { body: { $ref: 'schemaD' }, response: { 200: { $ref: 'schemaB' } } } }, () => {}) + instance.post('/url2', { schema: { body: { $ref: 'schemaC' }, response: { 200: { $ref: 'schemaA' } } } }, () => {}) + }) + + await fastify.ready() + + const swaggerObject = fastify.swagger() + t.equal(typeof swaggerObject, 'object') + + const definitions = swaggerObject.definitions + + t.match(Object.keys(definitions), ['schemaA', 'schemaB', 'schemaC', 'schemaD']) + + // ref must be prefixed by '#/definitions/' + t.equal(definitions.schemaC.properties.a.items.$ref, '#/definitions/schemaA') + t.equal(definitions.schemaD.properties.b.$ref, '#/definitions/schemaB') + t.equal(definitions.schemaD.properties.c.$ref, '#/definitions/schemaC') + + await Swagger.validate(swaggerObject) +}) + +test('support nested $ref schema : complex case without modifying buildLocalReference', async (t) => { + const fastify = Fastify() + fastify.register(fastifySwagger, { + routePrefix: '/docs', + exposeRoute: true + }) + fastify.register(async (instance) => { + instance.addSchema({ $id: 'schemaA', type: 'object', properties: { id: { type: 'integer' } } }) + instance.addSchema({ $id: 'schemaB', type: 'object', properties: { id: { type: 'string' } } }) + instance.addSchema({ $id: 'schemaC', type: 'object', properties: { a: { type: 'array', items: { $ref: 'schemaA' } } } }) + instance.addSchema({ $id: 'schemaD', type: 'object', properties: { b: { $ref: 'schemaB' }, c: { $ref: 'schemaC' } } }) + instance.post('/url1', { schema: { body: { $ref: 'schemaD' }, response: { 200: { $ref: 'schemaB' } } } }, () => {}) + instance.post('/url2', { schema: { body: { $ref: 'schemaC' }, response: { 200: { $ref: 'schemaA' } } } }, () => {}) + }) + + await fastify.ready() + + const swaggerObject = fastify.swagger() + t.equal(typeof swaggerObject, 'object') + + const definitions = swaggerObject.definitions + + t.match(Object.keys(definitions), ['def-0', 'def-1', 'def-2', 'def-3']) + + // ref must be prefixed by '#/definitions/' + t.equal(definitions['def-2'].properties.a.items.$ref, '#/definitions/schemaA') + t.equal(definitions['def-3'].properties.b.$ref, '#/definitions/schemaB') + t.equal(definitions['def-3'].properties.c.$ref, '#/definitions/schemaC') + + await Swagger.validate(swaggerObject) +})