From 7511cb71762b2fa82d90c94311fd153815ddd11b Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 15:06:13 +0800 Subject: [PATCH 01/46] refactor: better file structure - reduce the number of files in root folder --- index.js | 2 +- dynamic.js => lib/dynamic.js | 2 +- routes.js => lib/routes.js | 4 ++-- static.js => lib/static.js | 0 test/static.js | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) rename dynamic.js => lib/dynamic.js (99%) rename routes.js => lib/routes.js (94%) rename static.js => lib/static.js (100%) diff --git a/index.js b/index.js index ca388af2..ef67c663 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ const fp = require('fastify-plugin') -const setup = { dynamic: require('./dynamic'), static: require('./static') } +const setup = { dynamic: require('./lib/dynamic'), static: require('./lib/static') } function fastifySwagger (fastify, opts, next) { opts = opts || {} diff --git a/dynamic.js b/lib/dynamic.js similarity index 99% rename from dynamic.js rename to lib/dynamic.js index 53c310b4..48886f1c 100644 --- a/dynamic.js +++ b/lib/dynamic.js @@ -81,7 +81,7 @@ module.exports = function (fastify, opts, next) { let pkg try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'))) + pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) } catch (err) { return next(err) } diff --git a/routes.js b/lib/routes.js similarity index 94% rename from routes.js rename to lib/routes.js index 8e8c7a95..62860c6e 100644 --- a/routes.js +++ b/lib/routes.js @@ -51,13 +51,13 @@ function fastifySwagger (fastify, opts, next) { // serve swagger-ui with the help of fastify-static fastify.register(fastifyStatic, { - root: path.join(__dirname, 'static'), + root: path.join(__dirname, '..', 'static'), prefix: staticPrefix, decorateReply: false }) fastify.register(fastifyStatic, { - root: opts.baseDir || __dirname, + root: opts.baseDir || path.join(__dirname, '..'), serve: false }) diff --git a/static.js b/lib/static.js similarity index 100% rename from static.js rename to lib/static.js diff --git a/test/static.js b/test/static.js index db108093..db95b9d4 100644 --- a/test/static.js +++ b/test/static.js @@ -5,7 +5,7 @@ const t = require('tap') const test = t.test const Fastify = require('fastify') const fastifySwagger = require('../index') -const fastifySwaggerDynamic = require('../dynamic') +const fastifySwaggerDynamic = require('../lib/dynamic') const yaml = require('js-yaml') const resolve = require('path').resolve @@ -615,7 +615,7 @@ test('inserts default package name', t => { const testPackageJSON = path.join(__dirname, '../examples/test-package.json') path.join = (...args) => { - if (args[1] === 'package.json') { + if (args[2] === 'package.json') { return testPackageJSON } return originalPathJoin(...args) @@ -650,7 +650,7 @@ test('throws an error if cannot parse package\'s JSON', t => { const testPackageJSON = path.join(__dirname, '') path.join = (...args) => { - if (args[1] === 'package.json') { + if (args[2] === 'package.json') { return testPackageJSON } return originalPathJoin(...args) From 4d4b9156fc152fbc7539394108ebae3c5cb3bfef Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 15:31:35 +0800 Subject: [PATCH 02/46] refactor: extract common function - hook - formatParamUrl - consumesFormOnly - plainJsonObjectToSwagger2 - localRefResolve --- lib/dynamic.js | 126 ++------------------------------------------ lib/util.js | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 123 deletions(-) create mode 100644 lib/util.js diff --git a/lib/dynamic.js b/lib/dynamic.js index 48886f1c..97d0e4e0 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -3,32 +3,13 @@ const fs = require('fs') const path = require('path') const yaml = require('js-yaml') -const Ref = require('json-schema-resolver') +const { addHook, formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') module.exports = function (fastify, opts, next) { fastify.decorate('swagger', swagger) - const routes = [] - const sharedSchemasMap = new Map() let ref - - fastify.addHook('onRoute', (routeOptions) => { - routes.push(routeOptions) - }) - - fastify.addHook('onRegister', async (instance) => { - // we need to wait the ready event to get all the .getSchemas() - // otherwise it will be empty - instance.addHook('onReady', (done) => { - const allSchemas = instance.getSchemas() - for (const schemaId of Object.keys(allSchemas)) { - if (!sharedSchemasMap.has(schemaId)) { - sharedSchemasMap.set(schemaId, allSchemas[schemaId]) - } - } - done() - }) - }) + const { routes, Ref } = addHook(fastify) opts = Object.assign({}, { exposeRoute: false, @@ -123,9 +104,7 @@ module.exports = function (fastify, opts, next) { swaggerObject[key] = value } - const externalSchemas = Array.from(sharedSchemasMap.values()) - - ref = Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) + ref = Ref() swaggerObject.definitions = { ...swaggerObject.definitions, ...(ref.definitions().definitions) @@ -312,102 +291,3 @@ module.exports = function (fastify, opts, next) { next() } - -function consumesFormOnly (schema) { - const consumes = schema.consumes - return ( - consumes && - consumes.length === 1 && - (consumes[0] === 'application/x-www-form-urlencoded' || - consumes[0] === 'multipart/form-data') - ) -} - -// The swagger standard does not accept the url param with ':' -// so '/user/:id' is not valid. -// This function converts the url in a swagger compliant url string -// => '/user/{id}' -function formatParamUrl (url) { - let start = url.indexOf('/:') - if (start === -1) return url - - const end = url.indexOf('/', ++start) - - if (end === -1) { - return url.slice(0, start) + '{' + url.slice(++start) + '}' - } else { - return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) - } -} - -// For supported keys read: -// https://swagger.io/docs/specification/2-0/describing-parameters/ -function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { - const obj = localRefResolve(jsonSchema, externalSchemas) - let toSwaggerProp - switch (container) { - case 'query': - toSwaggerProp = function (properyName, jsonSchemaElement) { - jsonSchemaElement.in = container - jsonSchemaElement.name = properyName - return jsonSchemaElement - } - break - case 'formData': - toSwaggerProp = function (properyName, jsonSchemaElement) { - delete jsonSchemaElement.$id - jsonSchemaElement.in = container - jsonSchemaElement.name = properyName - - // https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding - if (jsonSchemaElement.contentEncoding === 'binary') { - delete jsonSchemaElement.contentEncoding // Must be removed - jsonSchemaElement.type = 'file' - } - - return jsonSchemaElement - } - break - case 'path': - toSwaggerProp = function (properyName, jsonSchemaElement) { - jsonSchemaElement.in = container - jsonSchemaElement.name = properyName - jsonSchemaElement.required = true - return jsonSchemaElement - } - break - case 'header': - toSwaggerProp = function (properyName, jsonSchemaElement) { - return { - in: 'header', - name: properyName, - required: jsonSchemaElement.required, - description: jsonSchemaElement.description, - type: jsonSchemaElement.type - } - } - break - } - - return Object.keys(obj).reduce((acc, propKey) => { - acc.push(toSwaggerProp(propKey, obj[propKey])) - return acc - }, []) -} - -function localRefResolve (jsonSchema, externalSchemas) { - if (jsonSchema.type && jsonSchema.properties) { - // for the shorthand querystring/params/headers declaration - const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, h) => { - const required = (jsonSchema.required && jsonSchema.required.indexOf(h) >= 0) || false - const newProps = Object.assign({}, jsonSchema.properties[h], { required }) - return Object.assign({}, acc, { [h]: newProps }) - }, {}) - - return propertiesMap - } - - // $ref is in the format: #/definitions// - const localReference = jsonSchema.$ref.split('/')[2] - return localRefResolve(externalSchemas[localReference], externalSchemas) -} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 00000000..d0d2b9cc --- /dev/null +++ b/lib/util.js @@ -0,0 +1,139 @@ +const Ref = require('json-schema-resolver') + +function addHook (fastify) { + const routes = [] + const sharedSchemasMap = new Map() + + fastify.addHook('onRoute', (routeOptions) => { + routes.push(routeOptions) + }) + + fastify.addHook('onRegister', async (instance) => { + // we need to wait the ready event to get all the .getSchemas() + // otherwise it will be empty + instance.addHook('onReady', (done) => { + const allSchemas = instance.getSchemas() + for (const schemaId of Object.keys(allSchemas)) { + if (!sharedSchemasMap.has(schemaId)) { + sharedSchemasMap.set(schemaId, allSchemas[schemaId]) + } + } + done() + }) + }) + + return { + routes, + Ref () { + const externalSchemas = Array.from(sharedSchemasMap.values()) + return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) + } + } +} + +// The swagger standard does not accept the url param with ':' +// so '/user/:id' is not valid. +// This function converts the url in a swagger compliant url string +// => '/user/{id}' +function formatParamUrl (url) { + let start = url.indexOf('/:') + if (start === -1) return url + + const end = url.indexOf('/', ++start) + + if (end === -1) { + return url.slice(0, start) + '{' + url.slice(++start) + '}' + } else { + return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) + } +} + +function consumesFormOnly (schema) { + const consumes = schema.consumes + return ( + consumes && + consumes.length === 1 && + (consumes[0] === 'application/x-www-form-urlencoded' || + consumes[0] === 'multipart/form-data') + ) +} + +// For supported keys read: +// https://swagger.io/docs/specification/2-0/describing-parameters/ +function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { + const obj = localRefResolve(jsonSchema, externalSchemas) + let toSwaggerProp + switch (container) { + case 'query': + toSwaggerProp = function (properyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = properyName + return jsonSchemaElement + } + break + case 'formData': + toSwaggerProp = function (properyName, jsonSchemaElement) { + delete jsonSchemaElement.$id + jsonSchemaElement.in = container + jsonSchemaElement.name = properyName + + // https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding + if (jsonSchemaElement.contentEncoding === 'binary') { + delete jsonSchemaElement.contentEncoding // Must be removed + jsonSchemaElement.type = 'file' + } + + return jsonSchemaElement + } + break + case 'path': + toSwaggerProp = function (properyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = properyName + jsonSchemaElement.required = true + return jsonSchemaElement + } + break + case 'header': + toSwaggerProp = function (properyName, jsonSchemaElement) { + return { + in: 'header', + name: properyName, + required: jsonSchemaElement.required, + description: jsonSchemaElement.description, + type: jsonSchemaElement.type + } + } + break + } + + return Object.keys(obj).reduce((acc, propKey) => { + acc.push(toSwaggerProp(propKey, obj[propKey])) + return acc + }, []) +} + +function localRefResolve (jsonSchema, externalSchemas) { + if (jsonSchema.type && jsonSchema.properties) { + // for the shorthand querystring/params/headers declaration + const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, h) => { + const required = (jsonSchema.required && jsonSchema.required.indexOf(h) >= 0) || false + const newProps = Object.assign({}, jsonSchema.properties[h], { required }) + return Object.assign({}, acc, { [h]: newProps }) + }, {}) + + return propertiesMap + } + + // $ref is in the format: #/definitions// + const localReference = jsonSchema.$ref.split('/')[2] + return localRefResolve(externalSchemas[localReference], externalSchemas) +} + +module.exports = { + addHook, + formatParamUrl, + consumesFormOnly, + plainJsonObjectToSwagger2, + localRefResolve +} From 3e5c54cbe5a6cd0d442f13c58c0e9670e86b11b0 Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 16:04:03 +0800 Subject: [PATCH 03/46] refactor: split dynamic handle to swagger and openapi --- lib/dynamic.js | 269 +----------------------------------------------- lib/swagger.js | 270 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 266 deletions(-) create mode 100644 lib/swagger.js diff --git a/lib/dynamic.js b/lib/dynamic.js index 97d0e4e0..fcabebda 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -1,14 +1,9 @@ 'use strict' -const fs = require('fs') -const path = require('path') -const yaml = require('js-yaml') -const { addHook, formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') +const { addHook } = require('./util') +const buildSwagger = require('./swagger') module.exports = function (fastify, opts, next) { - fastify.decorate('swagger', swagger) - - let ref const { routes, Ref } = addHook(fastify) opts = Object.assign({}, { @@ -19,28 +14,6 @@ module.exports = function (fastify, opts, next) { transform: null }, opts || {}) - const info = opts.swagger.info || null - const host = opts.swagger.host || null - const schemes = opts.swagger.schemes || null - const consumes = opts.swagger.consumes || null - const produces = opts.swagger.produces || null - const definitions = opts.swagger.definitions || null - const basePath = opts.swagger.basePath || null - const securityDefinitions = opts.swagger.securityDefinitions || null - const security = opts.swagger.security || null - const tags = opts.swagger.tags || null - const externalDocs = opts.swagger.externalDocs || null - const stripBasePath = opts.stripBasePath - const transform = opts.transform - const hiddenTag = opts.hiddenTag - const extensions = [] - - for (const [key, value] of Object.entries(opts.swagger)) { - if (key.startsWith('x-')) { - extensions.push([key, value]) - } - } - if (opts.exposeRoute === true) { const prefix = opts.routePrefix || '/documentation' fastify.register(require('./routes'), { prefix }) @@ -51,243 +24,7 @@ module.exports = function (fastify, opts, next) { swaggerString: null } - function swagger (opts) { - if (opts && opts.yaml) { - if (cache.swaggerString) return cache.swaggerString - } else { - if (cache.swaggerObject) return cache.swaggerObject - } - - const swaggerObject = {} - let pkg - - try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) - } catch (err) { - return next(err) - } - - // Base swagger info - // this info is displayed in the swagger file - // in the same order as here - swaggerObject.swagger = '2.0' - if (info) { - swaggerObject.info = info - } else { - swaggerObject.info = { - version: '1.0.0', - title: pkg.name || '' - } - } - if (host) swaggerObject.host = host - if (schemes) swaggerObject.schemes = schemes - if (basePath) swaggerObject.basePath = basePath - if (consumes) swaggerObject.consumes = consumes - if (produces) swaggerObject.produces = produces - if (definitions) swaggerObject.definitions = definitions - else swaggerObject.definitions = {} - - if (securityDefinitions) { - swaggerObject.securityDefinitions = securityDefinitions - } - if (security) { - swaggerObject.security = security - } - if (tags) { - swaggerObject.tags = tags - } - if (externalDocs) { - swaggerObject.externalDocs = externalDocs - } - - for (const [key, value] of extensions) { - swaggerObject[key] = value - } - - ref = Ref() - swaggerObject.definitions = { - ...swaggerObject.definitions, - ...(ref.definitions().definitions) - } - - // 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 - Object.values(swaggerObject.definitions) - .forEach(_ => { delete _.$id }) - - swaggerObject.paths = {} - for (const route of routes) { - const schema = transform - ? transform(route.schema) - : route.schema - - if (schema && schema.hide) { - continue - } - - if (schema && schema.tags && schema.tags.includes(hiddenTag)) { - continue - } - - let path = stripBasePath && route.url.startsWith(basePath) - ? route.url.replace(basePath, '') - : route.url - if (!path.startsWith('/')) { - path = '/' + path - } - const url = formatParamUrl(path) - - const swaggerRoute = swaggerObject.paths[url] || {} - - const swaggerMethod = {} - const parameters = [] - - // route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH'] - const methods = typeof route.method === 'string' ? [route.method] : route.method - - for (const method of methods) { - swaggerRoute[method.toLowerCase()] = swaggerMethod - } - - // All the data the user can give us, is via the schema object - if (schema) { - // the resulting schema will be in this order - if (schema.operationId) { - swaggerMethod.operationId = schema.operationId - } - - if (schema.summary) { - swaggerMethod.summary = schema.summary - } - - if (schema.description) { - swaggerMethod.description = schema.description - } - - if (schema.tags) { - swaggerMethod.tags = schema.tags - } - - if (schema.produces) { - swaggerMethod.produces = schema.produces - } - - if (schema.consumes) { - swaggerMethod.consumes = schema.consumes - } - - if (schema.querystring) { - getQueryParams(parameters, schema.querystring) - } - - if (schema.body) { - const consumesAllFormOnly = - consumesFormOnly(schema) || consumesFormOnly(swaggerObject) - consumesAllFormOnly - ? getFormParams(parameters, schema.body) - : getBodyParams(parameters, schema.body) - } - - if (schema.params) { - getPathParams(parameters, schema.params) - } - - if (schema.headers) { - getHeaderParams(parameters, schema.headers) - } - - if (parameters.length) { - swaggerMethod.parameters = parameters - } - - if (schema.deprecated) { - swaggerMethod.deprecated = schema.deprecated - } - - if (schema.security) { - swaggerMethod.security = schema.security - } - - for (const key of Object.keys(schema)) { - if (key.startsWith('x-')) { - swaggerMethod[key] = schema[key] - } - } - } - - swaggerMethod.responses = genResponse(schema ? schema.response : null) - - swaggerObject.paths[url] = swaggerRoute - } - - if (opts && opts.yaml) { - const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) - cache.swaggerString = swaggerString - return swaggerString - } - - cache.swaggerObject = swaggerObject - return swaggerObject - - function getBodyParams (parameters, body) { - const bodyResolved = ref.resolve(body) - - const param = {} - param.name = 'body' - param.in = 'body' - param.schema = bodyResolved - parameters.push(param) - } - - function getFormParams (parameters, form) { - const resolved = ref.resolve(form) - const add = plainJsonObjectToSwagger2('formData', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getQueryParams (parameters, query) { - const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getPathParams (parameters, path) { - const resolved = ref.resolve(path) - const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getHeaderParams (parameters, headers) { - const resolved = ref.resolve(headers) - const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - // https://swagger.io/docs/specification/2-0/describing-responses/ - function genResponse (fastifyResponseJson) { - // if the user does not provided an out schema - if (!fastifyResponseJson) { - return { 200: { description: 'Default Response' } } - } - - const responsesContainer = {} - - Object.keys(fastifyResponseJson).forEach(key => { - // 2xx is not supported by swagger - - const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) - - responsesContainer[key] = { - schema: resolved, - description: rawJsonSchema.description || 'Default Response' - } - }) - - return responsesContainer - } - } + fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) next() } diff --git a/lib/swagger.js b/lib/swagger.js new file mode 100644 index 00000000..7f5c69e2 --- /dev/null +++ b/lib/swagger.js @@ -0,0 +1,270 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') +const { formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') + +module.exports = function (opts, routes, Ref, cache, next) { + let ref + + const info = opts.swagger.info || null + const host = opts.swagger.host || null + const schemes = opts.swagger.schemes || null + const consumes = opts.swagger.consumes || null + const produces = opts.swagger.produces || null + const definitions = opts.swagger.definitions || null + const basePath = opts.swagger.basePath || null + const securityDefinitions = opts.swagger.securityDefinitions || null + const security = opts.swagger.security || null + const tags = opts.swagger.tags || null + const externalDocs = opts.swagger.externalDocs || null + const stripBasePath = opts.stripBasePath + const transform = opts.transform + const hiddenTag = opts.hiddenTag + const extensions = [] + + for (const [key, value] of Object.entries(opts.swagger)) { + if (key.startsWith('x-')) { + extensions.push([key, value]) + } + } + + return function (opts) { + if (opts && opts.yaml) { + if (cache.swaggerString) return cache.swaggerString + } else { + if (cache.swaggerObject) return cache.swaggerObject + } + + const swaggerObject = {} + let pkg + + try { + pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return next(err) + } + + // Base swagger info + // this info is displayed in the swagger file + // in the same order as here + swaggerObject.swagger = '2.0' + if (info) { + swaggerObject.info = info + } else { + swaggerObject.info = { + version: '1.0.0', + title: pkg.name || '' + } + } + if (host) swaggerObject.host = host + if (schemes) swaggerObject.schemes = schemes + if (basePath) swaggerObject.basePath = basePath + if (consumes) swaggerObject.consumes = consumes + if (produces) swaggerObject.produces = produces + if (definitions) swaggerObject.definitions = definitions + else swaggerObject.definitions = {} + + if (securityDefinitions) { + swaggerObject.securityDefinitions = securityDefinitions + } + if (security) { + swaggerObject.security = security + } + if (tags) { + swaggerObject.tags = tags + } + if (externalDocs) { + swaggerObject.externalDocs = externalDocs + } + + for (const [key, value] of extensions) { + swaggerObject[key] = value + } + + ref = Ref() + swaggerObject.definitions = { + ...swaggerObject.definitions, + ...(ref.definitions().definitions) + } + + // 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 + Object.values(swaggerObject.definitions) + .forEach(_ => { delete _.$id }) + + swaggerObject.paths = {} + for (const route of routes) { + const schema = transform + ? transform(route.schema) + : route.schema + + if (schema && schema.hide) { + continue + } + + if (schema && schema.tags && schema.tags.includes(hiddenTag)) { + continue + } + + let path = stripBasePath && route.url.startsWith(basePath) + ? route.url.replace(basePath, '') + : route.url + if (!path.startsWith('/')) { + path = '/' + path + } + const url = formatParamUrl(path) + + const swaggerRoute = swaggerObject.paths[url] || {} + + const swaggerMethod = {} + const parameters = [] + + // route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH'] + const methods = typeof route.method === 'string' ? [route.method] : route.method + + for (const method of methods) { + swaggerRoute[method.toLowerCase()] = swaggerMethod + } + + // All the data the user can give us, is via the schema object + if (schema) { + // the resulting schema will be in this order + if (schema.operationId) { + swaggerMethod.operationId = schema.operationId + } + + if (schema.summary) { + swaggerMethod.summary = schema.summary + } + + if (schema.description) { + swaggerMethod.description = schema.description + } + + if (schema.tags) { + swaggerMethod.tags = schema.tags + } + + if (schema.produces) { + swaggerMethod.produces = schema.produces + } + + if (schema.consumes) { + swaggerMethod.consumes = schema.consumes + } + + if (schema.querystring) { + getQueryParams(parameters, schema.querystring) + } + + if (schema.body) { + const consumesAllFormOnly = + consumesFormOnly(schema) || consumesFormOnly(swaggerObject) + consumesAllFormOnly + ? getFormParams(parameters, schema.body) + : getBodyParams(parameters, schema.body) + } + + if (schema.params) { + getPathParams(parameters, schema.params) + } + + if (schema.headers) { + getHeaderParams(parameters, schema.headers) + } + + if (parameters.length) { + swaggerMethod.parameters = parameters + } + + if (schema.deprecated) { + swaggerMethod.deprecated = schema.deprecated + } + + if (schema.security) { + swaggerMethod.security = schema.security + } + + for (const key of Object.keys(schema)) { + if (key.startsWith('x-')) { + swaggerMethod[key] = schema[key] + } + } + } + + swaggerMethod.responses = genResponse(schema ? schema.response : null) + + swaggerObject.paths[url] = swaggerRoute + } + + if (opts && opts.yaml) { + const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) + cache.swaggerString = swaggerString + return swaggerString + } + + cache.swaggerObject = swaggerObject + return swaggerObject + + function getBodyParams (parameters, body) { + const bodyResolved = ref.resolve(body) + + const param = {} + param.name = 'body' + param.in = 'body' + param.schema = bodyResolved + parameters.push(param) + } + + function getFormParams (parameters, form) { + const resolved = ref.resolve(form) + const add = plainJsonObjectToSwagger2('formData', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + function getQueryParams (parameters, query) { + const resolved = ref.resolve(query) + const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + function getPathParams (parameters, path) { + const resolved = ref.resolve(path) + const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + function getHeaderParams (parameters, headers) { + const resolved = ref.resolve(headers) + const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + // https://swagger.io/docs/specification/2-0/describing-responses/ + function genResponse (fastifyResponseJson) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + // 2xx is not supported by swagger + + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + responsesContainer[key] = { + schema: resolved, + description: rawJsonSchema.description || 'Default Response' + } + }) + + return responsesContainer + } + } +} From 1b2973f12ee726a67f55b3fc55b5e02876c9b826 Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 19:00:23 +0800 Subject: [PATCH 04/46] feat: add openapi 3 support --- lib/dynamic.js | 8 +- lib/openapi.js | 262 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/util.js | 33 ++++++- 3 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 lib/openapi.js diff --git a/lib/dynamic.js b/lib/dynamic.js index fcabebda..b6e80abd 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -2,6 +2,7 @@ const { addHook } = require('./util') const buildSwagger = require('./swagger') +const buildOpenapi = require('./openapi') module.exports = function (fastify, opts, next) { const { routes, Ref } = addHook(fastify) @@ -10,6 +11,7 @@ module.exports = function (fastify, opts, next) { exposeRoute: false, hiddenTag: 'X-HIDDEN', stripBasePath: true, + openapi: {}, swagger: {}, transform: null }, opts || {}) @@ -24,7 +26,11 @@ module.exports = function (fastify, opts, next) { swaggerString: null } - fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) + if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { + fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, next)) + } else { + fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) + } next() } diff --git a/lib/openapi.js b/lib/openapi.js new file mode 100644 index 00000000..d3a32414 --- /dev/null +++ b/lib/openapi.js @@ -0,0 +1,262 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') +const { formatParamUrl, plainJsonObjectToSwagger2, swagger2ParametersToOpenapi3, stripBasePathByServers } = require('./util') + +module.exports = function (opts, routes, Ref, cache, next) { + let ref + + const info = opts.openapi.info || null + const servers = opts.openapi.servers || null + const components = opts.openapi.components || null + const tags = opts.openapi.tags || null + const externalDocs = opts.openapi.externalDocs || null + + const stripBasePath = opts.stripBasePath + const transform = opts.transform + const hiddenTag = opts.hiddenTag + const extensions = [] + + for (const [key, value] of Object.entries(opts.openapi)) { + if (key.startsWith('x-')) { + extensions.push([key, value]) + } + } + + return function (opts) { + if (opts && opts.yaml) { + if (cache.swaggerString) return cache.swaggerString + } else { + if (cache.swaggerObject) return cache.swaggerObject + } + + const swaggerObject = {} + let pkg + + try { + pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return next(err) + } + + // Base Openapi info + // this info is displayed in the swagger file + // in the same order as here + swaggerObject.openapi = '3.0.0' + if (info) { + swaggerObject.info = info + } else { + swaggerObject.info = { + version: '1.0.0', + title: pkg.name || '' + } + } + if (servers) { + swaggerObject.servers = servers + } + if (components) { + swaggerObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) + } else { + swaggerObject.components = { schemas: {} } + } + if (tags) { + swaggerObject.tags = tags + } + if (externalDocs) { + swaggerObject.externalDocs = externalDocs + } + + for (const [key, value] of extensions) { + swaggerObject[key] = value + } + + ref = Ref() + swaggerObject.components.schemas = { + ...swaggerObject.components.schemas, + ...(ref.definitions().definitions) + } + + // 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 + Object.values(swaggerObject.components.schemas) + .forEach(_ => { delete _.$id }) + + swaggerObject.paths = {} + + for (const route of routes) { + const schema = transform + ? transform(route.schema) + : route.schema + + if (schema && schema.hide) { + continue + } + + if (schema && schema.tags && schema.tags.includes(hiddenTag)) { + continue + } + + const path = stripBasePath + ? stripBasePathByServers(route.url, swaggerObject.servers) + : route.url + const url = formatParamUrl(path) + + const swaggerRoute = swaggerObject.paths[url] || {} + + const swaggerMethod = {} + const parameters = [] + + // route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH'] + const methods = typeof route.method === 'string' ? [route.method] : route.method + + for (const method of methods) { + swaggerRoute[method.toLowerCase()] = swaggerMethod + } + + // All the data the user can give us, is via the schema object + if (schema) { + // the resulting schema will be in this order + if (schema.operationId) { + swaggerMethod.operationId = schema.operationId + } + + if (schema.tags) { + swaggerMethod.tags = schema.tags + } + + if (schema.summary) { + swaggerMethod.summary = schema.summary + } + + if (schema.description) { + swaggerMethod.description = schema.description + } + + if (schema.externalDocs) { + swaggerMethod.externalDocs = schema.externalDocs + } + + if (schema.querystring) { + getQueryParams(parameters, schema.querystring) + } + + if (schema.body) { + swaggerMethod.requestBody = { + content: {} + } + getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes) + } + + if (schema.params) { + getPathParams(parameters, schema.params) + } + + if (schema.headers) { + getHeaderParams(parameters, schema.headers) + } + + if (parameters.length) { + swaggerMethod.parameters = parameters + } + + if (schema.deprecated) { + swaggerMethod.deprecated = schema.deprecated + } + + if (schema.security) { + swaggerMethod.security = schema.security + } + + if (schema.servers) { + swaggerMethod.servers = schema.servers + } + + for (const key of Object.keys(schema)) { + if (key.startsWith('x-')) { + swaggerMethod[key] = schema[key] + } + } + } + + swaggerMethod.responses = genResponse(schema ? schema.response : null) + + swaggerObject.paths[url] = swaggerRoute + } + + if (opts && opts.yaml) { + const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) + cache.swaggerString = swaggerString + return swaggerString + } + + cache.swaggerObject = swaggerObject + return swaggerObject + + function getBodyParams (parameters, body, consumes) { + const bodyResolved = ref.resolve(body) + + if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { + consumes = ['application/json'] + } + + consumes.forEach((consume) => { + parameters[consume] = { + schema: bodyResolved + } + }) + } + + function getQueryParams (parameters, query) { + const resolved = ref.resolve(query) + const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.components.schemas) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + } + + function getPathParams (parameters, path) { + const resolved = ref.resolve(path) + const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.components.schemas) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + } + + function getHeaderParams (parameters, headers) { + const resolved = ref.resolve(headers) + const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.components.schemas) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + } + + // https://swagger.io/docs/specification/2-0/describing-responses/ + function genResponse (fastifyResponseJson) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + // 2xx is not supported by swagger + + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + const content = { + 'application/json': {} + } + + content['application/json'] = { + schema: resolved + } + + responsesContainer[key] = { + content, + description: rawJsonSchema.description || 'Default Response' + } + }) + + return responsesContainer + } + } +} diff --git a/lib/util.js b/lib/util.js index d0d2b9cc..8766e555 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,3 +1,6 @@ +'use strict' + +const { URL } = require('url') const Ref = require('json-schema-resolver') function addHook (fastify) { @@ -113,6 +116,21 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { }, []) } +function swagger2ParametersToOpenapi3 (jsonSchema) { + jsonSchema.schema = {} + jsonSchema.schema.type = jsonSchema.type + if (jsonSchema.type === 'object') { + jsonSchema.schema.properties = jsonSchema.properties + } + if (jsonSchema.type === 'array') { + jsonSchema.schema.items = jsonSchema.items + } + delete jsonSchema.type + delete jsonSchema.properties + delete jsonSchema.items + return jsonSchema +} + function localRefResolve (jsonSchema, externalSchemas) { if (jsonSchema.type && jsonSchema.properties) { // for the shorthand querystring/params/headers declaration @@ -130,10 +148,23 @@ function localRefResolve (jsonSchema, externalSchemas) { return localRefResolve(externalSchemas[localReference], externalSchemas) } +function stripBasePathByServers (path, servers) { + servers = Array.isArray(servers) ? servers : [] + servers.forEach(function (server) { + const basePath = new URL(server.url).pathname + if (path.startsWith(basePath) && basePath !== '/') { + path = path.replace(basePath, '') + } + }) + return path +} + module.exports = { addHook, formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2, - localRefResolve + swagger2ParametersToOpenapi3, + localRefResolve, + stripBasePathByServers } From 9d75d9aeb8f06097f7095b2a68fa9c62b82520ec Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 19:01:16 +0800 Subject: [PATCH 05/46] test: add openapi 3 test case --- test/openapi.js | 633 ++++++++++++++++++++++++++++++++++++++++++++++++ test/static.js | 76 ++++++ 2 files changed, 709 insertions(+) create mode 100644 test/openapi.js diff --git a/test/openapi.js b/test/openapi.js new file mode 100644 index 00000000..7fdb29ef --- /dev/null +++ b/test/openapi.js @@ -0,0 +1,633 @@ +'use strict' + +const t = require('tap') +const test = t.test +const Fastify = require('fastify') +const Swagger = require('swagger-parser') +const yaml = require('js-yaml') +const fastifySwagger = require('../index') + +const swaggerInfo = { + openapi: { + info: { + title: 'Test swagger', + description: 'testing the fastify swagger api', + version: '0.1.0' + }, + servers: [ + { + url: 'http://localhost' + } + ], + tags: [ + { name: 'tag' } + ], + externalDocs: { + description: 'Find more info here', + url: 'https://swagger.io' + } + } +} + +const opts1 = { + schema: { + response: { + 200: { + type: 'object', + properties: { + hello: { type: 'string' } + } + } + }, + querystring: { + hello: { type: 'string' }, + world: { type: 'string' }, + foo: { type: 'array', items: { type: 'string' } }, + bar: { type: 'object', properties: { baz: { type: 'string' } } } + } + } +} + +const opts2 = { + schema: { + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + }, + required: ['hello'] + } + } +} + +const opts3 = { + schema: { + params: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'user id' + } + } + } + } +} + +const opts4 = { + schema: { + headers: { + type: 'object', + properties: { + authorization: { + type: 'string', + description: 'api token' + } + }, + required: ['authorization'] + } + } +} + +const opts5 = { + schema: { + headers: { + type: 'object', + properties: { + 'x-api-token': { + type: 'string', + description: 'optional api token' + }, + 'x-api-version': { + type: 'string', + description: 'optional api version' + } + } + }, + params: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'user id' + } + } + } + } +} + +const opts6 = { + schema: { + security: [ + { + apiKey: [] + } + ] + } +} + +const opts7 = { + schema: { + consumes: ['application/x-www-form-urlencoded'], + body: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } +} + +const opts8 = { + schema: { + 'x-tension': true + } +} + +test('fastify.swagger should return a valid swagger object', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', () => {}) + fastify.post('/', () => {}) + fastify.get('/example', opts1, () => {}) + fastify.post('/example', opts2, () => {}) + fastify.get('/parameters/:id', opts3, () => {}) + fastify.get('/headers', opts4, () => {}) + fastify.get('/headers/:id', opts5, () => {}) + fastify.get('/security', opts6, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('fastify.swagger should return a valid swagger yaml', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', () => {}) + fastify.post('/', () => {}) + fastify.get('/example', opts1, () => {}) + fastify.post('/example', opts2, () => {}) + fastify.get('/parameters/:id', opts3, () => {}) + fastify.get('/headers', opts4, () => {}) + fastify.get('/headers/:id', opts5, () => {}) + fastify.get('/security', opts6, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerYaml = fastify.swagger({ yaml: true }) + t.is(typeof swaggerYaml, 'string') + + try { + yaml.safeLoad(swaggerYaml) + t.pass('valid swagger yaml') + } catch (err) { + t.fail(err) + } + }) +}) + +test('hide support when property set in transform() - property', t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(fastifySwagger, { + ...swaggerInfo, + transform: schema => { + return { ...schema, hide: true } + } + }) + + const opts = { + schema: { + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/']) + }) +}) + +test('fastify.swagger components', t => { + t.plan(2) + const fastify = Fastify() + + swaggerInfo.openapi.components = { + schemas: { + ExampleModel: { + type: 'object', + properties: { + id: { + type: 'integer', + description: 'Some id' + }, + name: { + type: 'string', + description: 'Name of smthng' + } + } + } + } + } + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.deepEquals(swaggerObject.components, swaggerInfo.openapi.components) + delete swaggerInfo.openapi.components // remove what we just added + }) +}) + +test('hide support - tags Default', t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + const opts = { + schema: { + tags: ['X-HIDDEN'], + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/']) + }) +}) + +test('hide support - tags Custom', t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(fastifySwagger, { ...swaggerInfo, hiddenTag: 'NOP' }) + + const opts = { + schema: { + tags: ['NOP'], + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/']) + }) +}) + +test('deprecated route', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + const opts = { + schema: { + deprecated: true, + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + t.ok(swaggerObject.paths['/']) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('route meta info', t => { + t.plan(8) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + const opts = { + schema: { + operationId: 'doSomething', + summary: 'Route summary', + tags: ['tag'], + description: 'Route description', + servers: [ + { + url: 'https://localhost' + } + ], + externalDocs: { + description: 'Find more info here', + url: 'https://swagger.io' + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.equal(opts.schema.operationId, definedPath.operationId) + t.equal(opts.schema.summary, definedPath.summary) + t.same(opts.schema.tags, definedPath.tags) + t.equal(opts.schema.description, definedPath.description) + t.equal(opts.schema.servers, definedPath.servers) + t.equal(opts.schema.externalDocs, definedPath.externalDocs) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { + t.plan(3) + const fastify = Fastify() + fastify.register(fastifySwagger, swaggerInfo) + fastify.get('/', opts7, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.requestBody.content, { + 'application/x-www-form-urlencoded': { + schema: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + }) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('includes swagger extensions', t => { + t.plan(5) + const fastify = Fastify() + fastify.register(fastifySwagger, { openapi: { 'x-ternal': true } }) + fastify.get('/', opts8, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + t.ok(api['x-ternal']) + t.same(api['x-ternal'], true) + + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath['x-tension'], true) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('basePath support', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, { + openapi: Object.assign({}, swaggerInfo.openapi, { + servers: [ + { + url: 'http://localhost/prefix' + } + ] + }) + }) + + fastify.get('/prefix/endpoint', {}, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/prefix/endpoint']) + t.ok(swaggerObject.paths['/endpoint']) + }) +}) + +test('basePath maintained when stripBasePath is set to false', t => { + t.plan(4) + + const fastify = Fastify() + + fastify.register(fastifySwagger, { + stripBasePath: false, + openapi: Object.assign({}, swaggerInfo.openapi, { + servers: [ + { + url: 'http://localhost/foo' + } + ] + }) + }) + + fastify.get('/foo/endpoint', {}, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths.endpoint) + t.notOk(swaggerObject.paths['/endpoint']) + t.ok(swaggerObject.paths['/foo/endpoint']) + }) +}) + +test('cache - json', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.ready(err => { + t.error(err) + + fastify.swagger() + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('cache - yaml', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.ready(err => { + t.error(err) + + fastify.swagger({ yaml: true }) + const swaggerYaml = fastify.swagger({ yaml: true }) + t.is(typeof swaggerYaml, 'string') + + try { + yaml.safeLoad(swaggerYaml) + t.pass('valid swagger yaml') + } catch (err) { + t.fail(err) + } + }) +}) + +test('route with multiple method', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.route({ + method: ['GET', 'POST'], + url: '/', + handler: function (request, reply) { + reply.send({ hello: 'world' }) + } + }) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) diff --git a/test/static.js b/test/static.js index db95b9d4..2f335fac 100644 --- a/test/static.js +++ b/test/static.js @@ -633,6 +633,44 @@ test('inserts default package name', t => { ) }) +test('inserts default package name - openapi', t => { + const config = { + mode: 'dynamic', + openapi: { + servers: [] + }, + specification: { + path: './examples/example-static-specification.json' + }, + exposeRoute: true + } + + t.plan(2) + const fastify = Fastify() + fastify.register(fastifySwagger, config) + + const originalPathJoin = path.join + const testPackageJSON = path.join(__dirname, '../examples/test-package.json') + + path.join = (...args) => { + if (args[2] === 'package.json') { + return testPackageJSON + } + return originalPathJoin(...args) + } + + fastify.inject( + { + method: 'GET', + url: '/documentation/json' + }, + (err, res) => { + t.error(err) + t.pass('Inserted default package name.') + } + ) +}) + test('throws an error if cannot parse package\'s JSON', t => { const config = { mode: 'dynamic', @@ -668,6 +706,44 @@ test('throws an error if cannot parse package\'s JSON', t => { ) }) +test('throws an error if cannot parse package\'s JSON - openapi', t => { + const config = { + mode: 'dynamic', + openapi: { + servers: [] + }, + specification: { + path: './examples/example-static-specification.json' + }, + exposeRoute: true + } + + t.plan(2) + const fastify = Fastify() + fastify.register(fastifySwagger, config) + + const originalPathJoin = path.join + const testPackageJSON = path.join(__dirname, '') + + path.join = (...args) => { + if (args[2] === 'package.json') { + return testPackageJSON + } + return originalPathJoin(...args) + } + + fastify.inject( + { + method: 'GET', + url: '/documentation/json' + }, + (err, res) => { + t.error(err) + t.equal(err, null) + } + ) +}) + test('inserts default opts in fastifySwaggerDynamic (dynamic.js)', t => { t.plan(1) const fastify = Fastify() From 6eb9e6a440c5541ef13a8b2cceaf43edbb142d6a Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:07:23 +0800 Subject: [PATCH 06/46] feat: update openapi from 3.0.0 to 3.0.3 --- lib/openapi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openapi.js b/lib/openapi.js index d3a32414..48646918 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -44,7 +44,7 @@ module.exports = function (opts, routes, Ref, cache, next) { // Base Openapi info // this info is displayed in the swagger file // in the same order as here - swaggerObject.openapi = '3.0.0' + swaggerObject.openapi = '3.0.3' if (info) { swaggerObject.info = info } else { From ee190b1e7d10ea2e4a279a4b9466eb3d924bd820 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:08:18 +0800 Subject: [PATCH 07/46] refactor: typo - properyName to propertyName --- lib/util.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/util.js b/lib/util.js index 8766e555..40fae2f6 100644 --- a/lib/util.js +++ b/lib/util.js @@ -68,17 +68,17 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { let toSwaggerProp switch (container) { case 'query': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { jsonSchemaElement.in = container - jsonSchemaElement.name = properyName + jsonSchemaElement.name = propertyName return jsonSchemaElement } break case 'formData': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { delete jsonSchemaElement.$id jsonSchemaElement.in = container - jsonSchemaElement.name = properyName + jsonSchemaElement.name = propertyName // https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding if (jsonSchemaElement.contentEncoding === 'binary') { @@ -90,18 +90,18 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { } break case 'path': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { jsonSchemaElement.in = container - jsonSchemaElement.name = properyName + jsonSchemaElement.name = propertyName jsonSchemaElement.required = true return jsonSchemaElement } break case 'header': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { return { in: 'header', - name: properyName, + name: propertyName, required: jsonSchemaElement.required, description: jsonSchemaElement.description, type: jsonSchemaElement.type From 0130700243cdd4dfaacf0aca57420a9f125ca9c8 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:09:01 +0800 Subject: [PATCH 08/46] refactor: more clear argument - h to headers --- lib/util.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/util.js b/lib/util.js index 40fae2f6..6f5a7052 100644 --- a/lib/util.js +++ b/lib/util.js @@ -134,10 +134,10 @@ function swagger2ParametersToOpenapi3 (jsonSchema) { function localRefResolve (jsonSchema, externalSchemas) { if (jsonSchema.type && jsonSchema.properties) { // for the shorthand querystring/params/headers declaration - const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, h) => { - const required = (jsonSchema.required && jsonSchema.required.indexOf(h) >= 0) || false - const newProps = Object.assign({}, jsonSchema.properties[h], { required }) - return Object.assign({}, acc, { [h]: newProps }) + const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => { + const required = (jsonSchema.required && jsonSchema.required.indexOf(headers) >= 0) || false + const newProps = Object.assign({}, jsonSchema.properties[headers], { required }) + return Object.assign({}, acc, { [headers]: newProps }) }, {}) return propertiesMap From 729f4dc3042e230eeec82a70e8c56e32c3750143 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:13:25 +0800 Subject: [PATCH 09/46] refactor: use map instead of reduce --- lib/util.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/util.js b/lib/util.js index 6f5a7052..268a212f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -110,10 +110,9 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { break } - return Object.keys(obj).reduce((acc, propKey) => { - acc.push(toSwaggerProp(propKey, obj[propKey])) - return acc - }, []) + return Object.keys(obj).map((propKey) => { + return toSwaggerProp(propKey, obj[propKey]) + }) } function swagger2ParametersToOpenapi3 (jsonSchema) { From a8ec31701bb0d0de236fa09c5b273254aeb0bba1 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:13:46 +0800 Subject: [PATCH 10/46] refactor: use done instead of next --- lib/dynamic.js | 8 ++++---- lib/openapi.js | 4 ++-- lib/routes.js | 4 ++-- lib/static.js | 22 +++++++++++----------- lib/swagger.js | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/dynamic.js b/lib/dynamic.js index b6e80abd..1098111c 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -4,7 +4,7 @@ const { addHook } = require('./util') const buildSwagger = require('./swagger') const buildOpenapi = require('./openapi') -module.exports = function (fastify, opts, next) { +module.exports = function (fastify, opts, done) { const { routes, Ref } = addHook(fastify) opts = Object.assign({}, { @@ -27,10 +27,10 @@ module.exports = function (fastify, opts, next) { } if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { - fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, next)) + fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, done)) } else { - fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) + fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, done)) } - next() + done() } diff --git a/lib/openapi.js b/lib/openapi.js index 48646918..95a8df0a 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -5,7 +5,7 @@ const path = require('path') const yaml = require('js-yaml') const { formatParamUrl, plainJsonObjectToSwagger2, swagger2ParametersToOpenapi3, stripBasePathByServers } = require('./util') -module.exports = function (opts, routes, Ref, cache, next) { +module.exports = function (opts, routes, Ref, cache, done) { let ref const info = opts.openapi.info || null @@ -38,7 +38,7 @@ module.exports = function (opts, routes, Ref, cache, next) { try { pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) } catch (err) { - return next(err) + return done(err) } // Base Openapi info diff --git a/lib/routes.js b/lib/routes.js index 62860c6e..011055d3 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -19,7 +19,7 @@ function getRedirectPathForTheRootRoute (url) { return redirectPath } -function fastifySwagger (fastify, opts, next) { +function fastifySwagger (fastify, opts, done) { fastify.route({ url: '/', method: 'GET', @@ -72,7 +72,7 @@ function fastifySwagger (fastify, opts, next) { } }) - next() + done() } module.exports = fastifySwagger diff --git a/lib/static.js b/lib/static.js index 23adbdb1..a2530f4c 100644 --- a/lib/static.js +++ b/lib/static.js @@ -4,25 +4,25 @@ const path = require('path') const fs = require('fs') const yaml = require('js-yaml') -module.exports = function (fastify, opts, next) { - if (!opts.specification) return next(new Error('specification is missing in the module options')) - if (typeof opts.specification !== 'object') return next(new Error('specification is not an object')) +module.exports = function (fastify, opts, done) { + if (!opts.specification) return done(new Error('specification is missing in the module options')) + if (typeof opts.specification !== 'object') return done(new Error('specification is not an object')) let swaggerObject = {} if (!opts.specification.path && !opts.specification.document) { - return next(new Error('both specification.path and specification.document are missing, should be path to the file or swagger document spec')) + return done(new Error('both specification.path and specification.document are missing, should be path to the file or swagger document spec')) } else if (opts.specification.path) { - if (typeof opts.specification.path !== 'string') return next(new Error('specification.path is not a string')) + if (typeof opts.specification.path !== 'string') return done(new Error('specification.path is not a string')) - if (!fs.existsSync(path.resolve(opts.specification.path))) return next(new Error(`${opts.specification.path} does not exist`)) + if (!fs.existsSync(path.resolve(opts.specification.path))) return done(new Error(`${opts.specification.path} does not exist`)) const extName = path.extname(opts.specification.path).toLowerCase() - if (['.yaml', '.json'].indexOf(extName) === -1) return next(new Error("specification.path extension name is not supported, should be one from ['.yaml', '.json']")) + if (['.yaml', '.json'].indexOf(extName) === -1) return done(new Error("specification.path extension name is not supported, should be one from ['.yaml', '.json']")) - if (opts.specification.postProcessor && typeof opts.specification.postProcessor !== 'function') return next(new Error('specification.postProcessor should be a function')) + if (opts.specification.postProcessor && typeof opts.specification.postProcessor !== 'function') return done(new Error('specification.postProcessor should be a function')) - if (opts.specification.baseDir && typeof opts.specification.baseDir !== 'string') return next(new Error('specification.baseDir should be string')) + if (opts.specification.baseDir && typeof opts.specification.baseDir !== 'string') return done(new Error('specification.baseDir should be string')) if (!opts.specification.baseDir) { opts.specification.baseDir = path.resolve(path.dirname(opts.specification.path)) @@ -51,7 +51,7 @@ module.exports = function (fastify, opts, next) { swaggerObject = opts.specification.postProcessor(swaggerObject) } } else { - if (typeof opts.specification.document !== 'object') return next(new Error('specification.document is not an object')) + if (typeof opts.specification.document !== 'object') return done(new Error('specification.document is not an object')) swaggerObject = opts.specification.document } @@ -89,5 +89,5 @@ module.exports = function (fastify, opts, next) { return swaggerObject } - next() + done() } diff --git a/lib/swagger.js b/lib/swagger.js index 7f5c69e2..3634e522 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -5,7 +5,7 @@ const path = require('path') const yaml = require('js-yaml') const { formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') -module.exports = function (opts, routes, Ref, cache, next) { +module.exports = function (opts, routes, Ref, cache, done) { let ref const info = opts.swagger.info || null @@ -43,7 +43,7 @@ module.exports = function (opts, routes, Ref, cache, next) { try { pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) } catch (err) { - return next(err) + return done(err) } // Base swagger info From 984e3c09306932941822b3d2f7117fcc9bfda23a Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:19:07 +0800 Subject: [PATCH 11/46] refactor: group functions - remove getQueryParams, getPathParams, getHeaderParams, getFormParams - add getParams --- lib/openapi.js | 22 +++++----------------- lib/swagger.js | 30 ++++++------------------------ 2 files changed, 11 insertions(+), 41 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 95a8df0a..f6feca89 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -140,7 +140,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getQueryParams(parameters, schema.querystring) + getParams('query', parameters, schema.querystring) } if (schema.body) { @@ -151,11 +151,11 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.params) { - getPathParams(parameters, schema.params) + getParams('path', parameters, schema.params) } if (schema.headers) { - getHeaderParams(parameters, schema.headers) + getParams('header', parameters, schema.headers) } if (parameters.length) { @@ -209,21 +209,9 @@ module.exports = function (opts, routes, Ref, cache, done) { }) } - function getQueryParams (parameters, query) { + function getParams (container, parameters, query) { const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.components.schemas) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) - } - - function getPathParams (parameters, path) { - const resolved = ref.resolve(path) - const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.components.schemas) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) - } - - function getHeaderParams (parameters, headers) { - const resolved = ref.resolve(headers) - const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.components.schemas) + const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.components.schemas) add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) } diff --git a/lib/swagger.js b/lib/swagger.js index 3634e522..ccb34881 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -157,23 +157,23 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getQueryParams(parameters, schema.querystring) + getParams('query', parameters, schema.querystring) } if (schema.body) { const consumesAllFormOnly = consumesFormOnly(schema) || consumesFormOnly(swaggerObject) consumesAllFormOnly - ? getFormParams(parameters, schema.body) + ? getParams('formData', parameters, schema.body) : getBodyParams(parameters, schema.body) } if (schema.params) { - getPathParams(parameters, schema.params) + getParams('path', parameters, schema.params) } if (schema.headers) { - getHeaderParams(parameters, schema.headers) + getParams('header', parameters, schema.headers) } if (parameters.length) { @@ -219,27 +219,9 @@ module.exports = function (opts, routes, Ref, cache, done) { parameters.push(param) } - function getFormParams (parameters, form) { + function getParams (container, parameters, form) { const resolved = ref.resolve(form) - const add = plainJsonObjectToSwagger2('formData', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getQueryParams (parameters, query) { - const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getPathParams (parameters, path) { - const resolved = ref.resolve(path) - const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getHeaderParams (parameters, headers) { - const resolved = ref.resolve(headers) - const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.definitions) + const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.definitions) add.forEach(_ => parameters.push(_)) } From 460ee0d677b96c7ccd2e2bd9729207eaf8b34991 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:21:47 +0800 Subject: [PATCH 12/46] refactor: genResponse to generateResponse --- lib/openapi.js | 4 ++-- lib/swagger.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index f6feca89..ac8698db 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -181,7 +181,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = genResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null) swaggerObject.paths[url] = swaggerRoute } @@ -216,7 +216,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } // https://swagger.io/docs/specification/2-0/describing-responses/ - function genResponse (fastifyResponseJson) { + function generateResponse (fastifyResponseJson) { // if the user does not provided an out schema if (!fastifyResponseJson) { return { 200: { description: 'Default Response' } } diff --git a/lib/swagger.js b/lib/swagger.js index ccb34881..b6939430 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -195,7 +195,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = genResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null) swaggerObject.paths[url] = swaggerRoute } @@ -226,7 +226,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } // https://swagger.io/docs/specification/2-0/describing-responses/ - function genResponse (fastifyResponseJson) { + function generateResponse (fastifyResponseJson) { // if the user does not provided an out schema if (!fastifyResponseJson) { return { 200: { description: 'Default Response' } } From ac41bef32ba872582c0d8d6fd81a27aab6f364fe Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:35:12 +0800 Subject: [PATCH 13/46] chore: remove invalid comment --- lib/openapi.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index ac8698db..e7633d3c 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -225,8 +225,6 @@ module.exports = function (opts, routes, Ref, cache, done) { const responsesContainer = {} Object.keys(fastifyResponseJson).forEach(key => { - // 2xx is not supported by swagger - const rawJsonSchema = fastifyResponseJson[key] const resolved = ref.resolve(rawJsonSchema) From aa229dff4d798cabc62f727f67de8f8bcbe8e69d Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 12:02:28 +0800 Subject: [PATCH 14/46] refactor: better structure - split util to dynamicUtil, OpenapiUtil, swaggerUtil - extract inline function from openapi and swagger --- lib/dynamic.js | 15 ++-- lib/dynamicUtil.js | 67 ++++++++++++++ lib/openapi.js | 63 ++------------ lib/openapiUtil.js | 87 +++++++++++++++++++ lib/swagger.js | 55 ++---------- lib/{util.js => swaggerUtil.js} | 149 ++++++++++++-------------------- 6 files changed, 229 insertions(+), 207 deletions(-) create mode 100644 lib/dynamicUtil.js create mode 100644 lib/openapiUtil.js rename lib/{util.js => swaggerUtil.js} (56%) diff --git a/lib/dynamic.js b/lib/dynamic.js index 1098111c..e393cedf 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -1,8 +1,6 @@ 'use strict' -const { addHook } = require('./util') -const buildSwagger = require('./swagger') -const buildOpenapi = require('./openapi') +const { addHook, resolveSwaggerFunction } = require('./dynamicUtil') module.exports = function (fastify, opts, done) { const { routes, Ref } = addHook(fastify) @@ -22,15 +20,12 @@ module.exports = function (fastify, opts, done) { } const cache = { - swaggerObject: null, - swaggerString: null + object: null, + string: null } - if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { - fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, done)) - } else { - fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, done)) - } + const swagger = resolveSwaggerFunction(opts, routes, Ref, cache, done) + fastify.decorate('swagger', swagger) done() } diff --git a/lib/dynamicUtil.js b/lib/dynamicUtil.js new file mode 100644 index 00000000..e279ec9b --- /dev/null +++ b/lib/dynamicUtil.js @@ -0,0 +1,67 @@ +'use strict' + +const Ref = require('json-schema-resolver') + +function addHook (fastify) { + const routes = [] + const sharedSchemasMap = new Map() + + fastify.addHook('onRoute', (routeOptions) => { + routes.push(routeOptions) + }) + + fastify.addHook('onRegister', async (instance) => { + // we need to wait the ready event to get all the .getSchemas() + // otherwise it will be empty + instance.addHook('onReady', (done) => { + const allSchemas = instance.getSchemas() + for (const schemaId of Object.keys(allSchemas)) { + if (!sharedSchemasMap.has(schemaId)) { + sharedSchemasMap.set(schemaId, allSchemas[schemaId]) + } + } + done() + }) + }) + + return { + routes, + Ref () { + const externalSchemas = Array.from(sharedSchemasMap.values()) + return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) + } + } +} + +function resolveSwaggerFunction (opts, routes, Ref, cache, done) { + let build + if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { + build = require('./openapi') + } else { + build = require('./swagger') + } + return build(opts, routes, Ref, cache, done) +} + +// The swagger standard does not accept the url param with ':' +// so '/user/:id' is not valid. +// This function converts the url in a swagger compliant url string +// => '/user/{id}' +function formatParamUrl (url) { + let start = url.indexOf('/:') + if (start === -1) return url + + const end = url.indexOf('/', ++start) + + if (end === -1) { + return url.slice(0, start) + '{' + url.slice(++start) + '}' + } else { + return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) + } +} + +module.exports = { + addHook, + resolveSwaggerFunction, + formatParamUrl +} diff --git a/lib/openapi.js b/lib/openapi.js index e7633d3c..6f3dd740 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -3,7 +3,8 @@ const fs = require('fs') const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl, plainJsonObjectToSwagger2, swagger2ParametersToOpenapi3, stripBasePathByServers } = require('./util') +const { formatParamUrl } = require('./dynamicUtil') +const { getBodyParams, getCommonParams, generateResponse, stripBasePathByServers } = require('./openapiUtil') module.exports = function (opts, routes, Ref, cache, done) { let ref @@ -140,22 +141,22 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getParams('query', parameters, schema.querystring) + getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.components.schemas) } if (schema.body) { swaggerMethod.requestBody = { content: {} } - getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes) + getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes, ref) } if (schema.params) { - getParams('path', parameters, schema.params) + getCommonParams('path', parameters, schema.params, ref, swaggerObject.components.schemas) } if (schema.headers) { - getParams('header', parameters, schema.headers) + getCommonParams('header', parameters, schema.headers, ref, swaggerObject.components.schemas) } if (parameters.length) { @@ -181,7 +182,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = generateResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null, ref) swaggerObject.paths[url] = swaggerRoute } @@ -194,55 +195,5 @@ module.exports = function (opts, routes, Ref, cache, done) { cache.swaggerObject = swaggerObject return swaggerObject - - function getBodyParams (parameters, body, consumes) { - const bodyResolved = ref.resolve(body) - - if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { - consumes = ['application/json'] - } - - consumes.forEach((consume) => { - parameters[consume] = { - schema: bodyResolved - } - }) - } - - function getParams (container, parameters, query) { - const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.components.schemas) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) - } - - // https://swagger.io/docs/specification/2-0/describing-responses/ - function generateResponse (fastifyResponseJson) { - // if the user does not provided an out schema - if (!fastifyResponseJson) { - return { 200: { description: 'Default Response' } } - } - - const responsesContainer = {} - - Object.keys(fastifyResponseJson).forEach(key => { - const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) - - const content = { - 'application/json': {} - } - - content['application/json'] = { - schema: resolved - } - - responsesContainer[key] = { - content, - description: rawJsonSchema.description || 'Default Response' - } - }) - - return responsesContainer - } } } diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js new file mode 100644 index 00000000..d4aef95a --- /dev/null +++ b/lib/openapiUtil.js @@ -0,0 +1,87 @@ +'use strict' + +const { URL } = require('url') +const { plainJsonObjectToSwagger2 } = require('./swaggerUtil') + +function swagger2ParametersToOpenapi3 (jsonSchema) { + jsonSchema.schema = {} + jsonSchema.schema.type = jsonSchema.type + if (jsonSchema.type === 'object') { + jsonSchema.schema.properties = jsonSchema.properties + } + if (jsonSchema.type === 'array') { + jsonSchema.schema.items = jsonSchema.items + } + delete jsonSchema.type + delete jsonSchema.properties + delete jsonSchema.items + return jsonSchema +} + +function getBodyParams (parameters, body, consumes, ref) { + const bodyResolved = ref.resolve(body) + + if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { + consumes = ['application/json'] + } + + consumes.forEach((consume) => { + parameters[consume] = { + schema: bodyResolved + } + }) +} + +function getCommonParams (container, parameters, schema, ref, sharedSchema) { + const resolved = ref.resolve(schema) + const add = plainJsonObjectToSwagger2(container, resolved, sharedSchema) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) +} + +// https://swagger.io/docs/specification/2-0/describing-responses/ +function generateResponse (fastifyResponseJson, ref) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + const content = { + 'application/json': {} + } + + content['application/json'] = { + schema: resolved + } + + responsesContainer[key] = { + content, + description: rawJsonSchema.description || 'Default Response' + } + }) + + return responsesContainer +} + +function stripBasePathByServers (path, servers) { + servers = Array.isArray(servers) ? servers : [] + servers.forEach(function (server) { + const basePath = new URL(server.url).pathname + if (path.startsWith(basePath) && basePath !== '/') { + path = path.replace(basePath, '') + } + }) + return path +} + +module.exports = { + getBodyParams, + getCommonParams, + generateResponse, + stripBasePathByServers +} diff --git a/lib/swagger.js b/lib/swagger.js index b6939430..09e392ce 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -3,7 +3,8 @@ const fs = require('fs') const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') +const { formatParamUrl } = require('./dynamicUtil') +const { consumesFormOnly, getBodyParams, getCommonParams, generateResponse } = require('./swaggerUtil') module.exports = function (opts, routes, Ref, cache, done) { let ref @@ -157,23 +158,23 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getParams('query', parameters, schema.querystring) + getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.definitions) } if (schema.body) { const consumesAllFormOnly = consumesFormOnly(schema) || consumesFormOnly(swaggerObject) consumesAllFormOnly - ? getParams('formData', parameters, schema.body) - : getBodyParams(parameters, schema.body) + ? getCommonParams('formData', parameters, schema.body, ref, swaggerObject.definitions) + : getBodyParams(parameters, schema.body, ref) } if (schema.params) { - getParams('path', parameters, schema.params) + getCommonParams('path', parameters, schema.params, ref, swaggerObject.definitions) } if (schema.headers) { - getParams('header', parameters, schema.headers) + getCommonParams('header', parameters, schema.headers, ref, swaggerObject.definitions) } if (parameters.length) { @@ -195,7 +196,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = generateResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null, ref) swaggerObject.paths[url] = swaggerRoute } @@ -208,45 +209,5 @@ module.exports = function (opts, routes, Ref, cache, done) { cache.swaggerObject = swaggerObject return swaggerObject - - function getBodyParams (parameters, body) { - const bodyResolved = ref.resolve(body) - - const param = {} - param.name = 'body' - param.in = 'body' - param.schema = bodyResolved - parameters.push(param) - } - - function getParams (container, parameters, form) { - const resolved = ref.resolve(form) - const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - // https://swagger.io/docs/specification/2-0/describing-responses/ - function generateResponse (fastifyResponseJson) { - // if the user does not provided an out schema - if (!fastifyResponseJson) { - return { 200: { description: 'Default Response' } } - } - - const responsesContainer = {} - - Object.keys(fastifyResponseJson).forEach(key => { - // 2xx is not supported by swagger - - const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) - - responsesContainer[key] = { - schema: resolved, - description: rawJsonSchema.description || 'Default Response' - } - }) - - return responsesContainer - } } } diff --git a/lib/util.js b/lib/swaggerUtil.js similarity index 56% rename from lib/util.js rename to lib/swaggerUtil.js index 268a212f..94d70dba 100644 --- a/lib/util.js +++ b/lib/swaggerUtil.js @@ -1,64 +1,20 @@ 'use strict' -const { URL } = require('url') -const Ref = require('json-schema-resolver') - -function addHook (fastify) { - const routes = [] - const sharedSchemasMap = new Map() - - fastify.addHook('onRoute', (routeOptions) => { - routes.push(routeOptions) - }) - - fastify.addHook('onRegister', async (instance) => { - // we need to wait the ready event to get all the .getSchemas() - // otherwise it will be empty - instance.addHook('onReady', (done) => { - const allSchemas = instance.getSchemas() - for (const schemaId of Object.keys(allSchemas)) { - if (!sharedSchemasMap.has(schemaId)) { - sharedSchemasMap.set(schemaId, allSchemas[schemaId]) - } - } - done() - }) - }) - - return { - routes, - Ref () { - const externalSchemas = Array.from(sharedSchemasMap.values()) - return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) - } - } -} - -// The swagger standard does not accept the url param with ':' -// so '/user/:id' is not valid. -// This function converts the url in a swagger compliant url string -// => '/user/{id}' -function formatParamUrl (url) { - let start = url.indexOf('/:') - if (start === -1) return url - - const end = url.indexOf('/', ++start) +function localRefResolve (jsonSchema, externalSchemas) { + if (jsonSchema.type && jsonSchema.properties) { + // for the shorthand querystring/params/headers declaration + const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => { + const required = (jsonSchema.required && jsonSchema.required.indexOf(headers) >= 0) || false + const newProps = Object.assign({}, jsonSchema.properties[headers], { required }) + return Object.assign({}, acc, { [headers]: newProps }) + }, {}) - if (end === -1) { - return url.slice(0, start) + '{' + url.slice(++start) + '}' - } else { - return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) + return propertiesMap } -} -function consumesFormOnly (schema) { - const consumes = schema.consumes - return ( - consumes && - consumes.length === 1 && - (consumes[0] === 'application/x-www-form-urlencoded' || - consumes[0] === 'multipart/form-data') - ) + // $ref is in the format: #/definitions// + const localReference = jsonSchema.$ref.split('/')[2] + return localRefResolve(externalSchemas[localReference], externalSchemas) } // For supported keys read: @@ -115,55 +71,60 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { }) } -function swagger2ParametersToOpenapi3 (jsonSchema) { - jsonSchema.schema = {} - jsonSchema.schema.type = jsonSchema.type - if (jsonSchema.type === 'object') { - jsonSchema.schema.properties = jsonSchema.properties - } - if (jsonSchema.type === 'array') { - jsonSchema.schema.items = jsonSchema.items - } - delete jsonSchema.type - delete jsonSchema.properties - delete jsonSchema.items - return jsonSchema +function consumesFormOnly (schema) { + const consumes = schema.consumes + return ( + consumes && + consumes.length === 1 && + (consumes[0] === 'application/x-www-form-urlencoded' || + consumes[0] === 'multipart/form-data') + ) } -function localRefResolve (jsonSchema, externalSchemas) { - if (jsonSchema.type && jsonSchema.properties) { - // for the shorthand querystring/params/headers declaration - const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => { - const required = (jsonSchema.required && jsonSchema.required.indexOf(headers) >= 0) || false - const newProps = Object.assign({}, jsonSchema.properties[headers], { required }) - return Object.assign({}, acc, { [headers]: newProps }) - }, {}) +function getBodyParams (parameters, body, ref) { + const bodyResolved = ref.resolve(body) - return propertiesMap - } + const param = {} + param.name = 'body' + param.in = 'body' + param.schema = bodyResolved + parameters.push(param) +} - // $ref is in the format: #/definitions// - const localReference = jsonSchema.$ref.split('/')[2] - return localRefResolve(externalSchemas[localReference], externalSchemas) +function getCommonParams (container, parameters, schema, ref, sharedSchemas) { + const resolved = ref.resolve(schema) + const add = plainJsonObjectToSwagger2(container, resolved, sharedSchemas) + add.forEach(_ => parameters.push(_)) } -function stripBasePathByServers (path, servers) { - servers = Array.isArray(servers) ? servers : [] - servers.forEach(function (server) { - const basePath = new URL(server.url).pathname - if (path.startsWith(basePath) && basePath !== '/') { - path = path.replace(basePath, '') +// https://swagger.io/docs/specification/2-0/describing-responses/ +function generateResponse (fastifyResponseJson, ref) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + // 2xx is not supported by swagger + + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + responsesContainer[key] = { + schema: resolved, + description: rawJsonSchema.description || 'Default Response' } }) - return path + + return responsesContainer } module.exports = { - addHook, - formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2, - swagger2ParametersToOpenapi3, - localRefResolve, - stripBasePathByServers + getBodyParams, + getCommonParams, + generateResponse } From 2862f279ad729be00b882a1463e95d3543f1c5c4 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 13:26:39 +0800 Subject: [PATCH 15/46] feat: generate response according to produces --- lib/openapi.js | 2 +- lib/openapiUtil.js | 17 ++++++++------ test/openapi.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 6f3dd740..f2855faa 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -182,7 +182,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = generateResponse(schema ? schema.response : null, ref) + swaggerMethod.responses = generateResponse(schema ? schema.response : null, schema ? schema.produces : null, ref) swaggerObject.paths[url] = swaggerRoute } diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js index d4aef95a..a1d4c57b 100644 --- a/lib/openapiUtil.js +++ b/lib/openapiUtil.js @@ -38,8 +38,7 @@ function getCommonParams (container, parameters, schema, ref, sharedSchema) { add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) } -// https://swagger.io/docs/specification/2-0/describing-responses/ -function generateResponse (fastifyResponseJson, ref) { +function generateResponse (fastifyResponseJson, produces, ref) { // if the user does not provided an out schema if (!fastifyResponseJson) { return { 200: { description: 'Default Response' } } @@ -51,14 +50,18 @@ function generateResponse (fastifyResponseJson, ref) { const rawJsonSchema = fastifyResponseJson[key] const resolved = ref.resolve(rawJsonSchema) - const content = { - 'application/json': {} - } + const content = {} - content['application/json'] = { - schema: resolved + if ((Array.isArray(produces) && produces.length === 0) || typeof produces === 'undefined') { + produces = ['application/json'] } + produces.forEach((produce) => { + content[produce] = { + schema: resolved + } + }) + responsesContainer[key] = { content, description: rawJsonSchema.description || 'Default Response' diff --git a/test/openapi.js b/test/openapi.js index 7fdb29ef..4a77f4d2 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -154,6 +154,24 @@ const opts8 = { } } +const opts9 = { + schema: { + produces: ['*/*'], + response: { + 200: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + } +} + test('fastify.swagger should return a valid swagger object', t => { t.plan(3) const fastify = Fastify() @@ -444,6 +462,43 @@ test('route meta info', t => { }) }) +test('route with produces', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', opts9, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.responses[200].content, { + '*/*': { + schema: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + }) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { t.plan(3) const fastify = Fastify() From 92d81d564cc28614357c0774cbe6b0937dd89bb8 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 13:48:51 +0800 Subject: [PATCH 16/46] feat: use package json as default info --- examples/test-package.json | 4 +--- lib/dynamicUtil.js | 13 ++++++++++++- lib/openapi.js | 14 +++----------- lib/swagger.js | 14 +++----------- test/swagger.js | 6 ++++-- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/examples/test-package.json b/examples/test-package.json index 05e6f1d1..0967ef42 100644 --- a/examples/test-package.json +++ b/examples/test-package.json @@ -1,3 +1 @@ -{ - "version": "3.1.0" -} +{} diff --git a/lib/dynamicUtil.js b/lib/dynamicUtil.js index e279ec9b..f22533af 100644 --- a/lib/dynamicUtil.js +++ b/lib/dynamicUtil.js @@ -1,5 +1,7 @@ 'use strict' +const fs = require('fs') +const path = require('path') const Ref = require('json-schema-resolver') function addHook (fastify) { @@ -60,8 +62,17 @@ function formatParamUrl (url) { } } +function readPackageJson (done) { + try { + return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return done(err) + } +} + module.exports = { addHook, resolveSwaggerFunction, - formatParamUrl + formatParamUrl, + readPackageJson } diff --git a/lib/openapi.js b/lib/openapi.js index f2855faa..e41d0e8f 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -1,9 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl } = require('./dynamicUtil') +const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { getBodyParams, getCommonParams, generateResponse, stripBasePathByServers } = require('./openapiUtil') module.exports = function (opts, routes, Ref, cache, done) { @@ -34,13 +32,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } const swaggerObject = {} - let pkg - - try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) - } catch (err) { - return done(err) - } + const pkg = readPackageJson(done) // Base Openapi info // this info is displayed in the swagger file @@ -50,7 +42,7 @@ module.exports = function (opts, routes, Ref, cache, done) { swaggerObject.info = info } else { swaggerObject.info = { - version: '1.0.0', + version: pkg.version || '1.0.0', title: pkg.name || '' } } diff --git a/lib/swagger.js b/lib/swagger.js index 09e392ce..4a6d0afb 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -1,9 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl } = require('./dynamicUtil') +const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { consumesFormOnly, getBodyParams, getCommonParams, generateResponse } = require('./swaggerUtil') module.exports = function (opts, routes, Ref, cache, done) { @@ -39,13 +37,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } const swaggerObject = {} - let pkg - - try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) - } catch (err) { - return done(err) - } + const pkg = readPackageJson(done) // Base swagger info // this info is displayed in the swagger file @@ -55,7 +47,7 @@ module.exports = function (opts, routes, Ref, cache, done) { swaggerObject.info = info } else { swaggerObject.info = { - version: '1.0.0', + version: pkg.version || '1.0.0', title: pkg.name || '' } } diff --git a/test/swagger.js b/test/swagger.js index 72fde412..486768bb 100644 --- a/test/swagger.js +++ b/test/swagger.js @@ -6,6 +6,7 @@ const Fastify = require('fastify') const Swagger = require('swagger-parser') const yaml = require('js-yaml') const fastifySwagger = require('../index') +const { readPackageJson } = require('../lib/dynamicUtil') const swaggerInfo = { swagger: { @@ -209,8 +210,9 @@ test('fastify.swagger should default info properties', t => { t.error(err) const swaggerObject = fastify.swagger() - t.equal(swaggerObject.info.title, 'fastify-swagger') - t.equal(swaggerObject.info.version, '1.0.0') + const pkg = readPackageJson(function () {}) + t.equal(swaggerObject.info.title, pkg.name) + t.equal(swaggerObject.info.version, pkg.version) }) }) From fc6ce2c8dd9f2195c6458eca7cd65aa267ca9749 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 14:04:11 +0800 Subject: [PATCH 17/46] refactor: better cache name --- lib/openapi.js | 54 +++++++++++++++++++++++++------------------------- lib/swagger.js | 8 ++++---- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index e41d0e8f..1edddfb6 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -26,58 +26,58 @@ module.exports = function (opts, routes, Ref, cache, done) { return function (opts) { if (opts && opts.yaml) { - if (cache.swaggerString) return cache.swaggerString + if (cache.string) return cache.string } else { - if (cache.swaggerObject) return cache.swaggerObject + if (cache.object) return cache.object } - const swaggerObject = {} + const openapiObject = {} const pkg = readPackageJson(done) // Base Openapi info // this info is displayed in the swagger file // in the same order as here - swaggerObject.openapi = '3.0.3' + openapiObject.openapi = '3.0.3' if (info) { - swaggerObject.info = info + openapiObject.info = info } else { - swaggerObject.info = { + openapiObject.info = { version: pkg.version || '1.0.0', title: pkg.name || '' } } if (servers) { - swaggerObject.servers = servers + openapiObject.servers = servers } if (components) { - swaggerObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) + openapiObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) } else { - swaggerObject.components = { schemas: {} } + openapiObject.components = { schemas: {} } } if (tags) { - swaggerObject.tags = tags + openapiObject.tags = tags } if (externalDocs) { - swaggerObject.externalDocs = externalDocs + openapiObject.externalDocs = externalDocs } for (const [key, value] of extensions) { - swaggerObject[key] = value + openapiObject[key] = value } ref = Ref() - swaggerObject.components.schemas = { - ...swaggerObject.components.schemas, + openapiObject.components.schemas = { + ...openapiObject.components.schemas, ...(ref.definitions().definitions) } // 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 - Object.values(swaggerObject.components.schemas) + Object.values(openapiObject.components.schemas) .forEach(_ => { delete _.$id }) - swaggerObject.paths = {} + openapiObject.paths = {} for (const route of routes) { const schema = transform @@ -93,11 +93,11 @@ module.exports = function (opts, routes, Ref, cache, done) { } const path = stripBasePath - ? stripBasePathByServers(route.url, swaggerObject.servers) + ? stripBasePathByServers(route.url, openapiObject.servers) : route.url const url = formatParamUrl(path) - const swaggerRoute = swaggerObject.paths[url] || {} + const swaggerRoute = openapiObject.paths[url] || {} const swaggerMethod = {} const parameters = [] @@ -133,7 +133,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.components.schemas) + getCommonParams('query', parameters, schema.querystring, ref, openapiObject.components.schemas) } if (schema.body) { @@ -144,11 +144,11 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.params) { - getCommonParams('path', parameters, schema.params, ref, swaggerObject.components.schemas) + getCommonParams('path', parameters, schema.params, ref, openapiObject.components.schemas) } if (schema.headers) { - getCommonParams('header', parameters, schema.headers, ref, swaggerObject.components.schemas) + getCommonParams('header', parameters, schema.headers, ref, openapiObject.components.schemas) } if (parameters.length) { @@ -176,16 +176,16 @@ module.exports = function (opts, routes, Ref, cache, done) { swaggerMethod.responses = generateResponse(schema ? schema.response : null, schema ? schema.produces : null, ref) - swaggerObject.paths[url] = swaggerRoute + openapiObject.paths[url] = swaggerRoute } if (opts && opts.yaml) { - const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) - cache.swaggerString = swaggerString - return swaggerString + const openapiString = yaml.safeDump(openapiObject, { skipInvalid: true }) + cache.string = openapiString + return openapiString } - cache.swaggerObject = swaggerObject - return swaggerObject + cache.object = openapiObject + return openapiObject } } diff --git a/lib/swagger.js b/lib/swagger.js index 4a6d0afb..ed979d4d 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -31,9 +31,9 @@ module.exports = function (opts, routes, Ref, cache, done) { return function (opts) { if (opts && opts.yaml) { - if (cache.swaggerString) return cache.swaggerString + if (cache.string) return cache.string } else { - if (cache.swaggerObject) return cache.swaggerObject + if (cache.object) return cache.object } const swaggerObject = {} @@ -195,11 +195,11 @@ module.exports = function (opts, routes, Ref, cache, done) { if (opts && opts.yaml) { const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) - cache.swaggerString = swaggerString + cache.string = swaggerString return swaggerString } - cache.swaggerObject = swaggerObject + cache.object = swaggerObject return swaggerObject } } From ee1e6b4cfe281bf2b6b2edaaf2a4bed2017baf6c Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 14:14:23 +0800 Subject: [PATCH 18/46] chore: add comment to address different issue --- lib/dynamicUtil.js | 4 ++++ lib/openapi.js | 3 +++ lib/swagger.js | 3 +++ 3 files changed, 10 insertions(+) diff --git a/lib/dynamicUtil.js b/lib/dynamicUtil.js index f22533af..e2ca40f5 100644 --- a/lib/dynamicUtil.js +++ b/lib/dynamicUtil.js @@ -15,6 +15,9 @@ function addHook (fastify) { fastify.addHook('onRegister', async (instance) => { // we need to wait the ready event to get all the .getSchemas() // otherwise it will be empty + // TODO: better handle for schemaId + // when schemaId is the same in difference instance + // the latter will lost instance.addHook('onReady', (done) => { const allSchemas = instance.getSchemas() for (const schemaId of Object.keys(allSchemas)) { @@ -30,6 +33,7 @@ function addHook (fastify) { routes, Ref () { const externalSchemas = Array.from(sharedSchemasMap.values()) + // TODO: hardcoded applicationUri is not a ideal solution return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) } } diff --git a/lib/openapi.js b/lib/openapi.js index 1edddfb6..a628fac5 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -4,6 +4,9 @@ const yaml = require('js-yaml') const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { getBodyParams, getCommonParams, generateResponse, stripBasePathByServers } = require('./openapiUtil') +// TODO: refactor for readability, reference in below +// https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two +// https://www.informit.com/articles/article.aspx?p=1398607 module.exports = function (opts, routes, Ref, cache, done) { let ref diff --git a/lib/swagger.js b/lib/swagger.js index ed979d4d..b7f6c423 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -4,6 +4,9 @@ const yaml = require('js-yaml') const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { consumesFormOnly, getBodyParams, getCommonParams, generateResponse } = require('./swaggerUtil') +// TODO: refactor for readability, reference in below +// https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two +// https://www.informit.com/articles/article.aspx?p=1398607 module.exports = function (opts, routes, Ref, cache, done) { let ref From 5453c3b39a6f1466cef353c37920fb2700944146 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 18:49:52 +0800 Subject: [PATCH 19/46] feat: add oneOf, allOf, anyOf support in query, header, path, formData --- lib/swaggerUtil.js | 9 +++++++++ test/openapi.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/lib/swaggerUtil.js b/lib/swaggerUtil.js index 94d70dba..4bb9db4f 100644 --- a/lib/swaggerUtil.js +++ b/lib/swaggerUtil.js @@ -12,6 +12,15 @@ function localRefResolve (jsonSchema, externalSchemas) { return propertiesMap } + // for oneOf, anyOf, allOf support in querystring/params/headers + if (jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf) { + const schemas = jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf + return schemas.reduce(function (acc, schema) { + const json = localRefResolve(schema, externalSchemas) + return { ...acc, ...json } + }, {}) + } + // $ref is in the format: #/definitions// const localReference = jsonSchema.$ref.split('/')[2] return localRefResolve(externalSchemas[localReference], externalSchemas) diff --git a/test/openapi.js b/test/openapi.js index 4a77f4d2..b36117da 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -172,6 +172,21 @@ const opts9 = { } } +const opts10 = { + schema: { + querystring: { + allOf: [ + { + type: 'object', + properties: { + foo: { type: 'string' } + } + } + ] + } + } +} + test('fastify.swagger should return a valid swagger object', t => { t.plan(3) const fastify = Fastify() @@ -499,6 +514,38 @@ test('route with produces', t => { }) }) +test('route oneOf, anyOf, allOf', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', opts10, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.parameters, [ + { + required: false, + in: 'query', + name: 'foo', + schema: { + type: 'string' + } + } + ]) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { t.plan(3) const fastify = Fastify() From ba310902081e229a6fa3469d6dfc1aae93cde134 Mon Sep 17 00:00:00 2001 From: radzom Date: Thu, 14 Jan 2021 10:20:29 +0100 Subject: [PATCH 20/46] Fix type of StaticDocumentSpec.document (#328) --- index.d.ts | 5 +++-- package.json | 1 + test/types/http2-types.test.ts | 3 ++- test/types/imports.test.ts | 3 ++- test/types/minimal-openapiV3-document.ts | 11 +++++++++++ test/types/types.test.ts | 5 +++-- 6 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 test/types/minimal-openapiV3-document.ts diff --git a/index.d.ts b/index.d.ts index 34f9eb1c..6299c2d9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,6 @@ import { FastifyPlugin } from 'fastify'; import * as SwaggerSchema from 'swagger-schema-official'; +import { OpenAPIV2, OpenAPIV3 } from 'openapi-types'; declare module 'fastify' { interface FastifyInstance { @@ -68,7 +69,7 @@ export interface StaticPathSpec { } export interface StaticDocumentSpec { - document: string; + document: OpenAPIV2.Document | OpenAPIV3.Document; } export interface FastifyStaticSwaggerOptions extends FastifySwaggerOptions { @@ -76,4 +77,4 @@ export interface FastifyStaticSwaggerOptions extends FastifySwaggerOptions { specification: StaticPathSpec | StaticDocumentSpec; } -export default fastifySwagger; \ No newline at end of file +export default fastifySwagger; diff --git a/package.json b/package.json index 0254ff79..b753742d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "fs-extra": "^9.0.0", "joi": "^14.3.1", "joi-to-json-schema": "^5.1.0", + "openapi-types": "^7.0.1", "pre-commit": "^1.2.2", "standard": "^16.0.1", "swagger-parser": "^10.0.2", diff --git a/test/types/http2-types.test.ts b/test/types/http2-types.test.ts index 813676f2..e6462d4e 100644 --- a/test/types/http2-types.test.ts +++ b/test/types/http2-types.test.ts @@ -1,5 +1,6 @@ import fastify from 'fastify'; import fastifySwagger from '../..'; +import { minimalOpenApiV3Document } from './minimal-openapiV3-document'; const app = fastify({ http2: true @@ -11,7 +12,7 @@ app.register(fastifySwagger, { transform: (schema : any) => schema }); app.register(fastifySwagger, { mode: 'static', specification: { - document: 'path' + document: minimalOpenApiV3Document }, routePrefix: '/documentation', exposeRoute: true, diff --git a/test/types/imports.test.ts b/test/types/imports.test.ts index 37b22491..28ee3c8f 100644 --- a/test/types/imports.test.ts +++ b/test/types/imports.test.ts @@ -2,12 +2,13 @@ import fastify from "fastify"; import swaggerDefault, { fastifySwagger, SwaggerOptions } from "../.."; import * as fastifySwaggerStar from "../.."; +import { minimalOpenApiV3Document } from './minimal-openapiV3-document'; const app = fastify(); const fastifySwaggerOptions: SwaggerOptions = { mode: "static", specification: { - document: "path", + document: minimalOpenApiV3Document, }, routePrefix: "/documentation", exposeRoute: true, diff --git a/test/types/minimal-openapiV3-document.ts b/test/types/minimal-openapiV3-document.ts new file mode 100644 index 00000000..dd94e296 --- /dev/null +++ b/test/types/minimal-openapiV3-document.ts @@ -0,0 +1,11 @@ +import { OpenAPIV3 } from 'openapi-types' + +export const minimalOpenApiV3Document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: { + "version": "1.0.0", + "title": "Test OpenApiv3 specification", + }, + "paths": { + } +} diff --git a/test/types/types.test.ts b/test/types/types.test.ts index 4af34522..8b3a9905 100644 --- a/test/types/types.test.ts +++ b/test/types/types.test.ts @@ -1,5 +1,6 @@ import fastify from 'fastify'; import fastifySwagger, { SwaggerOptions } from '../..'; +import { minimalOpenApiV3Document } from './minimal-openapiV3-document'; const app = fastify(); @@ -9,7 +10,7 @@ app.register(fastifySwagger, { transform: (schema : any) => schema }); app.register(fastifySwagger, { mode: 'static', specification: { - document: 'path' + document: minimalOpenApiV3Document }, routePrefix: '/documentation', exposeRoute: true, @@ -18,7 +19,7 @@ app.register(fastifySwagger, { const fastifySwaggerOptions: SwaggerOptions = { mode: 'static', specification: { - document: 'path' + document: minimalOpenApiV3Document }, routePrefix: '/documentation', exposeRoute: true, From a9f4d3b37a1fe7e4af73f64b5368a4082c47357f Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 15:06:13 +0800 Subject: [PATCH 21/46] refactor: better file structure - reduce the number of files in root folder --- index.js | 2 +- dynamic.js => lib/dynamic.js | 2 +- routes.js => lib/routes.js | 4 ++-- static.js => lib/static.js | 0 test/static.js | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) rename dynamic.js => lib/dynamic.js (99%) rename routes.js => lib/routes.js (94%) rename static.js => lib/static.js (100%) diff --git a/index.js b/index.js index ca388af2..ef67c663 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ const fp = require('fastify-plugin') -const setup = { dynamic: require('./dynamic'), static: require('./static') } +const setup = { dynamic: require('./lib/dynamic'), static: require('./lib/static') } function fastifySwagger (fastify, opts, next) { opts = opts || {} diff --git a/dynamic.js b/lib/dynamic.js similarity index 99% rename from dynamic.js rename to lib/dynamic.js index 53c310b4..48886f1c 100644 --- a/dynamic.js +++ b/lib/dynamic.js @@ -81,7 +81,7 @@ module.exports = function (fastify, opts, next) { let pkg try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'))) + pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) } catch (err) { return next(err) } diff --git a/routes.js b/lib/routes.js similarity index 94% rename from routes.js rename to lib/routes.js index 8e8c7a95..62860c6e 100644 --- a/routes.js +++ b/lib/routes.js @@ -51,13 +51,13 @@ function fastifySwagger (fastify, opts, next) { // serve swagger-ui with the help of fastify-static fastify.register(fastifyStatic, { - root: path.join(__dirname, 'static'), + root: path.join(__dirname, '..', 'static'), prefix: staticPrefix, decorateReply: false }) fastify.register(fastifyStatic, { - root: opts.baseDir || __dirname, + root: opts.baseDir || path.join(__dirname, '..'), serve: false }) diff --git a/static.js b/lib/static.js similarity index 100% rename from static.js rename to lib/static.js diff --git a/test/static.js b/test/static.js index db108093..db95b9d4 100644 --- a/test/static.js +++ b/test/static.js @@ -5,7 +5,7 @@ const t = require('tap') const test = t.test const Fastify = require('fastify') const fastifySwagger = require('../index') -const fastifySwaggerDynamic = require('../dynamic') +const fastifySwaggerDynamic = require('../lib/dynamic') const yaml = require('js-yaml') const resolve = require('path').resolve @@ -615,7 +615,7 @@ test('inserts default package name', t => { const testPackageJSON = path.join(__dirname, '../examples/test-package.json') path.join = (...args) => { - if (args[1] === 'package.json') { + if (args[2] === 'package.json') { return testPackageJSON } return originalPathJoin(...args) @@ -650,7 +650,7 @@ test('throws an error if cannot parse package\'s JSON', t => { const testPackageJSON = path.join(__dirname, '') path.join = (...args) => { - if (args[1] === 'package.json') { + if (args[2] === 'package.json') { return testPackageJSON } return originalPathJoin(...args) From 37cc39ba3f2ee5fa13c8cdc97e933b118f2e2308 Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 15:31:35 +0800 Subject: [PATCH 22/46] refactor: extract common function - hook - formatParamUrl - consumesFormOnly - plainJsonObjectToSwagger2 - localRefResolve --- lib/dynamic.js | 126 ++------------------------------------------ lib/util.js | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 123 deletions(-) create mode 100644 lib/util.js diff --git a/lib/dynamic.js b/lib/dynamic.js index 48886f1c..97d0e4e0 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -3,32 +3,13 @@ const fs = require('fs') const path = require('path') const yaml = require('js-yaml') -const Ref = require('json-schema-resolver') +const { addHook, formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') module.exports = function (fastify, opts, next) { fastify.decorate('swagger', swagger) - const routes = [] - const sharedSchemasMap = new Map() let ref - - fastify.addHook('onRoute', (routeOptions) => { - routes.push(routeOptions) - }) - - fastify.addHook('onRegister', async (instance) => { - // we need to wait the ready event to get all the .getSchemas() - // otherwise it will be empty - instance.addHook('onReady', (done) => { - const allSchemas = instance.getSchemas() - for (const schemaId of Object.keys(allSchemas)) { - if (!sharedSchemasMap.has(schemaId)) { - sharedSchemasMap.set(schemaId, allSchemas[schemaId]) - } - } - done() - }) - }) + const { routes, Ref } = addHook(fastify) opts = Object.assign({}, { exposeRoute: false, @@ -123,9 +104,7 @@ module.exports = function (fastify, opts, next) { swaggerObject[key] = value } - const externalSchemas = Array.from(sharedSchemasMap.values()) - - ref = Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) + ref = Ref() swaggerObject.definitions = { ...swaggerObject.definitions, ...(ref.definitions().definitions) @@ -312,102 +291,3 @@ module.exports = function (fastify, opts, next) { next() } - -function consumesFormOnly (schema) { - const consumes = schema.consumes - return ( - consumes && - consumes.length === 1 && - (consumes[0] === 'application/x-www-form-urlencoded' || - consumes[0] === 'multipart/form-data') - ) -} - -// The swagger standard does not accept the url param with ':' -// so '/user/:id' is not valid. -// This function converts the url in a swagger compliant url string -// => '/user/{id}' -function formatParamUrl (url) { - let start = url.indexOf('/:') - if (start === -1) return url - - const end = url.indexOf('/', ++start) - - if (end === -1) { - return url.slice(0, start) + '{' + url.slice(++start) + '}' - } else { - return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) - } -} - -// For supported keys read: -// https://swagger.io/docs/specification/2-0/describing-parameters/ -function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { - const obj = localRefResolve(jsonSchema, externalSchemas) - let toSwaggerProp - switch (container) { - case 'query': - toSwaggerProp = function (properyName, jsonSchemaElement) { - jsonSchemaElement.in = container - jsonSchemaElement.name = properyName - return jsonSchemaElement - } - break - case 'formData': - toSwaggerProp = function (properyName, jsonSchemaElement) { - delete jsonSchemaElement.$id - jsonSchemaElement.in = container - jsonSchemaElement.name = properyName - - // https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding - if (jsonSchemaElement.contentEncoding === 'binary') { - delete jsonSchemaElement.contentEncoding // Must be removed - jsonSchemaElement.type = 'file' - } - - return jsonSchemaElement - } - break - case 'path': - toSwaggerProp = function (properyName, jsonSchemaElement) { - jsonSchemaElement.in = container - jsonSchemaElement.name = properyName - jsonSchemaElement.required = true - return jsonSchemaElement - } - break - case 'header': - toSwaggerProp = function (properyName, jsonSchemaElement) { - return { - in: 'header', - name: properyName, - required: jsonSchemaElement.required, - description: jsonSchemaElement.description, - type: jsonSchemaElement.type - } - } - break - } - - return Object.keys(obj).reduce((acc, propKey) => { - acc.push(toSwaggerProp(propKey, obj[propKey])) - return acc - }, []) -} - -function localRefResolve (jsonSchema, externalSchemas) { - if (jsonSchema.type && jsonSchema.properties) { - // for the shorthand querystring/params/headers declaration - const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, h) => { - const required = (jsonSchema.required && jsonSchema.required.indexOf(h) >= 0) || false - const newProps = Object.assign({}, jsonSchema.properties[h], { required }) - return Object.assign({}, acc, { [h]: newProps }) - }, {}) - - return propertiesMap - } - - // $ref is in the format: #/definitions// - const localReference = jsonSchema.$ref.split('/')[2] - return localRefResolve(externalSchemas[localReference], externalSchemas) -} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 00000000..d0d2b9cc --- /dev/null +++ b/lib/util.js @@ -0,0 +1,139 @@ +const Ref = require('json-schema-resolver') + +function addHook (fastify) { + const routes = [] + const sharedSchemasMap = new Map() + + fastify.addHook('onRoute', (routeOptions) => { + routes.push(routeOptions) + }) + + fastify.addHook('onRegister', async (instance) => { + // we need to wait the ready event to get all the .getSchemas() + // otherwise it will be empty + instance.addHook('onReady', (done) => { + const allSchemas = instance.getSchemas() + for (const schemaId of Object.keys(allSchemas)) { + if (!sharedSchemasMap.has(schemaId)) { + sharedSchemasMap.set(schemaId, allSchemas[schemaId]) + } + } + done() + }) + }) + + return { + routes, + Ref () { + const externalSchemas = Array.from(sharedSchemasMap.values()) + return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) + } + } +} + +// The swagger standard does not accept the url param with ':' +// so '/user/:id' is not valid. +// This function converts the url in a swagger compliant url string +// => '/user/{id}' +function formatParamUrl (url) { + let start = url.indexOf('/:') + if (start === -1) return url + + const end = url.indexOf('/', ++start) + + if (end === -1) { + return url.slice(0, start) + '{' + url.slice(++start) + '}' + } else { + return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) + } +} + +function consumesFormOnly (schema) { + const consumes = schema.consumes + return ( + consumes && + consumes.length === 1 && + (consumes[0] === 'application/x-www-form-urlencoded' || + consumes[0] === 'multipart/form-data') + ) +} + +// For supported keys read: +// https://swagger.io/docs/specification/2-0/describing-parameters/ +function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { + const obj = localRefResolve(jsonSchema, externalSchemas) + let toSwaggerProp + switch (container) { + case 'query': + toSwaggerProp = function (properyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = properyName + return jsonSchemaElement + } + break + case 'formData': + toSwaggerProp = function (properyName, jsonSchemaElement) { + delete jsonSchemaElement.$id + jsonSchemaElement.in = container + jsonSchemaElement.name = properyName + + // https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding + if (jsonSchemaElement.contentEncoding === 'binary') { + delete jsonSchemaElement.contentEncoding // Must be removed + jsonSchemaElement.type = 'file' + } + + return jsonSchemaElement + } + break + case 'path': + toSwaggerProp = function (properyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = properyName + jsonSchemaElement.required = true + return jsonSchemaElement + } + break + case 'header': + toSwaggerProp = function (properyName, jsonSchemaElement) { + return { + in: 'header', + name: properyName, + required: jsonSchemaElement.required, + description: jsonSchemaElement.description, + type: jsonSchemaElement.type + } + } + break + } + + return Object.keys(obj).reduce((acc, propKey) => { + acc.push(toSwaggerProp(propKey, obj[propKey])) + return acc + }, []) +} + +function localRefResolve (jsonSchema, externalSchemas) { + if (jsonSchema.type && jsonSchema.properties) { + // for the shorthand querystring/params/headers declaration + const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, h) => { + const required = (jsonSchema.required && jsonSchema.required.indexOf(h) >= 0) || false + const newProps = Object.assign({}, jsonSchema.properties[h], { required }) + return Object.assign({}, acc, { [h]: newProps }) + }, {}) + + return propertiesMap + } + + // $ref is in the format: #/definitions// + const localReference = jsonSchema.$ref.split('/')[2] + return localRefResolve(externalSchemas[localReference], externalSchemas) +} + +module.exports = { + addHook, + formatParamUrl, + consumesFormOnly, + plainJsonObjectToSwagger2, + localRefResolve +} From 00cb007fd6a2bc3636efe8385ea56359b666252e Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 16:04:03 +0800 Subject: [PATCH 23/46] refactor: split dynamic handle to swagger and openapi --- lib/dynamic.js | 269 +----------------------------------------------- lib/swagger.js | 270 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 266 deletions(-) create mode 100644 lib/swagger.js diff --git a/lib/dynamic.js b/lib/dynamic.js index 97d0e4e0..fcabebda 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -1,14 +1,9 @@ 'use strict' -const fs = require('fs') -const path = require('path') -const yaml = require('js-yaml') -const { addHook, formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') +const { addHook } = require('./util') +const buildSwagger = require('./swagger') module.exports = function (fastify, opts, next) { - fastify.decorate('swagger', swagger) - - let ref const { routes, Ref } = addHook(fastify) opts = Object.assign({}, { @@ -19,28 +14,6 @@ module.exports = function (fastify, opts, next) { transform: null }, opts || {}) - const info = opts.swagger.info || null - const host = opts.swagger.host || null - const schemes = opts.swagger.schemes || null - const consumes = opts.swagger.consumes || null - const produces = opts.swagger.produces || null - const definitions = opts.swagger.definitions || null - const basePath = opts.swagger.basePath || null - const securityDefinitions = opts.swagger.securityDefinitions || null - const security = opts.swagger.security || null - const tags = opts.swagger.tags || null - const externalDocs = opts.swagger.externalDocs || null - const stripBasePath = opts.stripBasePath - const transform = opts.transform - const hiddenTag = opts.hiddenTag - const extensions = [] - - for (const [key, value] of Object.entries(opts.swagger)) { - if (key.startsWith('x-')) { - extensions.push([key, value]) - } - } - if (opts.exposeRoute === true) { const prefix = opts.routePrefix || '/documentation' fastify.register(require('./routes'), { prefix }) @@ -51,243 +24,7 @@ module.exports = function (fastify, opts, next) { swaggerString: null } - function swagger (opts) { - if (opts && opts.yaml) { - if (cache.swaggerString) return cache.swaggerString - } else { - if (cache.swaggerObject) return cache.swaggerObject - } - - const swaggerObject = {} - let pkg - - try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) - } catch (err) { - return next(err) - } - - // Base swagger info - // this info is displayed in the swagger file - // in the same order as here - swaggerObject.swagger = '2.0' - if (info) { - swaggerObject.info = info - } else { - swaggerObject.info = { - version: '1.0.0', - title: pkg.name || '' - } - } - if (host) swaggerObject.host = host - if (schemes) swaggerObject.schemes = schemes - if (basePath) swaggerObject.basePath = basePath - if (consumes) swaggerObject.consumes = consumes - if (produces) swaggerObject.produces = produces - if (definitions) swaggerObject.definitions = definitions - else swaggerObject.definitions = {} - - if (securityDefinitions) { - swaggerObject.securityDefinitions = securityDefinitions - } - if (security) { - swaggerObject.security = security - } - if (tags) { - swaggerObject.tags = tags - } - if (externalDocs) { - swaggerObject.externalDocs = externalDocs - } - - for (const [key, value] of extensions) { - swaggerObject[key] = value - } - - ref = Ref() - swaggerObject.definitions = { - ...swaggerObject.definitions, - ...(ref.definitions().definitions) - } - - // 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 - Object.values(swaggerObject.definitions) - .forEach(_ => { delete _.$id }) - - swaggerObject.paths = {} - for (const route of routes) { - const schema = transform - ? transform(route.schema) - : route.schema - - if (schema && schema.hide) { - continue - } - - if (schema && schema.tags && schema.tags.includes(hiddenTag)) { - continue - } - - let path = stripBasePath && route.url.startsWith(basePath) - ? route.url.replace(basePath, '') - : route.url - if (!path.startsWith('/')) { - path = '/' + path - } - const url = formatParamUrl(path) - - const swaggerRoute = swaggerObject.paths[url] || {} - - const swaggerMethod = {} - const parameters = [] - - // route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH'] - const methods = typeof route.method === 'string' ? [route.method] : route.method - - for (const method of methods) { - swaggerRoute[method.toLowerCase()] = swaggerMethod - } - - // All the data the user can give us, is via the schema object - if (schema) { - // the resulting schema will be in this order - if (schema.operationId) { - swaggerMethod.operationId = schema.operationId - } - - if (schema.summary) { - swaggerMethod.summary = schema.summary - } - - if (schema.description) { - swaggerMethod.description = schema.description - } - - if (schema.tags) { - swaggerMethod.tags = schema.tags - } - - if (schema.produces) { - swaggerMethod.produces = schema.produces - } - - if (schema.consumes) { - swaggerMethod.consumes = schema.consumes - } - - if (schema.querystring) { - getQueryParams(parameters, schema.querystring) - } - - if (schema.body) { - const consumesAllFormOnly = - consumesFormOnly(schema) || consumesFormOnly(swaggerObject) - consumesAllFormOnly - ? getFormParams(parameters, schema.body) - : getBodyParams(parameters, schema.body) - } - - if (schema.params) { - getPathParams(parameters, schema.params) - } - - if (schema.headers) { - getHeaderParams(parameters, schema.headers) - } - - if (parameters.length) { - swaggerMethod.parameters = parameters - } - - if (schema.deprecated) { - swaggerMethod.deprecated = schema.deprecated - } - - if (schema.security) { - swaggerMethod.security = schema.security - } - - for (const key of Object.keys(schema)) { - if (key.startsWith('x-')) { - swaggerMethod[key] = schema[key] - } - } - } - - swaggerMethod.responses = genResponse(schema ? schema.response : null) - - swaggerObject.paths[url] = swaggerRoute - } - - if (opts && opts.yaml) { - const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) - cache.swaggerString = swaggerString - return swaggerString - } - - cache.swaggerObject = swaggerObject - return swaggerObject - - function getBodyParams (parameters, body) { - const bodyResolved = ref.resolve(body) - - const param = {} - param.name = 'body' - param.in = 'body' - param.schema = bodyResolved - parameters.push(param) - } - - function getFormParams (parameters, form) { - const resolved = ref.resolve(form) - const add = plainJsonObjectToSwagger2('formData', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getQueryParams (parameters, query) { - const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getPathParams (parameters, path) { - const resolved = ref.resolve(path) - const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getHeaderParams (parameters, headers) { - const resolved = ref.resolve(headers) - const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - // https://swagger.io/docs/specification/2-0/describing-responses/ - function genResponse (fastifyResponseJson) { - // if the user does not provided an out schema - if (!fastifyResponseJson) { - return { 200: { description: 'Default Response' } } - } - - const responsesContainer = {} - - Object.keys(fastifyResponseJson).forEach(key => { - // 2xx is not supported by swagger - - const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) - - responsesContainer[key] = { - schema: resolved, - description: rawJsonSchema.description || 'Default Response' - } - }) - - return responsesContainer - } - } + fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) next() } diff --git a/lib/swagger.js b/lib/swagger.js new file mode 100644 index 00000000..7f5c69e2 --- /dev/null +++ b/lib/swagger.js @@ -0,0 +1,270 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') +const { formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') + +module.exports = function (opts, routes, Ref, cache, next) { + let ref + + const info = opts.swagger.info || null + const host = opts.swagger.host || null + const schemes = opts.swagger.schemes || null + const consumes = opts.swagger.consumes || null + const produces = opts.swagger.produces || null + const definitions = opts.swagger.definitions || null + const basePath = opts.swagger.basePath || null + const securityDefinitions = opts.swagger.securityDefinitions || null + const security = opts.swagger.security || null + const tags = opts.swagger.tags || null + const externalDocs = opts.swagger.externalDocs || null + const stripBasePath = opts.stripBasePath + const transform = opts.transform + const hiddenTag = opts.hiddenTag + const extensions = [] + + for (const [key, value] of Object.entries(opts.swagger)) { + if (key.startsWith('x-')) { + extensions.push([key, value]) + } + } + + return function (opts) { + if (opts && opts.yaml) { + if (cache.swaggerString) return cache.swaggerString + } else { + if (cache.swaggerObject) return cache.swaggerObject + } + + const swaggerObject = {} + let pkg + + try { + pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return next(err) + } + + // Base swagger info + // this info is displayed in the swagger file + // in the same order as here + swaggerObject.swagger = '2.0' + if (info) { + swaggerObject.info = info + } else { + swaggerObject.info = { + version: '1.0.0', + title: pkg.name || '' + } + } + if (host) swaggerObject.host = host + if (schemes) swaggerObject.schemes = schemes + if (basePath) swaggerObject.basePath = basePath + if (consumes) swaggerObject.consumes = consumes + if (produces) swaggerObject.produces = produces + if (definitions) swaggerObject.definitions = definitions + else swaggerObject.definitions = {} + + if (securityDefinitions) { + swaggerObject.securityDefinitions = securityDefinitions + } + if (security) { + swaggerObject.security = security + } + if (tags) { + swaggerObject.tags = tags + } + if (externalDocs) { + swaggerObject.externalDocs = externalDocs + } + + for (const [key, value] of extensions) { + swaggerObject[key] = value + } + + ref = Ref() + swaggerObject.definitions = { + ...swaggerObject.definitions, + ...(ref.definitions().definitions) + } + + // 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 + Object.values(swaggerObject.definitions) + .forEach(_ => { delete _.$id }) + + swaggerObject.paths = {} + for (const route of routes) { + const schema = transform + ? transform(route.schema) + : route.schema + + if (schema && schema.hide) { + continue + } + + if (schema && schema.tags && schema.tags.includes(hiddenTag)) { + continue + } + + let path = stripBasePath && route.url.startsWith(basePath) + ? route.url.replace(basePath, '') + : route.url + if (!path.startsWith('/')) { + path = '/' + path + } + const url = formatParamUrl(path) + + const swaggerRoute = swaggerObject.paths[url] || {} + + const swaggerMethod = {} + const parameters = [] + + // route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH'] + const methods = typeof route.method === 'string' ? [route.method] : route.method + + for (const method of methods) { + swaggerRoute[method.toLowerCase()] = swaggerMethod + } + + // All the data the user can give us, is via the schema object + if (schema) { + // the resulting schema will be in this order + if (schema.operationId) { + swaggerMethod.operationId = schema.operationId + } + + if (schema.summary) { + swaggerMethod.summary = schema.summary + } + + if (schema.description) { + swaggerMethod.description = schema.description + } + + if (schema.tags) { + swaggerMethod.tags = schema.tags + } + + if (schema.produces) { + swaggerMethod.produces = schema.produces + } + + if (schema.consumes) { + swaggerMethod.consumes = schema.consumes + } + + if (schema.querystring) { + getQueryParams(parameters, schema.querystring) + } + + if (schema.body) { + const consumesAllFormOnly = + consumesFormOnly(schema) || consumesFormOnly(swaggerObject) + consumesAllFormOnly + ? getFormParams(parameters, schema.body) + : getBodyParams(parameters, schema.body) + } + + if (schema.params) { + getPathParams(parameters, schema.params) + } + + if (schema.headers) { + getHeaderParams(parameters, schema.headers) + } + + if (parameters.length) { + swaggerMethod.parameters = parameters + } + + if (schema.deprecated) { + swaggerMethod.deprecated = schema.deprecated + } + + if (schema.security) { + swaggerMethod.security = schema.security + } + + for (const key of Object.keys(schema)) { + if (key.startsWith('x-')) { + swaggerMethod[key] = schema[key] + } + } + } + + swaggerMethod.responses = genResponse(schema ? schema.response : null) + + swaggerObject.paths[url] = swaggerRoute + } + + if (opts && opts.yaml) { + const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) + cache.swaggerString = swaggerString + return swaggerString + } + + cache.swaggerObject = swaggerObject + return swaggerObject + + function getBodyParams (parameters, body) { + const bodyResolved = ref.resolve(body) + + const param = {} + param.name = 'body' + param.in = 'body' + param.schema = bodyResolved + parameters.push(param) + } + + function getFormParams (parameters, form) { + const resolved = ref.resolve(form) + const add = plainJsonObjectToSwagger2('formData', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + function getQueryParams (parameters, query) { + const resolved = ref.resolve(query) + const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + function getPathParams (parameters, path) { + const resolved = ref.resolve(path) + const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + function getHeaderParams (parameters, headers) { + const resolved = ref.resolve(headers) + const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.definitions) + add.forEach(_ => parameters.push(_)) + } + + // https://swagger.io/docs/specification/2-0/describing-responses/ + function genResponse (fastifyResponseJson) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + // 2xx is not supported by swagger + + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + responsesContainer[key] = { + schema: resolved, + description: rawJsonSchema.description || 'Default Response' + } + }) + + return responsesContainer + } + } +} From fb23cdc0ea0a0bd6b518d7b48deafeb63180d061 Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 19:00:23 +0800 Subject: [PATCH 24/46] feat: add openapi 3 support --- lib/dynamic.js | 8 +- lib/openapi.js | 262 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/util.js | 33 ++++++- 3 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 lib/openapi.js diff --git a/lib/dynamic.js b/lib/dynamic.js index fcabebda..b6e80abd 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -2,6 +2,7 @@ const { addHook } = require('./util') const buildSwagger = require('./swagger') +const buildOpenapi = require('./openapi') module.exports = function (fastify, opts, next) { const { routes, Ref } = addHook(fastify) @@ -10,6 +11,7 @@ module.exports = function (fastify, opts, next) { exposeRoute: false, hiddenTag: 'X-HIDDEN', stripBasePath: true, + openapi: {}, swagger: {}, transform: null }, opts || {}) @@ -24,7 +26,11 @@ module.exports = function (fastify, opts, next) { swaggerString: null } - fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) + if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { + fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, next)) + } else { + fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) + } next() } diff --git a/lib/openapi.js b/lib/openapi.js new file mode 100644 index 00000000..d3a32414 --- /dev/null +++ b/lib/openapi.js @@ -0,0 +1,262 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') +const { formatParamUrl, plainJsonObjectToSwagger2, swagger2ParametersToOpenapi3, stripBasePathByServers } = require('./util') + +module.exports = function (opts, routes, Ref, cache, next) { + let ref + + const info = opts.openapi.info || null + const servers = opts.openapi.servers || null + const components = opts.openapi.components || null + const tags = opts.openapi.tags || null + const externalDocs = opts.openapi.externalDocs || null + + const stripBasePath = opts.stripBasePath + const transform = opts.transform + const hiddenTag = opts.hiddenTag + const extensions = [] + + for (const [key, value] of Object.entries(opts.openapi)) { + if (key.startsWith('x-')) { + extensions.push([key, value]) + } + } + + return function (opts) { + if (opts && opts.yaml) { + if (cache.swaggerString) return cache.swaggerString + } else { + if (cache.swaggerObject) return cache.swaggerObject + } + + const swaggerObject = {} + let pkg + + try { + pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return next(err) + } + + // Base Openapi info + // this info is displayed in the swagger file + // in the same order as here + swaggerObject.openapi = '3.0.0' + if (info) { + swaggerObject.info = info + } else { + swaggerObject.info = { + version: '1.0.0', + title: pkg.name || '' + } + } + if (servers) { + swaggerObject.servers = servers + } + if (components) { + swaggerObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) + } else { + swaggerObject.components = { schemas: {} } + } + if (tags) { + swaggerObject.tags = tags + } + if (externalDocs) { + swaggerObject.externalDocs = externalDocs + } + + for (const [key, value] of extensions) { + swaggerObject[key] = value + } + + ref = Ref() + swaggerObject.components.schemas = { + ...swaggerObject.components.schemas, + ...(ref.definitions().definitions) + } + + // 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 + Object.values(swaggerObject.components.schemas) + .forEach(_ => { delete _.$id }) + + swaggerObject.paths = {} + + for (const route of routes) { + const schema = transform + ? transform(route.schema) + : route.schema + + if (schema && schema.hide) { + continue + } + + if (schema && schema.tags && schema.tags.includes(hiddenTag)) { + continue + } + + const path = stripBasePath + ? stripBasePathByServers(route.url, swaggerObject.servers) + : route.url + const url = formatParamUrl(path) + + const swaggerRoute = swaggerObject.paths[url] || {} + + const swaggerMethod = {} + const parameters = [] + + // route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH'] + const methods = typeof route.method === 'string' ? [route.method] : route.method + + for (const method of methods) { + swaggerRoute[method.toLowerCase()] = swaggerMethod + } + + // All the data the user can give us, is via the schema object + if (schema) { + // the resulting schema will be in this order + if (schema.operationId) { + swaggerMethod.operationId = schema.operationId + } + + if (schema.tags) { + swaggerMethod.tags = schema.tags + } + + if (schema.summary) { + swaggerMethod.summary = schema.summary + } + + if (schema.description) { + swaggerMethod.description = schema.description + } + + if (schema.externalDocs) { + swaggerMethod.externalDocs = schema.externalDocs + } + + if (schema.querystring) { + getQueryParams(parameters, schema.querystring) + } + + if (schema.body) { + swaggerMethod.requestBody = { + content: {} + } + getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes) + } + + if (schema.params) { + getPathParams(parameters, schema.params) + } + + if (schema.headers) { + getHeaderParams(parameters, schema.headers) + } + + if (parameters.length) { + swaggerMethod.parameters = parameters + } + + if (schema.deprecated) { + swaggerMethod.deprecated = schema.deprecated + } + + if (schema.security) { + swaggerMethod.security = schema.security + } + + if (schema.servers) { + swaggerMethod.servers = schema.servers + } + + for (const key of Object.keys(schema)) { + if (key.startsWith('x-')) { + swaggerMethod[key] = schema[key] + } + } + } + + swaggerMethod.responses = genResponse(schema ? schema.response : null) + + swaggerObject.paths[url] = swaggerRoute + } + + if (opts && opts.yaml) { + const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) + cache.swaggerString = swaggerString + return swaggerString + } + + cache.swaggerObject = swaggerObject + return swaggerObject + + function getBodyParams (parameters, body, consumes) { + const bodyResolved = ref.resolve(body) + + if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { + consumes = ['application/json'] + } + + consumes.forEach((consume) => { + parameters[consume] = { + schema: bodyResolved + } + }) + } + + function getQueryParams (parameters, query) { + const resolved = ref.resolve(query) + const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.components.schemas) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + } + + function getPathParams (parameters, path) { + const resolved = ref.resolve(path) + const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.components.schemas) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + } + + function getHeaderParams (parameters, headers) { + const resolved = ref.resolve(headers) + const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.components.schemas) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + } + + // https://swagger.io/docs/specification/2-0/describing-responses/ + function genResponse (fastifyResponseJson) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + // 2xx is not supported by swagger + + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + const content = { + 'application/json': {} + } + + content['application/json'] = { + schema: resolved + } + + responsesContainer[key] = { + content, + description: rawJsonSchema.description || 'Default Response' + } + }) + + return responsesContainer + } + } +} diff --git a/lib/util.js b/lib/util.js index d0d2b9cc..8766e555 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,3 +1,6 @@ +'use strict' + +const { URL } = require('url') const Ref = require('json-schema-resolver') function addHook (fastify) { @@ -113,6 +116,21 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { }, []) } +function swagger2ParametersToOpenapi3 (jsonSchema) { + jsonSchema.schema = {} + jsonSchema.schema.type = jsonSchema.type + if (jsonSchema.type === 'object') { + jsonSchema.schema.properties = jsonSchema.properties + } + if (jsonSchema.type === 'array') { + jsonSchema.schema.items = jsonSchema.items + } + delete jsonSchema.type + delete jsonSchema.properties + delete jsonSchema.items + return jsonSchema +} + function localRefResolve (jsonSchema, externalSchemas) { if (jsonSchema.type && jsonSchema.properties) { // for the shorthand querystring/params/headers declaration @@ -130,10 +148,23 @@ function localRefResolve (jsonSchema, externalSchemas) { return localRefResolve(externalSchemas[localReference], externalSchemas) } +function stripBasePathByServers (path, servers) { + servers = Array.isArray(servers) ? servers : [] + servers.forEach(function (server) { + const basePath = new URL(server.url).pathname + if (path.startsWith(basePath) && basePath !== '/') { + path = path.replace(basePath, '') + } + }) + return path +} + module.exports = { addHook, formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2, - localRefResolve + swagger2ParametersToOpenapi3, + localRefResolve, + stripBasePathByServers } From d1660c74969e104a7e19eb6b34c13470deaa4896 Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 11 Jan 2021 19:01:16 +0800 Subject: [PATCH 25/46] test: add openapi 3 test case --- test/openapi.js | 633 ++++++++++++++++++++++++++++++++++++++++++++++++ test/static.js | 76 ++++++ 2 files changed, 709 insertions(+) create mode 100644 test/openapi.js diff --git a/test/openapi.js b/test/openapi.js new file mode 100644 index 00000000..7fdb29ef --- /dev/null +++ b/test/openapi.js @@ -0,0 +1,633 @@ +'use strict' + +const t = require('tap') +const test = t.test +const Fastify = require('fastify') +const Swagger = require('swagger-parser') +const yaml = require('js-yaml') +const fastifySwagger = require('../index') + +const swaggerInfo = { + openapi: { + info: { + title: 'Test swagger', + description: 'testing the fastify swagger api', + version: '0.1.0' + }, + servers: [ + { + url: 'http://localhost' + } + ], + tags: [ + { name: 'tag' } + ], + externalDocs: { + description: 'Find more info here', + url: 'https://swagger.io' + } + } +} + +const opts1 = { + schema: { + response: { + 200: { + type: 'object', + properties: { + hello: { type: 'string' } + } + } + }, + querystring: { + hello: { type: 'string' }, + world: { type: 'string' }, + foo: { type: 'array', items: { type: 'string' } }, + bar: { type: 'object', properties: { baz: { type: 'string' } } } + } + } +} + +const opts2 = { + schema: { + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + }, + required: ['hello'] + } + } +} + +const opts3 = { + schema: { + params: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'user id' + } + } + } + } +} + +const opts4 = { + schema: { + headers: { + type: 'object', + properties: { + authorization: { + type: 'string', + description: 'api token' + } + }, + required: ['authorization'] + } + } +} + +const opts5 = { + schema: { + headers: { + type: 'object', + properties: { + 'x-api-token': { + type: 'string', + description: 'optional api token' + }, + 'x-api-version': { + type: 'string', + description: 'optional api version' + } + } + }, + params: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'user id' + } + } + } + } +} + +const opts6 = { + schema: { + security: [ + { + apiKey: [] + } + ] + } +} + +const opts7 = { + schema: { + consumes: ['application/x-www-form-urlencoded'], + body: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } +} + +const opts8 = { + schema: { + 'x-tension': true + } +} + +test('fastify.swagger should return a valid swagger object', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', () => {}) + fastify.post('/', () => {}) + fastify.get('/example', opts1, () => {}) + fastify.post('/example', opts2, () => {}) + fastify.get('/parameters/:id', opts3, () => {}) + fastify.get('/headers', opts4, () => {}) + fastify.get('/headers/:id', opts5, () => {}) + fastify.get('/security', opts6, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('fastify.swagger should return a valid swagger yaml', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', () => {}) + fastify.post('/', () => {}) + fastify.get('/example', opts1, () => {}) + fastify.post('/example', opts2, () => {}) + fastify.get('/parameters/:id', opts3, () => {}) + fastify.get('/headers', opts4, () => {}) + fastify.get('/headers/:id', opts5, () => {}) + fastify.get('/security', opts6, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerYaml = fastify.swagger({ yaml: true }) + t.is(typeof swaggerYaml, 'string') + + try { + yaml.safeLoad(swaggerYaml) + t.pass('valid swagger yaml') + } catch (err) { + t.fail(err) + } + }) +}) + +test('hide support when property set in transform() - property', t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(fastifySwagger, { + ...swaggerInfo, + transform: schema => { + return { ...schema, hide: true } + } + }) + + const opts = { + schema: { + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/']) + }) +}) + +test('fastify.swagger components', t => { + t.plan(2) + const fastify = Fastify() + + swaggerInfo.openapi.components = { + schemas: { + ExampleModel: { + type: 'object', + properties: { + id: { + type: 'integer', + description: 'Some id' + }, + name: { + type: 'string', + description: 'Name of smthng' + } + } + } + } + } + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.deepEquals(swaggerObject.components, swaggerInfo.openapi.components) + delete swaggerInfo.openapi.components // remove what we just added + }) +}) + +test('hide support - tags Default', t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + const opts = { + schema: { + tags: ['X-HIDDEN'], + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/']) + }) +}) + +test('hide support - tags Custom', t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(fastifySwagger, { ...swaggerInfo, hiddenTag: 'NOP' }) + + const opts = { + schema: { + tags: ['NOP'], + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/']) + }) +}) + +test('deprecated route', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + const opts = { + schema: { + deprecated: true, + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + t.ok(swaggerObject.paths['/']) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('route meta info', t => { + t.plan(8) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + const opts = { + schema: { + operationId: 'doSomething', + summary: 'Route summary', + tags: ['tag'], + description: 'Route description', + servers: [ + { + url: 'https://localhost' + } + ], + externalDocs: { + description: 'Find more info here', + url: 'https://swagger.io' + } + } + } + + fastify.get('/', opts, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.equal(opts.schema.operationId, definedPath.operationId) + t.equal(opts.schema.summary, definedPath.summary) + t.same(opts.schema.tags, definedPath.tags) + t.equal(opts.schema.description, definedPath.description) + t.equal(opts.schema.servers, definedPath.servers) + t.equal(opts.schema.externalDocs, definedPath.externalDocs) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { + t.plan(3) + const fastify = Fastify() + fastify.register(fastifySwagger, swaggerInfo) + fastify.get('/', opts7, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.requestBody.content, { + 'application/x-www-form-urlencoded': { + schema: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + }) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('includes swagger extensions', t => { + t.plan(5) + const fastify = Fastify() + fastify.register(fastifySwagger, { openapi: { 'x-ternal': true } }) + fastify.get('/', opts8, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + t.ok(api['x-ternal']) + t.same(api['x-ternal'], true) + + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath['x-tension'], true) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('basePath support', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, { + openapi: Object.assign({}, swaggerInfo.openapi, { + servers: [ + { + url: 'http://localhost/prefix' + } + ] + }) + }) + + fastify.get('/prefix/endpoint', {}, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths['/prefix/endpoint']) + t.ok(swaggerObject.paths['/endpoint']) + }) +}) + +test('basePath maintained when stripBasePath is set to false', t => { + t.plan(4) + + const fastify = Fastify() + + fastify.register(fastifySwagger, { + stripBasePath: false, + openapi: Object.assign({}, swaggerInfo.openapi, { + servers: [ + { + url: 'http://localhost/foo' + } + ] + }) + }) + + fastify.get('/foo/endpoint', {}, () => {}) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.notOk(swaggerObject.paths.endpoint) + t.notOk(swaggerObject.paths['/endpoint']) + t.ok(swaggerObject.paths['/foo/endpoint']) + }) +}) + +test('cache - json', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.ready(err => { + t.error(err) + + fastify.swagger() + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + +test('cache - yaml', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.ready(err => { + t.error(err) + + fastify.swagger({ yaml: true }) + const swaggerYaml = fastify.swagger({ yaml: true }) + t.is(typeof swaggerYaml, 'string') + + try { + yaml.safeLoad(swaggerYaml) + t.pass('valid swagger yaml') + } catch (err) { + t.fail(err) + } + }) +}) + +test('route with multiple method', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.route({ + method: ['GET', 'POST'], + url: '/', + handler: function (request, reply) { + reply.send({ hello: 'world' }) + } + }) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) diff --git a/test/static.js b/test/static.js index db95b9d4..2f335fac 100644 --- a/test/static.js +++ b/test/static.js @@ -633,6 +633,44 @@ test('inserts default package name', t => { ) }) +test('inserts default package name - openapi', t => { + const config = { + mode: 'dynamic', + openapi: { + servers: [] + }, + specification: { + path: './examples/example-static-specification.json' + }, + exposeRoute: true + } + + t.plan(2) + const fastify = Fastify() + fastify.register(fastifySwagger, config) + + const originalPathJoin = path.join + const testPackageJSON = path.join(__dirname, '../examples/test-package.json') + + path.join = (...args) => { + if (args[2] === 'package.json') { + return testPackageJSON + } + return originalPathJoin(...args) + } + + fastify.inject( + { + method: 'GET', + url: '/documentation/json' + }, + (err, res) => { + t.error(err) + t.pass('Inserted default package name.') + } + ) +}) + test('throws an error if cannot parse package\'s JSON', t => { const config = { mode: 'dynamic', @@ -668,6 +706,44 @@ test('throws an error if cannot parse package\'s JSON', t => { ) }) +test('throws an error if cannot parse package\'s JSON - openapi', t => { + const config = { + mode: 'dynamic', + openapi: { + servers: [] + }, + specification: { + path: './examples/example-static-specification.json' + }, + exposeRoute: true + } + + t.plan(2) + const fastify = Fastify() + fastify.register(fastifySwagger, config) + + const originalPathJoin = path.join + const testPackageJSON = path.join(__dirname, '') + + path.join = (...args) => { + if (args[2] === 'package.json') { + return testPackageJSON + } + return originalPathJoin(...args) + } + + fastify.inject( + { + method: 'GET', + url: '/documentation/json' + }, + (err, res) => { + t.error(err) + t.equal(err, null) + } + ) +}) + test('inserts default opts in fastifySwaggerDynamic (dynamic.js)', t => { t.plan(1) const fastify = Fastify() From bad28b65afa62850bfa1f5482af6e8e7b7e5ee50 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:07:23 +0800 Subject: [PATCH 26/46] feat: update openapi from 3.0.0 to 3.0.3 --- lib/openapi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openapi.js b/lib/openapi.js index d3a32414..48646918 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -44,7 +44,7 @@ module.exports = function (opts, routes, Ref, cache, next) { // Base Openapi info // this info is displayed in the swagger file // in the same order as here - swaggerObject.openapi = '3.0.0' + swaggerObject.openapi = '3.0.3' if (info) { swaggerObject.info = info } else { From abc1a903a7079c10a14410c56db4160e635d93dd Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:08:18 +0800 Subject: [PATCH 27/46] refactor: typo - properyName to propertyName --- lib/util.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/util.js b/lib/util.js index 8766e555..40fae2f6 100644 --- a/lib/util.js +++ b/lib/util.js @@ -68,17 +68,17 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { let toSwaggerProp switch (container) { case 'query': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { jsonSchemaElement.in = container - jsonSchemaElement.name = properyName + jsonSchemaElement.name = propertyName return jsonSchemaElement } break case 'formData': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { delete jsonSchemaElement.$id jsonSchemaElement.in = container - jsonSchemaElement.name = properyName + jsonSchemaElement.name = propertyName // https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding if (jsonSchemaElement.contentEncoding === 'binary') { @@ -90,18 +90,18 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { } break case 'path': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { jsonSchemaElement.in = container - jsonSchemaElement.name = properyName + jsonSchemaElement.name = propertyName jsonSchemaElement.required = true return jsonSchemaElement } break case 'header': - toSwaggerProp = function (properyName, jsonSchemaElement) { + toSwaggerProp = function (propertyName, jsonSchemaElement) { return { in: 'header', - name: properyName, + name: propertyName, required: jsonSchemaElement.required, description: jsonSchemaElement.description, type: jsonSchemaElement.type From 780ed1469816441f8b88f2263b98c49b13398634 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:09:01 +0800 Subject: [PATCH 28/46] refactor: more clear argument - h to headers --- lib/util.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/util.js b/lib/util.js index 40fae2f6..6f5a7052 100644 --- a/lib/util.js +++ b/lib/util.js @@ -134,10 +134,10 @@ function swagger2ParametersToOpenapi3 (jsonSchema) { function localRefResolve (jsonSchema, externalSchemas) { if (jsonSchema.type && jsonSchema.properties) { // for the shorthand querystring/params/headers declaration - const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, h) => { - const required = (jsonSchema.required && jsonSchema.required.indexOf(h) >= 0) || false - const newProps = Object.assign({}, jsonSchema.properties[h], { required }) - return Object.assign({}, acc, { [h]: newProps }) + const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => { + const required = (jsonSchema.required && jsonSchema.required.indexOf(headers) >= 0) || false + const newProps = Object.assign({}, jsonSchema.properties[headers], { required }) + return Object.assign({}, acc, { [headers]: newProps }) }, {}) return propertiesMap From 4c6806ad207d4785cb3cb233a0d989d18f478a55 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:13:25 +0800 Subject: [PATCH 29/46] refactor: use map instead of reduce --- lib/util.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/util.js b/lib/util.js index 6f5a7052..268a212f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -110,10 +110,9 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { break } - return Object.keys(obj).reduce((acc, propKey) => { - acc.push(toSwaggerProp(propKey, obj[propKey])) - return acc - }, []) + return Object.keys(obj).map((propKey) => { + return toSwaggerProp(propKey, obj[propKey]) + }) } function swagger2ParametersToOpenapi3 (jsonSchema) { From c11a9360aed7635a3cc879c1f6fe8a287aaf3667 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:13:46 +0800 Subject: [PATCH 30/46] refactor: use done instead of next --- lib/dynamic.js | 8 ++++---- lib/openapi.js | 4 ++-- lib/routes.js | 4 ++-- lib/static.js | 22 +++++++++++----------- lib/swagger.js | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/dynamic.js b/lib/dynamic.js index b6e80abd..1098111c 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -4,7 +4,7 @@ const { addHook } = require('./util') const buildSwagger = require('./swagger') const buildOpenapi = require('./openapi') -module.exports = function (fastify, opts, next) { +module.exports = function (fastify, opts, done) { const { routes, Ref } = addHook(fastify) opts = Object.assign({}, { @@ -27,10 +27,10 @@ module.exports = function (fastify, opts, next) { } if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { - fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, next)) + fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, done)) } else { - fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, next)) + fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, done)) } - next() + done() } diff --git a/lib/openapi.js b/lib/openapi.js index 48646918..95a8df0a 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -5,7 +5,7 @@ const path = require('path') const yaml = require('js-yaml') const { formatParamUrl, plainJsonObjectToSwagger2, swagger2ParametersToOpenapi3, stripBasePathByServers } = require('./util') -module.exports = function (opts, routes, Ref, cache, next) { +module.exports = function (opts, routes, Ref, cache, done) { let ref const info = opts.openapi.info || null @@ -38,7 +38,7 @@ module.exports = function (opts, routes, Ref, cache, next) { try { pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) } catch (err) { - return next(err) + return done(err) } // Base Openapi info diff --git a/lib/routes.js b/lib/routes.js index 62860c6e..011055d3 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -19,7 +19,7 @@ function getRedirectPathForTheRootRoute (url) { return redirectPath } -function fastifySwagger (fastify, opts, next) { +function fastifySwagger (fastify, opts, done) { fastify.route({ url: '/', method: 'GET', @@ -72,7 +72,7 @@ function fastifySwagger (fastify, opts, next) { } }) - next() + done() } module.exports = fastifySwagger diff --git a/lib/static.js b/lib/static.js index 23adbdb1..a2530f4c 100644 --- a/lib/static.js +++ b/lib/static.js @@ -4,25 +4,25 @@ const path = require('path') const fs = require('fs') const yaml = require('js-yaml') -module.exports = function (fastify, opts, next) { - if (!opts.specification) return next(new Error('specification is missing in the module options')) - if (typeof opts.specification !== 'object') return next(new Error('specification is not an object')) +module.exports = function (fastify, opts, done) { + if (!opts.specification) return done(new Error('specification is missing in the module options')) + if (typeof opts.specification !== 'object') return done(new Error('specification is not an object')) let swaggerObject = {} if (!opts.specification.path && !opts.specification.document) { - return next(new Error('both specification.path and specification.document are missing, should be path to the file or swagger document spec')) + return done(new Error('both specification.path and specification.document are missing, should be path to the file or swagger document spec')) } else if (opts.specification.path) { - if (typeof opts.specification.path !== 'string') return next(new Error('specification.path is not a string')) + if (typeof opts.specification.path !== 'string') return done(new Error('specification.path is not a string')) - if (!fs.existsSync(path.resolve(opts.specification.path))) return next(new Error(`${opts.specification.path} does not exist`)) + if (!fs.existsSync(path.resolve(opts.specification.path))) return done(new Error(`${opts.specification.path} does not exist`)) const extName = path.extname(opts.specification.path).toLowerCase() - if (['.yaml', '.json'].indexOf(extName) === -1) return next(new Error("specification.path extension name is not supported, should be one from ['.yaml', '.json']")) + if (['.yaml', '.json'].indexOf(extName) === -1) return done(new Error("specification.path extension name is not supported, should be one from ['.yaml', '.json']")) - if (opts.specification.postProcessor && typeof opts.specification.postProcessor !== 'function') return next(new Error('specification.postProcessor should be a function')) + if (opts.specification.postProcessor && typeof opts.specification.postProcessor !== 'function') return done(new Error('specification.postProcessor should be a function')) - if (opts.specification.baseDir && typeof opts.specification.baseDir !== 'string') return next(new Error('specification.baseDir should be string')) + if (opts.specification.baseDir && typeof opts.specification.baseDir !== 'string') return done(new Error('specification.baseDir should be string')) if (!opts.specification.baseDir) { opts.specification.baseDir = path.resolve(path.dirname(opts.specification.path)) @@ -51,7 +51,7 @@ module.exports = function (fastify, opts, next) { swaggerObject = opts.specification.postProcessor(swaggerObject) } } else { - if (typeof opts.specification.document !== 'object') return next(new Error('specification.document is not an object')) + if (typeof opts.specification.document !== 'object') return done(new Error('specification.document is not an object')) swaggerObject = opts.specification.document } @@ -89,5 +89,5 @@ module.exports = function (fastify, opts, next) { return swaggerObject } - next() + done() } diff --git a/lib/swagger.js b/lib/swagger.js index 7f5c69e2..3634e522 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -5,7 +5,7 @@ const path = require('path') const yaml = require('js-yaml') const { formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') -module.exports = function (opts, routes, Ref, cache, next) { +module.exports = function (opts, routes, Ref, cache, done) { let ref const info = opts.swagger.info || null @@ -43,7 +43,7 @@ module.exports = function (opts, routes, Ref, cache, next) { try { pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) } catch (err) { - return next(err) + return done(err) } // Base swagger info From cf9bec59e6f61074ee498e2b5abe380bd4c7c7a5 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:19:07 +0800 Subject: [PATCH 31/46] refactor: group functions - remove getQueryParams, getPathParams, getHeaderParams, getFormParams - add getParams --- lib/openapi.js | 22 +++++----------------- lib/swagger.js | 30 ++++++------------------------ 2 files changed, 11 insertions(+), 41 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 95a8df0a..f6feca89 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -140,7 +140,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getQueryParams(parameters, schema.querystring) + getParams('query', parameters, schema.querystring) } if (schema.body) { @@ -151,11 +151,11 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.params) { - getPathParams(parameters, schema.params) + getParams('path', parameters, schema.params) } if (schema.headers) { - getHeaderParams(parameters, schema.headers) + getParams('header', parameters, schema.headers) } if (parameters.length) { @@ -209,21 +209,9 @@ module.exports = function (opts, routes, Ref, cache, done) { }) } - function getQueryParams (parameters, query) { + function getParams (container, parameters, query) { const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.components.schemas) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) - } - - function getPathParams (parameters, path) { - const resolved = ref.resolve(path) - const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.components.schemas) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) - } - - function getHeaderParams (parameters, headers) { - const resolved = ref.resolve(headers) - const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.components.schemas) + const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.components.schemas) add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) } diff --git a/lib/swagger.js b/lib/swagger.js index 3634e522..ccb34881 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -157,23 +157,23 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getQueryParams(parameters, schema.querystring) + getParams('query', parameters, schema.querystring) } if (schema.body) { const consumesAllFormOnly = consumesFormOnly(schema) || consumesFormOnly(swaggerObject) consumesAllFormOnly - ? getFormParams(parameters, schema.body) + ? getParams('formData', parameters, schema.body) : getBodyParams(parameters, schema.body) } if (schema.params) { - getPathParams(parameters, schema.params) + getParams('path', parameters, schema.params) } if (schema.headers) { - getHeaderParams(parameters, schema.headers) + getParams('header', parameters, schema.headers) } if (parameters.length) { @@ -219,27 +219,9 @@ module.exports = function (opts, routes, Ref, cache, done) { parameters.push(param) } - function getFormParams (parameters, form) { + function getParams (container, parameters, form) { const resolved = ref.resolve(form) - const add = plainJsonObjectToSwagger2('formData', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getQueryParams (parameters, query) { - const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2('query', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getPathParams (parameters, path) { - const resolved = ref.resolve(path) - const add = plainJsonObjectToSwagger2('path', resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - function getHeaderParams (parameters, headers) { - const resolved = ref.resolve(headers) - const add = plainJsonObjectToSwagger2('header', resolved, swaggerObject.definitions) + const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.definitions) add.forEach(_ => parameters.push(_)) } From c66fd2c9e124ce7a9148d669303e0f9ac733bd2b Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:21:47 +0800 Subject: [PATCH 32/46] refactor: genResponse to generateResponse --- lib/openapi.js | 4 ++-- lib/swagger.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index f6feca89..ac8698db 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -181,7 +181,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = genResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null) swaggerObject.paths[url] = swaggerRoute } @@ -216,7 +216,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } // https://swagger.io/docs/specification/2-0/describing-responses/ - function genResponse (fastifyResponseJson) { + function generateResponse (fastifyResponseJson) { // if the user does not provided an out schema if (!fastifyResponseJson) { return { 200: { description: 'Default Response' } } diff --git a/lib/swagger.js b/lib/swagger.js index ccb34881..b6939430 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -195,7 +195,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = genResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null) swaggerObject.paths[url] = swaggerRoute } @@ -226,7 +226,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } // https://swagger.io/docs/specification/2-0/describing-responses/ - function genResponse (fastifyResponseJson) { + function generateResponse (fastifyResponseJson) { // if the user does not provided an out schema if (!fastifyResponseJson) { return { 200: { description: 'Default Response' } } From 4afe782d48d9fa02e407aa294d72ad81535c2cfc Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 11:35:12 +0800 Subject: [PATCH 33/46] chore: remove invalid comment --- lib/openapi.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index ac8698db..e7633d3c 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -225,8 +225,6 @@ module.exports = function (opts, routes, Ref, cache, done) { const responsesContainer = {} Object.keys(fastifyResponseJson).forEach(key => { - // 2xx is not supported by swagger - const rawJsonSchema = fastifyResponseJson[key] const resolved = ref.resolve(rawJsonSchema) From dd7d500a4df22fe6c0a54dc7983d4ad9d8206609 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 12:02:28 +0800 Subject: [PATCH 34/46] refactor: better structure - split util to dynamicUtil, OpenapiUtil, swaggerUtil - extract inline function from openapi and swagger --- lib/dynamic.js | 15 ++-- lib/dynamicUtil.js | 67 ++++++++++++++ lib/openapi.js | 63 ++------------ lib/openapiUtil.js | 87 +++++++++++++++++++ lib/swagger.js | 55 ++---------- lib/{util.js => swaggerUtil.js} | 149 ++++++++++++-------------------- 6 files changed, 229 insertions(+), 207 deletions(-) create mode 100644 lib/dynamicUtil.js create mode 100644 lib/openapiUtil.js rename lib/{util.js => swaggerUtil.js} (56%) diff --git a/lib/dynamic.js b/lib/dynamic.js index 1098111c..e393cedf 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -1,8 +1,6 @@ 'use strict' -const { addHook } = require('./util') -const buildSwagger = require('./swagger') -const buildOpenapi = require('./openapi') +const { addHook, resolveSwaggerFunction } = require('./dynamicUtil') module.exports = function (fastify, opts, done) { const { routes, Ref } = addHook(fastify) @@ -22,15 +20,12 @@ module.exports = function (fastify, opts, done) { } const cache = { - swaggerObject: null, - swaggerString: null + object: null, + string: null } - if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { - fastify.decorate('swagger', buildOpenapi(opts, routes, Ref, cache, done)) - } else { - fastify.decorate('swagger', buildSwagger(opts, routes, Ref, cache, done)) - } + const swagger = resolveSwaggerFunction(opts, routes, Ref, cache, done) + fastify.decorate('swagger', swagger) done() } diff --git a/lib/dynamicUtil.js b/lib/dynamicUtil.js new file mode 100644 index 00000000..e279ec9b --- /dev/null +++ b/lib/dynamicUtil.js @@ -0,0 +1,67 @@ +'use strict' + +const Ref = require('json-schema-resolver') + +function addHook (fastify) { + const routes = [] + const sharedSchemasMap = new Map() + + fastify.addHook('onRoute', (routeOptions) => { + routes.push(routeOptions) + }) + + fastify.addHook('onRegister', async (instance) => { + // we need to wait the ready event to get all the .getSchemas() + // otherwise it will be empty + instance.addHook('onReady', (done) => { + const allSchemas = instance.getSchemas() + for (const schemaId of Object.keys(allSchemas)) { + if (!sharedSchemasMap.has(schemaId)) { + sharedSchemasMap.set(schemaId, allSchemas[schemaId]) + } + } + done() + }) + }) + + return { + routes, + Ref () { + const externalSchemas = Array.from(sharedSchemasMap.values()) + return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) + } + } +} + +function resolveSwaggerFunction (opts, routes, Ref, cache, done) { + let build + if (Object.keys(opts.openapi).length > 0 && opts.openapi.constructor === Object) { + build = require('./openapi') + } else { + build = require('./swagger') + } + return build(opts, routes, Ref, cache, done) +} + +// The swagger standard does not accept the url param with ':' +// so '/user/:id' is not valid. +// This function converts the url in a swagger compliant url string +// => '/user/{id}' +function formatParamUrl (url) { + let start = url.indexOf('/:') + if (start === -1) return url + + const end = url.indexOf('/', ++start) + + if (end === -1) { + return url.slice(0, start) + '{' + url.slice(++start) + '}' + } else { + return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) + } +} + +module.exports = { + addHook, + resolveSwaggerFunction, + formatParamUrl +} diff --git a/lib/openapi.js b/lib/openapi.js index e7633d3c..6f3dd740 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -3,7 +3,8 @@ const fs = require('fs') const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl, plainJsonObjectToSwagger2, swagger2ParametersToOpenapi3, stripBasePathByServers } = require('./util') +const { formatParamUrl } = require('./dynamicUtil') +const { getBodyParams, getCommonParams, generateResponse, stripBasePathByServers } = require('./openapiUtil') module.exports = function (opts, routes, Ref, cache, done) { let ref @@ -140,22 +141,22 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getParams('query', parameters, schema.querystring) + getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.components.schemas) } if (schema.body) { swaggerMethod.requestBody = { content: {} } - getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes) + getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes, ref) } if (schema.params) { - getParams('path', parameters, schema.params) + getCommonParams('path', parameters, schema.params, ref, swaggerObject.components.schemas) } if (schema.headers) { - getParams('header', parameters, schema.headers) + getCommonParams('header', parameters, schema.headers, ref, swaggerObject.components.schemas) } if (parameters.length) { @@ -181,7 +182,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = generateResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null, ref) swaggerObject.paths[url] = swaggerRoute } @@ -194,55 +195,5 @@ module.exports = function (opts, routes, Ref, cache, done) { cache.swaggerObject = swaggerObject return swaggerObject - - function getBodyParams (parameters, body, consumes) { - const bodyResolved = ref.resolve(body) - - if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { - consumes = ['application/json'] - } - - consumes.forEach((consume) => { - parameters[consume] = { - schema: bodyResolved - } - }) - } - - function getParams (container, parameters, query) { - const resolved = ref.resolve(query) - const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.components.schemas) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) - } - - // https://swagger.io/docs/specification/2-0/describing-responses/ - function generateResponse (fastifyResponseJson) { - // if the user does not provided an out schema - if (!fastifyResponseJson) { - return { 200: { description: 'Default Response' } } - } - - const responsesContainer = {} - - Object.keys(fastifyResponseJson).forEach(key => { - const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) - - const content = { - 'application/json': {} - } - - content['application/json'] = { - schema: resolved - } - - responsesContainer[key] = { - content, - description: rawJsonSchema.description || 'Default Response' - } - }) - - return responsesContainer - } } } diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js new file mode 100644 index 00000000..d4aef95a --- /dev/null +++ b/lib/openapiUtil.js @@ -0,0 +1,87 @@ +'use strict' + +const { URL } = require('url') +const { plainJsonObjectToSwagger2 } = require('./swaggerUtil') + +function swagger2ParametersToOpenapi3 (jsonSchema) { + jsonSchema.schema = {} + jsonSchema.schema.type = jsonSchema.type + if (jsonSchema.type === 'object') { + jsonSchema.schema.properties = jsonSchema.properties + } + if (jsonSchema.type === 'array') { + jsonSchema.schema.items = jsonSchema.items + } + delete jsonSchema.type + delete jsonSchema.properties + delete jsonSchema.items + return jsonSchema +} + +function getBodyParams (parameters, body, consumes, ref) { + const bodyResolved = ref.resolve(body) + + if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { + consumes = ['application/json'] + } + + consumes.forEach((consume) => { + parameters[consume] = { + schema: bodyResolved + } + }) +} + +function getCommonParams (container, parameters, schema, ref, sharedSchema) { + const resolved = ref.resolve(schema) + const add = plainJsonObjectToSwagger2(container, resolved, sharedSchema) + add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) +} + +// https://swagger.io/docs/specification/2-0/describing-responses/ +function generateResponse (fastifyResponseJson, ref) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + const content = { + 'application/json': {} + } + + content['application/json'] = { + schema: resolved + } + + responsesContainer[key] = { + content, + description: rawJsonSchema.description || 'Default Response' + } + }) + + return responsesContainer +} + +function stripBasePathByServers (path, servers) { + servers = Array.isArray(servers) ? servers : [] + servers.forEach(function (server) { + const basePath = new URL(server.url).pathname + if (path.startsWith(basePath) && basePath !== '/') { + path = path.replace(basePath, '') + } + }) + return path +} + +module.exports = { + getBodyParams, + getCommonParams, + generateResponse, + stripBasePathByServers +} diff --git a/lib/swagger.js b/lib/swagger.js index b6939430..09e392ce 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -3,7 +3,8 @@ const fs = require('fs') const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2 } = require('./util') +const { formatParamUrl } = require('./dynamicUtil') +const { consumesFormOnly, getBodyParams, getCommonParams, generateResponse } = require('./swaggerUtil') module.exports = function (opts, routes, Ref, cache, done) { let ref @@ -157,23 +158,23 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getParams('query', parameters, schema.querystring) + getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.definitions) } if (schema.body) { const consumesAllFormOnly = consumesFormOnly(schema) || consumesFormOnly(swaggerObject) consumesAllFormOnly - ? getParams('formData', parameters, schema.body) - : getBodyParams(parameters, schema.body) + ? getCommonParams('formData', parameters, schema.body, ref, swaggerObject.definitions) + : getBodyParams(parameters, schema.body, ref) } if (schema.params) { - getParams('path', parameters, schema.params) + getCommonParams('path', parameters, schema.params, ref, swaggerObject.definitions) } if (schema.headers) { - getParams('header', parameters, schema.headers) + getCommonParams('header', parameters, schema.headers, ref, swaggerObject.definitions) } if (parameters.length) { @@ -195,7 +196,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = generateResponse(schema ? schema.response : null) + swaggerMethod.responses = generateResponse(schema ? schema.response : null, ref) swaggerObject.paths[url] = swaggerRoute } @@ -208,45 +209,5 @@ module.exports = function (opts, routes, Ref, cache, done) { cache.swaggerObject = swaggerObject return swaggerObject - - function getBodyParams (parameters, body) { - const bodyResolved = ref.resolve(body) - - const param = {} - param.name = 'body' - param.in = 'body' - param.schema = bodyResolved - parameters.push(param) - } - - function getParams (container, parameters, form) { - const resolved = ref.resolve(form) - const add = plainJsonObjectToSwagger2(container, resolved, swaggerObject.definitions) - add.forEach(_ => parameters.push(_)) - } - - // https://swagger.io/docs/specification/2-0/describing-responses/ - function generateResponse (fastifyResponseJson) { - // if the user does not provided an out schema - if (!fastifyResponseJson) { - return { 200: { description: 'Default Response' } } - } - - const responsesContainer = {} - - Object.keys(fastifyResponseJson).forEach(key => { - // 2xx is not supported by swagger - - const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) - - responsesContainer[key] = { - schema: resolved, - description: rawJsonSchema.description || 'Default Response' - } - }) - - return responsesContainer - } } } diff --git a/lib/util.js b/lib/swaggerUtil.js similarity index 56% rename from lib/util.js rename to lib/swaggerUtil.js index 268a212f..94d70dba 100644 --- a/lib/util.js +++ b/lib/swaggerUtil.js @@ -1,64 +1,20 @@ 'use strict' -const { URL } = require('url') -const Ref = require('json-schema-resolver') - -function addHook (fastify) { - const routes = [] - const sharedSchemasMap = new Map() - - fastify.addHook('onRoute', (routeOptions) => { - routes.push(routeOptions) - }) - - fastify.addHook('onRegister', async (instance) => { - // we need to wait the ready event to get all the .getSchemas() - // otherwise it will be empty - instance.addHook('onReady', (done) => { - const allSchemas = instance.getSchemas() - for (const schemaId of Object.keys(allSchemas)) { - if (!sharedSchemasMap.has(schemaId)) { - sharedSchemasMap.set(schemaId, allSchemas[schemaId]) - } - } - done() - }) - }) - - return { - routes, - Ref () { - const externalSchemas = Array.from(sharedSchemasMap.values()) - return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) - } - } -} - -// The swagger standard does not accept the url param with ':' -// so '/user/:id' is not valid. -// This function converts the url in a swagger compliant url string -// => '/user/{id}' -function formatParamUrl (url) { - let start = url.indexOf('/:') - if (start === -1) return url - - const end = url.indexOf('/', ++start) +function localRefResolve (jsonSchema, externalSchemas) { + if (jsonSchema.type && jsonSchema.properties) { + // for the shorthand querystring/params/headers declaration + const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => { + const required = (jsonSchema.required && jsonSchema.required.indexOf(headers) >= 0) || false + const newProps = Object.assign({}, jsonSchema.properties[headers], { required }) + return Object.assign({}, acc, { [headers]: newProps }) + }, {}) - if (end === -1) { - return url.slice(0, start) + '{' + url.slice(++start) + '}' - } else { - return formatParamUrl(url.slice(0, start) + '{' + url.slice(++start, end) + '}' + url.slice(end)) + return propertiesMap } -} -function consumesFormOnly (schema) { - const consumes = schema.consumes - return ( - consumes && - consumes.length === 1 && - (consumes[0] === 'application/x-www-form-urlencoded' || - consumes[0] === 'multipart/form-data') - ) + // $ref is in the format: #/definitions// + const localReference = jsonSchema.$ref.split('/')[2] + return localRefResolve(externalSchemas[localReference], externalSchemas) } // For supported keys read: @@ -115,55 +71,60 @@ function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas) { }) } -function swagger2ParametersToOpenapi3 (jsonSchema) { - jsonSchema.schema = {} - jsonSchema.schema.type = jsonSchema.type - if (jsonSchema.type === 'object') { - jsonSchema.schema.properties = jsonSchema.properties - } - if (jsonSchema.type === 'array') { - jsonSchema.schema.items = jsonSchema.items - } - delete jsonSchema.type - delete jsonSchema.properties - delete jsonSchema.items - return jsonSchema +function consumesFormOnly (schema) { + const consumes = schema.consumes + return ( + consumes && + consumes.length === 1 && + (consumes[0] === 'application/x-www-form-urlencoded' || + consumes[0] === 'multipart/form-data') + ) } -function localRefResolve (jsonSchema, externalSchemas) { - if (jsonSchema.type && jsonSchema.properties) { - // for the shorthand querystring/params/headers declaration - const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => { - const required = (jsonSchema.required && jsonSchema.required.indexOf(headers) >= 0) || false - const newProps = Object.assign({}, jsonSchema.properties[headers], { required }) - return Object.assign({}, acc, { [headers]: newProps }) - }, {}) +function getBodyParams (parameters, body, ref) { + const bodyResolved = ref.resolve(body) - return propertiesMap - } + const param = {} + param.name = 'body' + param.in = 'body' + param.schema = bodyResolved + parameters.push(param) +} - // $ref is in the format: #/definitions// - const localReference = jsonSchema.$ref.split('/')[2] - return localRefResolve(externalSchemas[localReference], externalSchemas) +function getCommonParams (container, parameters, schema, ref, sharedSchemas) { + const resolved = ref.resolve(schema) + const add = plainJsonObjectToSwagger2(container, resolved, sharedSchemas) + add.forEach(_ => parameters.push(_)) } -function stripBasePathByServers (path, servers) { - servers = Array.isArray(servers) ? servers : [] - servers.forEach(function (server) { - const basePath = new URL(server.url).pathname - if (path.startsWith(basePath) && basePath !== '/') { - path = path.replace(basePath, '') +// https://swagger.io/docs/specification/2-0/describing-responses/ +function generateResponse (fastifyResponseJson, ref) { + // if the user does not provided an out schema + if (!fastifyResponseJson) { + return { 200: { description: 'Default Response' } } + } + + const responsesContainer = {} + + Object.keys(fastifyResponseJson).forEach(key => { + // 2xx is not supported by swagger + + const rawJsonSchema = fastifyResponseJson[key] + const resolved = ref.resolve(rawJsonSchema) + + responsesContainer[key] = { + schema: resolved, + description: rawJsonSchema.description || 'Default Response' } }) - return path + + return responsesContainer } module.exports = { - addHook, - formatParamUrl, consumesFormOnly, plainJsonObjectToSwagger2, - swagger2ParametersToOpenapi3, - localRefResolve, - stripBasePathByServers + getBodyParams, + getCommonParams, + generateResponse } From 66b0fcacdc2187c58cbc54e279981d31808b99c8 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 13:26:39 +0800 Subject: [PATCH 35/46] feat: generate response according to produces --- lib/openapi.js | 2 +- lib/openapiUtil.js | 17 ++++++++------ test/openapi.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index 6f3dd740..f2855faa 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -182,7 +182,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } } - swaggerMethod.responses = generateResponse(schema ? schema.response : null, ref) + swaggerMethod.responses = generateResponse(schema ? schema.response : null, schema ? schema.produces : null, ref) swaggerObject.paths[url] = swaggerRoute } diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js index d4aef95a..a1d4c57b 100644 --- a/lib/openapiUtil.js +++ b/lib/openapiUtil.js @@ -38,8 +38,7 @@ function getCommonParams (container, parameters, schema, ref, sharedSchema) { add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) } -// https://swagger.io/docs/specification/2-0/describing-responses/ -function generateResponse (fastifyResponseJson, ref) { +function generateResponse (fastifyResponseJson, produces, ref) { // if the user does not provided an out schema if (!fastifyResponseJson) { return { 200: { description: 'Default Response' } } @@ -51,14 +50,18 @@ function generateResponse (fastifyResponseJson, ref) { const rawJsonSchema = fastifyResponseJson[key] const resolved = ref.resolve(rawJsonSchema) - const content = { - 'application/json': {} - } + const content = {} - content['application/json'] = { - schema: resolved + if ((Array.isArray(produces) && produces.length === 0) || typeof produces === 'undefined') { + produces = ['application/json'] } + produces.forEach((produce) => { + content[produce] = { + schema: resolved + } + }) + responsesContainer[key] = { content, description: rawJsonSchema.description || 'Default Response' diff --git a/test/openapi.js b/test/openapi.js index 7fdb29ef..4a77f4d2 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -154,6 +154,24 @@ const opts8 = { } } +const opts9 = { + schema: { + produces: ['*/*'], + response: { + 200: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + } +} + test('fastify.swagger should return a valid swagger object', t => { t.plan(3) const fastify = Fastify() @@ -444,6 +462,43 @@ test('route meta info', t => { }) }) +test('route with produces', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', opts9, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.responses[200].content, { + '*/*': { + schema: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + }) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { t.plan(3) const fastify = Fastify() From 85f5136231391eef7b6a18e0236b8ace3b57f5ba Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 13:48:51 +0800 Subject: [PATCH 36/46] feat: use package json as default info --- examples/test-package.json | 4 +--- lib/dynamicUtil.js | 13 ++++++++++++- lib/openapi.js | 14 +++----------- lib/swagger.js | 14 +++----------- test/swagger.js | 6 ++++-- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/examples/test-package.json b/examples/test-package.json index 05e6f1d1..0967ef42 100644 --- a/examples/test-package.json +++ b/examples/test-package.json @@ -1,3 +1 @@ -{ - "version": "3.1.0" -} +{} diff --git a/lib/dynamicUtil.js b/lib/dynamicUtil.js index e279ec9b..f22533af 100644 --- a/lib/dynamicUtil.js +++ b/lib/dynamicUtil.js @@ -1,5 +1,7 @@ 'use strict' +const fs = require('fs') +const path = require('path') const Ref = require('json-schema-resolver') function addHook (fastify) { @@ -60,8 +62,17 @@ function formatParamUrl (url) { } } +function readPackageJson (done) { + try { + return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return done(err) + } +} + module.exports = { addHook, resolveSwaggerFunction, - formatParamUrl + formatParamUrl, + readPackageJson } diff --git a/lib/openapi.js b/lib/openapi.js index f2855faa..e41d0e8f 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -1,9 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl } = require('./dynamicUtil') +const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { getBodyParams, getCommonParams, generateResponse, stripBasePathByServers } = require('./openapiUtil') module.exports = function (opts, routes, Ref, cache, done) { @@ -34,13 +32,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } const swaggerObject = {} - let pkg - - try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) - } catch (err) { - return done(err) - } + const pkg = readPackageJson(done) // Base Openapi info // this info is displayed in the swagger file @@ -50,7 +42,7 @@ module.exports = function (opts, routes, Ref, cache, done) { swaggerObject.info = info } else { swaggerObject.info = { - version: '1.0.0', + version: pkg.version || '1.0.0', title: pkg.name || '' } } diff --git a/lib/swagger.js b/lib/swagger.js index 09e392ce..4a6d0afb 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -1,9 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') const yaml = require('js-yaml') -const { formatParamUrl } = require('./dynamicUtil') +const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { consumesFormOnly, getBodyParams, getCommonParams, generateResponse } = require('./swaggerUtil') module.exports = function (opts, routes, Ref, cache, done) { @@ -39,13 +37,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } const swaggerObject = {} - let pkg - - try { - pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) - } catch (err) { - return done(err) - } + const pkg = readPackageJson(done) // Base swagger info // this info is displayed in the swagger file @@ -55,7 +47,7 @@ module.exports = function (opts, routes, Ref, cache, done) { swaggerObject.info = info } else { swaggerObject.info = { - version: '1.0.0', + version: pkg.version || '1.0.0', title: pkg.name || '' } } diff --git a/test/swagger.js b/test/swagger.js index 72fde412..486768bb 100644 --- a/test/swagger.js +++ b/test/swagger.js @@ -6,6 +6,7 @@ const Fastify = require('fastify') const Swagger = require('swagger-parser') const yaml = require('js-yaml') const fastifySwagger = require('../index') +const { readPackageJson } = require('../lib/dynamicUtil') const swaggerInfo = { swagger: { @@ -209,8 +210,9 @@ test('fastify.swagger should default info properties', t => { t.error(err) const swaggerObject = fastify.swagger() - t.equal(swaggerObject.info.title, 'fastify-swagger') - t.equal(swaggerObject.info.version, '1.0.0') + const pkg = readPackageJson(function () {}) + t.equal(swaggerObject.info.title, pkg.name) + t.equal(swaggerObject.info.version, pkg.version) }) }) From 18a31f115442faba83c3e871cbb6d4cb7504c508 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 14:04:11 +0800 Subject: [PATCH 37/46] refactor: better cache name --- lib/openapi.js | 54 +++++++++++++++++++++++++------------------------- lib/swagger.js | 8 ++++---- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index e41d0e8f..1edddfb6 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -26,58 +26,58 @@ module.exports = function (opts, routes, Ref, cache, done) { return function (opts) { if (opts && opts.yaml) { - if (cache.swaggerString) return cache.swaggerString + if (cache.string) return cache.string } else { - if (cache.swaggerObject) return cache.swaggerObject + if (cache.object) return cache.object } - const swaggerObject = {} + const openapiObject = {} const pkg = readPackageJson(done) // Base Openapi info // this info is displayed in the swagger file // in the same order as here - swaggerObject.openapi = '3.0.3' + openapiObject.openapi = '3.0.3' if (info) { - swaggerObject.info = info + openapiObject.info = info } else { - swaggerObject.info = { + openapiObject.info = { version: pkg.version || '1.0.0', title: pkg.name || '' } } if (servers) { - swaggerObject.servers = servers + openapiObject.servers = servers } if (components) { - swaggerObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) + openapiObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) } else { - swaggerObject.components = { schemas: {} } + openapiObject.components = { schemas: {} } } if (tags) { - swaggerObject.tags = tags + openapiObject.tags = tags } if (externalDocs) { - swaggerObject.externalDocs = externalDocs + openapiObject.externalDocs = externalDocs } for (const [key, value] of extensions) { - swaggerObject[key] = value + openapiObject[key] = value } ref = Ref() - swaggerObject.components.schemas = { - ...swaggerObject.components.schemas, + openapiObject.components.schemas = { + ...openapiObject.components.schemas, ...(ref.definitions().definitions) } // 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 - Object.values(swaggerObject.components.schemas) + Object.values(openapiObject.components.schemas) .forEach(_ => { delete _.$id }) - swaggerObject.paths = {} + openapiObject.paths = {} for (const route of routes) { const schema = transform @@ -93,11 +93,11 @@ module.exports = function (opts, routes, Ref, cache, done) { } const path = stripBasePath - ? stripBasePathByServers(route.url, swaggerObject.servers) + ? stripBasePathByServers(route.url, openapiObject.servers) : route.url const url = formatParamUrl(path) - const swaggerRoute = swaggerObject.paths[url] || {} + const swaggerRoute = openapiObject.paths[url] || {} const swaggerMethod = {} const parameters = [] @@ -133,7 +133,7 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.querystring) { - getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.components.schemas) + getCommonParams('query', parameters, schema.querystring, ref, openapiObject.components.schemas) } if (schema.body) { @@ -144,11 +144,11 @@ module.exports = function (opts, routes, Ref, cache, done) { } if (schema.params) { - getCommonParams('path', parameters, schema.params, ref, swaggerObject.components.schemas) + getCommonParams('path', parameters, schema.params, ref, openapiObject.components.schemas) } if (schema.headers) { - getCommonParams('header', parameters, schema.headers, ref, swaggerObject.components.schemas) + getCommonParams('header', parameters, schema.headers, ref, openapiObject.components.schemas) } if (parameters.length) { @@ -176,16 +176,16 @@ module.exports = function (opts, routes, Ref, cache, done) { swaggerMethod.responses = generateResponse(schema ? schema.response : null, schema ? schema.produces : null, ref) - swaggerObject.paths[url] = swaggerRoute + openapiObject.paths[url] = swaggerRoute } if (opts && opts.yaml) { - const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) - cache.swaggerString = swaggerString - return swaggerString + const openapiString = yaml.safeDump(openapiObject, { skipInvalid: true }) + cache.string = openapiString + return openapiString } - cache.swaggerObject = swaggerObject - return swaggerObject + cache.object = openapiObject + return openapiObject } } diff --git a/lib/swagger.js b/lib/swagger.js index 4a6d0afb..ed979d4d 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -31,9 +31,9 @@ module.exports = function (opts, routes, Ref, cache, done) { return function (opts) { if (opts && opts.yaml) { - if (cache.swaggerString) return cache.swaggerString + if (cache.string) return cache.string } else { - if (cache.swaggerObject) return cache.swaggerObject + if (cache.object) return cache.object } const swaggerObject = {} @@ -195,11 +195,11 @@ module.exports = function (opts, routes, Ref, cache, done) { if (opts && opts.yaml) { const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) - cache.swaggerString = swaggerString + cache.string = swaggerString return swaggerString } - cache.swaggerObject = swaggerObject + cache.object = swaggerObject return swaggerObject } } From f291f315f05ff39660bebf199c545e520643c5b1 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 14:14:23 +0800 Subject: [PATCH 38/46] chore: add comment to address different issue --- lib/dynamicUtil.js | 4 ++++ lib/openapi.js | 3 +++ lib/swagger.js | 3 +++ 3 files changed, 10 insertions(+) diff --git a/lib/dynamicUtil.js b/lib/dynamicUtil.js index f22533af..e2ca40f5 100644 --- a/lib/dynamicUtil.js +++ b/lib/dynamicUtil.js @@ -15,6 +15,9 @@ function addHook (fastify) { fastify.addHook('onRegister', async (instance) => { // we need to wait the ready event to get all the .getSchemas() // otherwise it will be empty + // TODO: better handle for schemaId + // when schemaId is the same in difference instance + // the latter will lost instance.addHook('onReady', (done) => { const allSchemas = instance.getSchemas() for (const schemaId of Object.keys(allSchemas)) { @@ -30,6 +33,7 @@ function addHook (fastify) { routes, Ref () { const externalSchemas = Array.from(sharedSchemasMap.values()) + // TODO: hardcoded applicationUri is not a ideal solution return Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) } } diff --git a/lib/openapi.js b/lib/openapi.js index 1edddfb6..a628fac5 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -4,6 +4,9 @@ const yaml = require('js-yaml') const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { getBodyParams, getCommonParams, generateResponse, stripBasePathByServers } = require('./openapiUtil') +// TODO: refactor for readability, reference in below +// https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two +// https://www.informit.com/articles/article.aspx?p=1398607 module.exports = function (opts, routes, Ref, cache, done) { let ref diff --git a/lib/swagger.js b/lib/swagger.js index ed979d4d..b7f6c423 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -4,6 +4,9 @@ const yaml = require('js-yaml') const { formatParamUrl, readPackageJson } = require('./dynamicUtil') const { consumesFormOnly, getBodyParams, getCommonParams, generateResponse } = require('./swaggerUtil') +// TODO: refactor for readability, reference in below +// https://en.wikipedia.org/wiki/The_Magical_Number_Seven,_Plus_or_Minus_Two +// https://www.informit.com/articles/article.aspx?p=1398607 module.exports = function (opts, routes, Ref, cache, done) { let ref From 4edb67b4412d643f5b823b871f76a3f45c675f08 Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 12 Jan 2021 18:49:52 +0800 Subject: [PATCH 39/46] feat: add oneOf, allOf, anyOf support in query, header, path, formData --- lib/swaggerUtil.js | 9 +++++++++ test/openapi.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/lib/swaggerUtil.js b/lib/swaggerUtil.js index 94d70dba..4bb9db4f 100644 --- a/lib/swaggerUtil.js +++ b/lib/swaggerUtil.js @@ -12,6 +12,15 @@ function localRefResolve (jsonSchema, externalSchemas) { return propertiesMap } + // for oneOf, anyOf, allOf support in querystring/params/headers + if (jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf) { + const schemas = jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf + return schemas.reduce(function (acc, schema) { + const json = localRefResolve(schema, externalSchemas) + return { ...acc, ...json } + }, {}) + } + // $ref is in the format: #/definitions// const localReference = jsonSchema.$ref.split('/')[2] return localRefResolve(externalSchemas[localReference], externalSchemas) diff --git a/test/openapi.js b/test/openapi.js index 4a77f4d2..b36117da 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -172,6 +172,21 @@ const opts9 = { } } +const opts10 = { + schema: { + querystring: { + allOf: [ + { + type: 'object', + properties: { + foo: { type: 'string' } + } + } + ] + } + } +} + test('fastify.swagger should return a valid swagger object', t => { t.plan(3) const fastify = Fastify() @@ -499,6 +514,38 @@ test('route with produces', t => { }) }) +test('route oneOf, anyOf, allOf', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', opts10, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.parameters, [ + { + required: false, + in: 'query', + name: 'foo', + schema: { + type: 'string' + } + } + ]) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { t.plan(3) const fastify = Fastify() From c0439b61a5d17307fc523fa7121dfdf3e2bdbc5a Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 14 Jan 2021 18:46:13 +0800 Subject: [PATCH 40/46] refactor: better argument name --- lib/openapiUtil.js | 4 ++-- lib/swaggerUtil.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js index a1d4c57b..7fc660d9 100644 --- a/lib/openapiUtil.js +++ b/lib/openapiUtil.js @@ -34,8 +34,8 @@ function getBodyParams (parameters, body, consumes, ref) { function getCommonParams (container, parameters, schema, ref, sharedSchema) { const resolved = ref.resolve(schema) - const add = plainJsonObjectToSwagger2(container, resolved, sharedSchema) - add.forEach(_ => parameters.push(swagger2ParametersToOpenapi3(_))) + const add = plainJsonObjectToOpenapi3(container, resolved, sharedSchema) + add.forEach(openapiSchema => parameters.push(openapiSchema)) } function generateResponse (fastifyResponseJson, produces, ref) { diff --git a/lib/swaggerUtil.js b/lib/swaggerUtil.js index 4bb9db4f..e057abed 100644 --- a/lib/swaggerUtil.js +++ b/lib/swaggerUtil.js @@ -103,7 +103,7 @@ function getBodyParams (parameters, body, ref) { function getCommonParams (container, parameters, schema, ref, sharedSchemas) { const resolved = ref.resolve(schema) const add = plainJsonObjectToSwagger2(container, resolved, sharedSchemas) - add.forEach(_ => parameters.push(_)) + add.forEach(swaggerSchema => parameters.push(swaggerSchema)) } // https://swagger.io/docs/specification/2-0/describing-responses/ From 76f5858d0849213b5828ec166e8690f1dd513b93 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 14 Jan 2021 18:52:08 +0800 Subject: [PATCH 41/46] feat: add cookies schema support for openapi 3 --- lib/openapi.js | 6 +++++ lib/openapiUtil.js | 63 +++++++++++++++++++++++++++++++++++----------- lib/swaggerUtil.js | 1 + test/openapi.js | 43 +++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/lib/openapi.js b/lib/openapi.js index a628fac5..3a4d1bed 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -154,6 +154,12 @@ module.exports = function (opts, routes, Ref, cache, done) { getCommonParams('header', parameters, schema.headers, ref, openapiObject.components.schemas) } + // TODO: need to documentation, we treat it same as the querystring + // fastify do not support cookies schema in first place + if (schema.cookies) { + getCommonParams('cookie', parameters, schema.cookies, ref, openapiObject.components.schemas) + } + if (parameters.length) { swaggerMethod.parameters = parameters } diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js index 7fc660d9..b9dfce98 100644 --- a/lib/openapiUtil.js +++ b/lib/openapiUtil.js @@ -1,21 +1,56 @@ 'use strict' const { URL } = require('url') -const { plainJsonObjectToSwagger2 } = require('./swaggerUtil') - -function swagger2ParametersToOpenapi3 (jsonSchema) { - jsonSchema.schema = {} - jsonSchema.schema.type = jsonSchema.type - if (jsonSchema.type === 'object') { - jsonSchema.schema.properties = jsonSchema.properties - } - if (jsonSchema.type === 'array') { - jsonSchema.schema.items = jsonSchema.items +const { localRefResolve } = require('./swaggerUtil') + +function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas) { + const obj = localRefResolve(jsonSchema, externalSchemas) + let toSwaggerProp + switch (container) { + case 'cookie': + case 'query': + toSwaggerProp = function (propertyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = propertyName + return jsonSchemaElement + } + break + case 'path': + toSwaggerProp = function (propertyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = propertyName + jsonSchemaElement.required = true + return jsonSchemaElement + } + break + case 'header': + toSwaggerProp = function (propertyName, jsonSchemaElement) { + return { + in: 'header', + name: propertyName, + required: jsonSchemaElement.required, + description: jsonSchemaElement.description, + type: jsonSchemaElement.type + } + } + break } - delete jsonSchema.type - delete jsonSchema.properties - delete jsonSchema.items - return jsonSchema + + return Object.keys(obj).map((propKey) => { + const jsonSchema = toSwaggerProp(propKey, obj[propKey]) + jsonSchema.schema = {} + jsonSchema.schema.type = jsonSchema.type + if (jsonSchema.type === 'object') { + jsonSchema.schema.properties = jsonSchema.properties + } + if (jsonSchema.type === 'array') { + jsonSchema.schema.items = jsonSchema.items + } + delete jsonSchema.type + delete jsonSchema.properties + delete jsonSchema.items + return jsonSchema + }) } function getBodyParams (parameters, body, consumes, ref) { diff --git a/lib/swaggerUtil.js b/lib/swaggerUtil.js index e057abed..b861e4b7 100644 --- a/lib/swaggerUtil.js +++ b/lib/swaggerUtil.js @@ -132,6 +132,7 @@ function generateResponse (fastifyResponseJson, ref) { module.exports = { consumesFormOnly, + localRefResolve, plainJsonObjectToSwagger2, getBodyParams, getCommonParams, diff --git a/test/openapi.js b/test/openapi.js index b36117da..8c9a7735 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -187,6 +187,17 @@ const opts10 = { } } +const opts11 = { + schema: { + cookies: { + type: 'object', + properties: { + bar: { type: 'string' } + } + } + } +} + test('fastify.swagger should return a valid swagger object', t => { t.plan(3) const fastify = Fastify() @@ -546,6 +557,38 @@ test('route oneOf, anyOf, allOf', t => { }) }) +test('route cookies schema', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + + fastify.get('/', opts11, () => {}) + + fastify.ready(err => { + t.error(err) + const swaggerObject = fastify.swagger() + Swagger.validate(swaggerObject) + .then(function (api) { + const definedPath = api.paths['/'].get + t.ok(definedPath) + t.same(definedPath.parameters, [ + { + required: false, + in: 'quecookiery', + name: 'bar', + schema: { + type: 'string' + } + } + ]) + }) + .catch(function (err) { + t.fail(err) + }) + }) +}) + test('parses form parameters when all api consumes application/x-www-form-urlencoded', t => { t.plan(3) const fastify = Fastify() From 30ec49e56f8bdc0885a7df6e4650c63b6ee25db8 Mon Sep 17 00:00:00 2001 From: KaKa Date: Thu, 14 Jan 2021 18:52:52 +0800 Subject: [PATCH 42/46] fix: typo --- test/openapi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/openapi.js b/test/openapi.js index 8c9a7735..d4125833 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -575,7 +575,7 @@ test('route cookies schema', t => { t.same(definedPath.parameters, [ { required: false, - in: 'quecookiery', + in: 'cookie', name: 'bar', schema: { type: 'string' From b405dd42cb6dbd9ae2cd174f5b4d56d331b8ca9d Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 19 Jan 2021 00:37:56 +0800 Subject: [PATCH 43/46] chore: add openapi typings --- index.d.ts | 11 ++++++----- test/types/types.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index 6299c2d9..c1908a0c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,6 @@ -import { FastifyPlugin } from 'fastify'; +import { FastifyPluginCallback } from 'fastify'; +import { OpenAPI, OpenAPIV2, OpenAPIV3 } from 'openapi-types'; import * as SwaggerSchema from 'swagger-schema-official'; -import { OpenAPIV2, OpenAPIV3 } from 'openapi-types'; declare module 'fastify' { interface FastifyInstance { @@ -30,7 +30,7 @@ declare module 'fastify' { } } -export const fastifySwagger: FastifyPlugin; +export const fastifySwagger: FastifyPluginCallback; export type SwaggerOptions = (FastifyStaticSwaggerOptions | FastifyDynamicSwaggerOptions); export interface FastifySwaggerOptions { @@ -49,7 +49,8 @@ export interface FastifySwaggerOptions { export interface FastifyDynamicSwaggerOptions extends FastifySwaggerOptions { mode?: 'dynamic'; - swagger?: Partial; + swagger?: Partial; + openapi?: Partial hiddenTag?: string; /** * Strips matching base path from routes in documentation @@ -64,7 +65,7 @@ export interface FastifyDynamicSwaggerOptions extends FastifySwaggerOptions { export interface StaticPathSpec { path: string; - postProcessor?: (spec: SwaggerSchema.Spec) => SwaggerSchema.Spec; + postProcessor?: (spec: OpenAPI.Document) => OpenAPI.Document; baseDir: string; } diff --git a/test/types/types.test.ts b/test/types/types.test.ts index 8b3a9905..72eb27cf 100644 --- a/test/types/types.test.ts +++ b/test/types/types.test.ts @@ -87,3 +87,32 @@ app .ready(err => { app.swagger(); }); + +app + .register(fastifySwagger, { + openapi: { + info: { + title: "Test openapi", + description: "testing the fastify swagger api", + version: "0.1.0", + }, + servers: [{ url: "http://localhost" }], + externalDocs: { + url: "https://swagger.io", + description: "Find more info here", + }, + components: { + schemas: {}, + securitySchemes: { + apiKey: { + type: "apiKey", + name: "apiKey", + in: "header", + }, + }, + }, + }, + }) + .ready((err) => { + app.swagger(); + }); \ No newline at end of file From 33bed4333d6b3612396ad92bf3f8c0674521a08e Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 19 Jan 2021 00:51:18 +0800 Subject: [PATCH 44/46] docs: add openapi support docs --- README.md | 21 ++++++- examples/dynamic-openapi.js | 70 +++++++++++++++++++++ examples/{dynamic.js => dynamic-swagger.js} | 0 test/static.js | 2 +- 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 examples/dynamic-openapi.js rename examples/{dynamic.js => dynamic-swagger.js} (100%) diff --git a/README.md b/README.md index 831008b7..b98ce137 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ fastify.ready(err => { `dynamic` mode is the default one, if you use the plugin this way - swagger specification would be gathered from your routes definitions. ```js { + // swagger 2.0 options swagger: { info: { title: String, @@ -136,14 +137,29 @@ fastify.ready(err => { produces: [ String ], tags: [ Object ], securityDefinitions: Object + }, + // openapi 3.0.3 options + openapi: { + info: { + title: String, + description: String, + version: String, + }, + externalDocs: Object, + servers: [ Object ], + components: Object, + security: [ Object ], + tags: [ Object ] } } ``` *All the above parameters are optional.* - You can use all the properties of the [swagger specification](https://swagger.io/specification/), if you find anything missing, please open an issue or a pr! + You can use all the properties of the [swagger specification](https://swagger.io/specification/v2/) and [openapi specification](https://swagger.io/specification/), if you find anything missing, please open an issue or a pr! + + *Please note that when you specify `openapi` option, it will take precedence and ignore the `swagger` option. - Example of the `fastify-swagger` usage in the `dynamic` mode is available [here](examples/dynamic.js). + Example of the `fastify-swagger` usage in the `dynamic` mode, `swagger` option is available [here](examples/dynamic-swagger.js) and `openapi` option is avaiable [here](examples/dynamic-openapi.js). ##### options @@ -154,6 +170,7 @@ fastify.ready(err => { | hiddenTag | X-HIDDEN | Tag to control hiding of routes. | | stripBasePath | true | Strips base path from routes in docs. | | swagger | {} | Swagger configuration. | + | openapi | {} | Openapi configuration. | | transform | null | Transform method for schema. | ##### static diff --git a/examples/dynamic-openapi.js b/examples/dynamic-openapi.js new file mode 100644 index 00000000..a1a9f9ee --- /dev/null +++ b/examples/dynamic-openapi.js @@ -0,0 +1,70 @@ +'use strict' + +const fastify = require('fastify')() + +fastify.register(require('../index'), { + openapi: { + info: { + title: 'Test swagger', + description: 'testing the fastify swagger api', + version: '0.1.0' + }, + servers: [{ + url: 'http://localhost' + }], + components: { + securitySchemes: { + apiKey: { + type: 'apiKey', + name: 'apiKey', + in: 'header' + } + } + } + }, + exposeRoute: true +}) + +fastify.put('/some-route/:id', { + schema: { + description: 'post some data', + tags: ['user', 'code'], + summary: 'qwerty', + security: [{ apiKey: [] }], + params: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'user id' + } + } + }, + body: { + type: 'object', + properties: { + hello: { type: 'string' }, + obj: { + type: 'object', + properties: { + some: { type: 'string' } + } + } + } + }, + response: { + 201: { + description: 'Succesful response', + type: 'object', + properties: { + hello: { type: 'string' } + } + } + } + } +}, (req, reply) => { reply.send({ hello: `Hello ${req.body.hello}` }) }) + +fastify.listen(3000, err => { + if (err) throw err + console.log('listening') +}) diff --git a/examples/dynamic.js b/examples/dynamic-swagger.js similarity index 100% rename from examples/dynamic.js rename to examples/dynamic-swagger.js diff --git a/test/static.js b/test/static.js index 2f335fac..efe13b22 100644 --- a/test/static.js +++ b/test/static.js @@ -298,7 +298,7 @@ test('/documentation/:file should serve static file from the location of main sp fastify.inject({ method: 'GET', - url: '/documentation/dynamic.js' + url: '/documentation/dynamic-swagger.js' }, (err, res) => { t.error(err) t.strictEqual(res.statusCode, 200) From 03777ad4a7ebabb15fe3098b052529adbcf5eb6b Mon Sep 17 00:00:00 2001 From: KaKa Date: Tue, 19 Jan 2021 01:38:02 +0800 Subject: [PATCH 45/46] docs: update as suggestion --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b98ce137..f83ca999 100644 --- a/README.md +++ b/README.md @@ -139,25 +139,25 @@ fastify.ready(err => { securityDefinitions: Object }, // openapi 3.0.3 options - openapi: { - info: { - title: String, - description: String, - version: String, - }, - externalDocs: Object, - servers: [ Object ], - components: Object, - security: [ Object ], - tags: [ Object ] - } + // openapi: { + // info: { + // title: String, + // description: String, + // version: String, + // }, + // externalDocs: Object, + // servers: [ Object ], + // components: Object, + // security: [ Object ], + // tags: [ Object ] + // } } ``` *All the above parameters are optional.* You can use all the properties of the [swagger specification](https://swagger.io/specification/v2/) and [openapi specification](https://swagger.io/specification/), if you find anything missing, please open an issue or a pr! - *Please note that when you specify `openapi` option, it will take precedence and ignore the `swagger` option. + fastify-swagger will generate Swagger v2 by default. If you pass the `opeanapi` option it will generate OpenAPI instead. Example of the `fastify-swagger` usage in the `dynamic` mode, `swagger` option is available [here](examples/dynamic-swagger.js) and `openapi` option is avaiable [here](examples/dynamic-openapi.js). From 35a9be8a793858c2094685ad1b8d45e7f7fef844 Mon Sep 17 00:00:00 2001 From: KaKa Date: Mon, 25 Jan 2021 13:37:42 +0800 Subject: [PATCH 46/46] fix: openapi --- lib/openapiUtil.js | 22 ++++++++++++++++++---- test/openapi.js | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js index b9dfce98..b5ef1310 100644 --- a/lib/openapiUtil.js +++ b/lib/openapiUtil.js @@ -3,8 +3,22 @@ const { URL } = require('url') const { localRefResolve } = require('./swaggerUtil') +// TODO: improvement needed, maybe remove the depend of json-schema-resolver +function defToComponent (jsonSchema) { + if (typeof jsonSchema === 'object') { + Object.keys(jsonSchema).forEach(function (key) { + if (key === '$ref') { + jsonSchema[key] = jsonSchema[key].replace('definitions', 'components/schemas') + } else { + jsonSchema[key] = defToComponent(jsonSchema[key]) + } + }) + } + return jsonSchema +} + function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas) { - const obj = localRefResolve(jsonSchema, externalSchemas) + const obj = defToComponent(localRefResolve(jsonSchema, externalSchemas)) let toSwaggerProp switch (container) { case 'cookie': @@ -54,7 +68,7 @@ function plainJsonObjectToOpenapi3 (container, jsonSchema, externalSchemas) { } function getBodyParams (parameters, body, consumes, ref) { - const bodyResolved = ref.resolve(body) + const bodyResolved = defToComponent(ref.resolve(body)) if ((Array.isArray(consumes) && consumes.length === 0) || typeof consumes === 'undefined') { consumes = ['application/json'] @@ -68,7 +82,7 @@ function getBodyParams (parameters, body, consumes, ref) { } function getCommonParams (container, parameters, schema, ref, sharedSchema) { - const resolved = ref.resolve(schema) + const resolved = defToComponent(ref.resolve(schema)) const add = plainJsonObjectToOpenapi3(container, resolved, sharedSchema) add.forEach(openapiSchema => parameters.push(openapiSchema)) } @@ -83,7 +97,7 @@ function generateResponse (fastifyResponseJson, produces, ref) { Object.keys(fastifyResponseJson).forEach(key => { const rawJsonSchema = fastifyResponseJson[key] - const resolved = ref.resolve(rawJsonSchema) + const resolved = defToComponent(ref.resolve(rawJsonSchema)) const content = {} diff --git a/test/openapi.js b/test/openapi.js index d4125833..7e162ee8 100644 --- a/test/openapi.js +++ b/test/openapi.js @@ -776,3 +776,30 @@ test('route with multiple method', t => { }) }) }) + +test('openapi $ref', t => { + t.plan(3) + const fastify = Fastify() + + fastify.register(fastifySwagger, swaggerInfo) + fastify.register(function (instance, _, done) { + instance.addSchema({ $id: 'Order', type: 'object', properties: { id: { type: 'integer' } } }) + instance.post('/', { schema: { body: { $ref: 'Order#' }, response: { 200: { $ref: 'Order#' } } } }, () => {}) + done() + }) + + fastify.ready(err => { + t.error(err) + + const swaggerObject = fastify.swagger() + t.is(typeof swaggerObject, 'object') + + Swagger.validate(swaggerObject) + .then(function (api) { + t.pass('valid swagger object') + }) + .catch(function (err) { + t.fail(err) + }) + }) +})