diff --git a/README.md b/README.md index 831008b7..f83ca999 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! + + 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 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/dynamic.js b/dynamic.js deleted file mode 100644 index 53c310b4..00000000 --- a/dynamic.js +++ /dev/null @@ -1,413 +0,0 @@ -'use strict' - -const fs = require('fs') -const path = require('path') -const yaml = require('js-yaml') -const Ref = require('json-schema-resolver') - -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() - }) - }) - - opts = Object.assign({}, { - exposeRoute: false, - hiddenTag: 'X-HIDDEN', - stripBasePath: true, - swagger: {}, - 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 }) - } - - const cache = { - swaggerObject: null, - 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 - } - - const externalSchemas = Array.from(sharedSchemasMap.values()) - - ref = Ref({ clone: true, applicationUri: 'todo.com', externalSchemas }) - 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 - } - } - - 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/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/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/index.d.ts b/index.d.ts index 34f9eb1c..c1908a0c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,5 @@ -import { FastifyPlugin } from 'fastify'; +import { FastifyPluginCallback } from 'fastify'; +import { OpenAPI, OpenAPIV2, OpenAPIV3 } from 'openapi-types'; import * as SwaggerSchema from 'swagger-schema-official'; declare module 'fastify' { @@ -29,7 +30,7 @@ declare module 'fastify' { } } -export const fastifySwagger: FastifyPlugin; +export const fastifySwagger: FastifyPluginCallback; export type SwaggerOptions = (FastifyStaticSwaggerOptions | FastifyDynamicSwaggerOptions); export interface FastifySwaggerOptions { @@ -48,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 @@ -63,12 +65,12 @@ export interface FastifyDynamicSwaggerOptions extends FastifySwaggerOptions { export interface StaticPathSpec { path: string; - postProcessor?: (spec: SwaggerSchema.Spec) => SwaggerSchema.Spec; + postProcessor?: (spec: OpenAPI.Document) => OpenAPI.Document; baseDir: string; } export interface StaticDocumentSpec { - document: string; + document: OpenAPIV2.Document | OpenAPIV3.Document; } export interface FastifyStaticSwaggerOptions extends FastifySwaggerOptions { @@ -76,4 +78,4 @@ export interface FastifyStaticSwaggerOptions extends FastifySwaggerOptions { specification: StaticPathSpec | StaticDocumentSpec; } -export default fastifySwagger; \ No newline at end of file +export default fastifySwagger; 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/lib/dynamic.js b/lib/dynamic.js new file mode 100644 index 00000000..e393cedf --- /dev/null +++ b/lib/dynamic.js @@ -0,0 +1,31 @@ +'use strict' + +const { addHook, resolveSwaggerFunction } = require('./dynamicUtil') + +module.exports = function (fastify, opts, done) { + const { routes, Ref } = addHook(fastify) + + opts = Object.assign({}, { + exposeRoute: false, + hiddenTag: 'X-HIDDEN', + stripBasePath: true, + openapi: {}, + swagger: {}, + transform: null + }, opts || {}) + + if (opts.exposeRoute === true) { + const prefix = opts.routePrefix || '/documentation' + fastify.register(require('./routes'), { prefix }) + } + + const cache = { + object: null, + string: null + } + + 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..e2ca40f5 --- /dev/null +++ b/lib/dynamicUtil.js @@ -0,0 +1,82 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +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 + // 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)) { + if (!sharedSchemasMap.has(schemaId)) { + sharedSchemasMap.set(schemaId, allSchemas[schemaId]) + } + } + done() + }) + }) + + return { + routes, + Ref () { + const externalSchemas = Array.from(sharedSchemasMap.values()) + // TODO: hardcoded applicationUri is not a ideal solution + 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)) + } +} + +function readPackageJson (done) { + try { + return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'))) + } catch (err) { + return done(err) + } +} + +module.exports = { + addHook, + resolveSwaggerFunction, + formatParamUrl, + readPackageJson +} diff --git a/lib/openapi.js b/lib/openapi.js new file mode 100644 index 00000000..3a4d1bed --- /dev/null +++ b/lib/openapi.js @@ -0,0 +1,200 @@ +'use strict' + +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 + + 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.string) return cache.string + } else { + if (cache.object) return cache.object + } + + const openapiObject = {} + const pkg = readPackageJson(done) + + // Base Openapi info + // this info is displayed in the swagger file + // in the same order as here + openapiObject.openapi = '3.0.3' + if (info) { + openapiObject.info = info + } else { + openapiObject.info = { + version: pkg.version || '1.0.0', + title: pkg.name || '' + } + } + if (servers) { + openapiObject.servers = servers + } + if (components) { + openapiObject.components = Object.assign({}, components, { schemas: Object.assign({}, components.schemas) }) + } else { + openapiObject.components = { schemas: {} } + } + if (tags) { + openapiObject.tags = tags + } + if (externalDocs) { + openapiObject.externalDocs = externalDocs + } + + for (const [key, value] of extensions) { + openapiObject[key] = value + } + + ref = Ref() + 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(openapiObject.components.schemas) + .forEach(_ => { delete _.$id }) + + openapiObject.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, openapiObject.servers) + : route.url + const url = formatParamUrl(path) + + const swaggerRoute = openapiObject.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) { + getCommonParams('query', parameters, schema.querystring, ref, openapiObject.components.schemas) + } + + if (schema.body) { + swaggerMethod.requestBody = { + content: {} + } + getBodyParams(swaggerMethod.requestBody.content, schema.body, schema.consumes, ref) + } + + if (schema.params) { + getCommonParams('path', parameters, schema.params, ref, openapiObject.components.schemas) + } + + if (schema.headers) { + 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 + } + + 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 = generateResponse(schema ? schema.response : null, schema ? schema.produces : null, ref) + + openapiObject.paths[url] = swaggerRoute + } + + if (opts && opts.yaml) { + const openapiString = yaml.safeDump(openapiObject, { skipInvalid: true }) + cache.string = openapiString + return openapiString + } + + cache.object = openapiObject + return openapiObject + } +} diff --git a/lib/openapiUtil.js b/lib/openapiUtil.js new file mode 100644 index 00000000..b5ef1310 --- /dev/null +++ b/lib/openapiUtil.js @@ -0,0 +1,139 @@ +'use strict' + +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 = defToComponent(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 + } + + 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) { + const bodyResolved = defToComponent(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 = defToComponent(ref.resolve(schema)) + const add = plainJsonObjectToOpenapi3(container, resolved, sharedSchema) + add.forEach(openapiSchema => parameters.push(openapiSchema)) +} + +function generateResponse (fastifyResponseJson, produces, 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 = defToComponent(ref.resolve(rawJsonSchema)) + + const content = {} + + 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' + } + }) + + 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/routes.js b/lib/routes.js similarity index 90% rename from routes.js rename to lib/routes.js index 8e8c7a95..011055d3 100644 --- a/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', @@ -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 }) @@ -72,7 +72,7 @@ function fastifySwagger (fastify, opts, next) { } }) - next() + done() } module.exports = fastifySwagger diff --git a/static.js b/lib/static.js similarity index 80% rename from static.js rename to lib/static.js index 23adbdb1..a2530f4c 100644 --- a/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 new file mode 100644 index 00000000..b7f6c423 --- /dev/null +++ b/lib/swagger.js @@ -0,0 +1,208 @@ +'use strict' + +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 + + 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.string) return cache.string + } else { + if (cache.object) return cache.object + } + + const swaggerObject = {} + const pkg = readPackageJson(done) + + // 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: pkg.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) { + getCommonParams('query', parameters, schema.querystring, ref, swaggerObject.definitions) + } + + if (schema.body) { + const consumesAllFormOnly = + consumesFormOnly(schema) || consumesFormOnly(swaggerObject) + consumesAllFormOnly + ? getCommonParams('formData', parameters, schema.body, ref, swaggerObject.definitions) + : getBodyParams(parameters, schema.body, ref) + } + + if (schema.params) { + getCommonParams('path', parameters, schema.params, ref, swaggerObject.definitions) + } + + if (schema.headers) { + getCommonParams('header', parameters, schema.headers, ref, swaggerObject.definitions) + } + + 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 = generateResponse(schema ? schema.response : null, ref) + + swaggerObject.paths[url] = swaggerRoute + } + + if (opts && opts.yaml) { + const swaggerString = yaml.safeDump(swaggerObject, { skipInvalid: true }) + cache.string = swaggerString + return swaggerString + } + + cache.object = swaggerObject + return swaggerObject + } +} diff --git a/lib/swaggerUtil.js b/lib/swaggerUtil.js new file mode 100644 index 00000000..b861e4b7 --- /dev/null +++ b/lib/swaggerUtil.js @@ -0,0 +1,140 @@ +'use strict' + +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 }) + }, {}) + + 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) +} + +// 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 (propertyName, jsonSchemaElement) { + jsonSchemaElement.in = container + jsonSchemaElement.name = propertyName + return jsonSchemaElement + } + break + case 'formData': + toSwaggerProp = function (propertyName, jsonSchemaElement) { + delete jsonSchemaElement.$id + jsonSchemaElement.in = container + jsonSchemaElement.name = propertyName + + // 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 (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 + } + + return Object.keys(obj).map((propKey) => { + return toSwaggerProp(propKey, obj[propKey]) + }) +} + +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 getBodyParams (parameters, body, ref) { + const bodyResolved = ref.resolve(body) + + const param = {} + param.name = 'body' + param.in = 'body' + param.schema = bodyResolved + parameters.push(param) +} + +function getCommonParams (container, parameters, schema, ref, sharedSchemas) { + const resolved = ref.resolve(schema) + const add = plainJsonObjectToSwagger2(container, resolved, sharedSchemas) + add.forEach(swaggerSchema => parameters.push(swaggerSchema)) +} + +// 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 responsesContainer +} + +module.exports = { + consumesFormOnly, + localRefResolve, + plainJsonObjectToSwagger2, + getBodyParams, + getCommonParams, + generateResponse +} 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/openapi.js b/test/openapi.js new file mode 100644 index 00000000..7e162ee8 --- /dev/null +++ b/test/openapi.js @@ -0,0 +1,805 @@ +'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 + } +} + +const opts9 = { + schema: { + produces: ['*/*'], + response: { + 200: { + type: 'object', + properties: { + hello: { + description: 'hello', + type: 'string' + } + }, + required: ['hello'] + } + } + } +} + +const opts10 = { + schema: { + querystring: { + allOf: [ + { + type: 'object', + properties: { + foo: { type: 'string' } + } + } + ] + } + } +} + +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() + + 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('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('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('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: 'cookie', + 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() + 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) + }) + }) +}) + +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) + }) + }) +}) diff --git a/test/static.js b/test/static.js index db108093..efe13b22 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 @@ -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) @@ -615,7 +615,45 @@ 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) + } + + fastify.inject( + { + method: 'GET', + url: '/documentation/json' + }, + (err, res) => { + t.error(err) + t.pass('Inserted default package name.') + } + ) +}) + +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) @@ -650,7 +688,45 @@ 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) + } + + fastify.inject( + { + method: 'GET', + url: '/documentation/json' + }, + (err, res) => { + t.error(err) + t.equal(err, null) + } + ) +}) + +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) 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) }) }) 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..72eb27cf 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, @@ -86,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