diff --git a/README.md b/README.md index 7a1e2cc..c91232d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,4 @@ that generates an OpenApi spec .json file to the specified path and with specified options. ### License - -restifyApiGenerate is currently licensed under the MIT license. -specified options. +restifyApiGenerate is currently licensed under the MIT license. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ff1ee4e..2cdeca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "restifyapigenerate", "version": "1.1.0", "license": "MIT", - "dependencies": { - "joi": "17.11.0" - }, "devDependencies": { "eslint": "8.56.0", "eslint-config-prettier": "9.1.0" @@ -84,19 +81,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -165,24 +149,6 @@ "node": ">= 8" } }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -765,18 +731,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/joi": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", - "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", - "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/package.json b/package.json index ed7c9ce..4b6b5c8 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,6 @@ "type": "git", "url": "https://github.com/zone-eu/restify-api-generate.git" }, - "dependencies": { - "joi": "17.11.0" - }, "devDependencies": { "eslint": "8.56.0", "eslint-config-prettier": "9.1.0" diff --git a/restifyOpenapiGenerator.js b/restifyOpenapiGenerator.js index db1b68a..21ba78a 100644 --- a/restifyOpenapiGenerator.js +++ b/restifyOpenapiGenerator.js @@ -1,7 +1,6 @@ 'use strict'; const fs = require('fs'); -const Joi = require('joi'); const structuredCloneWrapper = typeof structuredClone === 'function' ? structuredClone : obj => JSON.parse(JSON.stringify(obj)); @@ -16,373 +15,385 @@ const joiTypeToOpenApiTypeMap = { binary: 'string' }; -function replaceWithRefs(reqBodyData) { - if (reqBodyData.type === 'array') { - const obj = reqBodyData.items; - - replaceWithRefs(obj); - } else if (reqBodyData.type === 'object') { - if (reqBodyData.objectName) { - const objectName = reqBodyData.objectName; - Object.keys(reqBodyData).forEach(key => { - if (key !== '$ref' || key !== 'description') { - delete reqBodyData[key]; +class RestifyApiGenerate { + constructor(joi, dirname) { + this.Joi = joi; + + this.dirname = dirname; + + if (!dirname) { + throw Error('Pass in your __dirname as the second parameter'); + } + } + + replaceWithRefs(reqBodyData) { + if (reqBodyData.type === 'array') { + const obj = reqBodyData.items; + + this.replaceWithRefs(obj); + } else if (reqBodyData.type === 'object') { + if (reqBodyData.objectName) { + const objectName = reqBodyData.objectName; + Object.keys(reqBodyData).forEach(key => { + if (key !== '$ref' || key !== 'description') { + delete reqBodyData[key]; + } + }); + reqBodyData.$ref = `#/components/schemas/${objectName}`; + } else { + for (const key in reqBodyData.properties) { + this.replaceWithRefs(reqBodyData.properties[key]); } - }); - reqBodyData.$ref = `#/components/schemas/${objectName}`; - } else { - for (const key in reqBodyData.properties) { - replaceWithRefs(reqBodyData.properties[key]); } - } - } else if (reqBodyData.type === 'alternatives') { - for (const obj in reqBodyData.oneOf) { - replaceWithRefs(obj); + } else if (reqBodyData.type === 'alternatives') { + for (const obj in reqBodyData.oneOf) { + this.replaceWithRefs(obj); + } } } -} -function parseComponetsDecoupled(component, components) { - if (component.type === 'array') { - const obj = structuredCloneWrapper(component.items); // copy + parseComponetsDecoupled(component, components) { + if (component.type === 'array') { + const obj = structuredCloneWrapper(component.items); // copy - if (obj.objectName) { - for (const key in obj.properties) { - parseComponetsDecoupled(obj.properties[key], components); - } + if (obj.objectName) { + for (const key in obj.properties) { + this.parseComponetsDecoupled(obj.properties[key], components); + } - // in case the Array itself is marked as a separate object > + // in case the Array itself is marked as a separate object > + const objectName = obj.objectName; + components[objectName] = obj; + delete components[objectName].objectName; + // ^ + } + } else if (component.type === 'object') { + const obj = structuredCloneWrapper(component); // copy const objectName = obj.objectName; - components[objectName] = obj; - delete components[objectName].objectName; - // ^ - } - } else if (component.type === 'object') { - const obj = structuredCloneWrapper(component); // copy - const objectName = obj.objectName; - for (const key in obj.properties) { - parseComponetsDecoupled(obj.properties[key], components); - } + for (const key in obj.properties) { + this.parseComponetsDecoupled(obj.properties[key], components); + } - if (objectName) { - components[objectName] = obj; - delete components[objectName].objectName; - } - } else if (component.oneOf) { - // Joi object is of 'alternatives' types - for (const obj in component.oneOf) { - parseComponetsDecoupled({ ...obj }, components); + if (objectName) { + components[objectName] = obj; + delete components[objectName].objectName; + } + } else if (component.oneOf) { + // Joi object is of 'alternatives' types + for (const obj in component.oneOf) { + this.parseComponetsDecoupled({ ...obj }, components); + } } } -} -/** - * Parse Joi Objects - */ -function parseJoiObject(path, joiObject, requestBodyProperties) { - if (joiObject.type === 'object') { - const fieldsMap = joiObject._ids._byKey; - - const data = { - type: joiObject.type, - description: joiObject._flags.description, - properties: {}, - required: [] - }; + /** + * Parse Joi Objects + */ + parseJoiObject(path, joiObject, requestBodyProperties) { + if (joiObject.type === 'object') { + const fieldsMap = joiObject._ids._byKey; + + const data = { + type: joiObject.type, + description: joiObject._flags.description, + properties: {}, + required: [] + }; - if (joiObject._flags.objectName) { - data.objectName = joiObject._flags.objectName; - } + if (joiObject._flags.objectName) { + data.objectName = joiObject._flags.objectName; + } - if (path) { - requestBodyProperties[path] = data; - } else if (Array.isArray(requestBodyProperties)) { - requestBodyProperties.push(data); - } else { - requestBodyProperties.items = data; - } + if (path) { + requestBodyProperties[path] = data; + } else if (Array.isArray(requestBodyProperties)) { + requestBodyProperties.push(data); + } else { + requestBodyProperties.items = data; + } - for (const [key, value] of fieldsMap) { - if (value.schema._flags.presence === 'required') { - data.required.push(key); + for (const [key, value] of fieldsMap) { + if (value.schema._flags.presence === 'required') { + data.required.push(key); + } + this.parseJoiObject(key, value.schema, data.properties); } - parseJoiObject(key, value.schema, data.properties); - } - } else if (joiObject.type === 'alternatives') { - const matches = joiObject.$_terms.matches; + } else if (joiObject.type === 'alternatives') { + const matches = joiObject.$_terms.matches; - const data = { - oneOf: [], - description: joiObject._flags.description - }; + const data = { + oneOf: [], + description: joiObject._flags.description + }; - if (path) { - requestBodyProperties[path] = data; - } else if (Array.isArray(requestBodyProperties)) { - requestBodyProperties.push(data); - } else { - requestBodyProperties.items = data; - } + if (path) { + requestBodyProperties[path] = data; + } else if (Array.isArray(requestBodyProperties)) { + requestBodyProperties.push(data); + } else { + requestBodyProperties.items = data; + } - for (const alternative of matches) { - parseJoiObject(null, alternative.schema, data.oneOf); - } - } else if (joiObject.type === 'array') { - const elems = joiObject?.$_terms.items; + for (const alternative of matches) { + this.parseJoiObject(null, alternative.schema, data.oneOf); + } + } else if (joiObject.type === 'array') { + const elems = joiObject?.$_terms.items; - const data = { - type: 'array', - items: {}, - description: joiObject._flags.description - }; + const data = { + type: 'array', + items: {}, + description: joiObject._flags.description + }; - if (path) { - requestBodyProperties[path] = data; - } else if (Array.isArray(requestBodyProperties)) { - requestBodyProperties.push(data); + if (path) { + requestBodyProperties[path] = data; + } else if (Array.isArray(requestBodyProperties)) { + requestBodyProperties.push(data); + } else { + requestBodyProperties.items = data; + } + this.parseJoiObject(null, elems[0], data); } else { - requestBodyProperties.items = data; - } - parseJoiObject(null, elems[0], data); - } else { - const openApiType = joiTypeToOpenApiTypeMap[joiObject.type]; // even if type is object here then ignore and do not go recursive - const isRequired = joiObject._flags.presence === 'required'; - const description = joiObject._flags.description; - let format = undefined; - - if (!openApiType) { - throw new Error('Unsupported type! Check API endpoint!'); - } + const openApiType = joiTypeToOpenApiTypeMap[joiObject.type]; // even if type is object here then ignore and do not go recursive + const isRequired = joiObject._flags.presence === 'required'; + const description = joiObject._flags.description; + let format = undefined; - if (joiObject.type !== openApiType) { - // type has changed, so probably string, acquire format - format = joiObject.type; - } - - const data = { type: openApiType, description, required: isRequired }; - if (format) { - data.format = format; + if (!openApiType) { + throw new Error('Unsupported type! Check API endpoint!'); + } - if (data.format === 'date') { - data.format = 'date-time'; + if (joiObject.type !== openApiType) { + // type has changed, so probably string, acquire format + format = joiObject.type; } - } - // enum check - if (joiObject._valids) { - const enumValues = []; - for (const validEnumValue of joiObject._valids._values) { - enumValues.push(validEnumValue); + const data = { type: openApiType, description, required: isRequired }; + if (format) { + data.format = format; + + if (data.format === 'date') { + data.format = 'date-time'; + } } - if (enumValues.length > 0) { - data.enum = enumValues; + + // enum check + if (joiObject._valids) { + const enumValues = []; + for (const validEnumValue of joiObject._valids._values) { + enumValues.push(validEnumValue); + } + if (enumValues.length > 0) { + data.enum = enumValues; + } } - } - // example check - if (joiObject.$_terms && joiObject.$_terms.examples && joiObject.$_terms.examples.length > 0) { - const example = joiObject.$_terms.examples[0]; + // example check + if (joiObject.$_terms && joiObject.$_terms.examples && joiObject.$_terms.examples.length > 0) { + const example = joiObject.$_terms.examples[0]; - data.example = example; - } + data.example = example; + } - if (path) { - requestBodyProperties[path] = data; - } else if (Array.isArray(requestBodyProperties)) { - requestBodyProperties.push(data); - } else { - requestBodyProperties.items = data; + if (path) { + requestBodyProperties[path] = data; + } else if (Array.isArray(requestBodyProperties)) { + requestBodyProperties.push(data); + } else { + requestBodyProperties.items = data; + } } } -} -async function generateAPiDocs(routes, options) { - let docs = { - openapi: options.openapiVersion || '3.0.0', - info: options.info || { - title: 'Example API', - description: 'Example API docs', - version: '1.0.0', - contact: { - url: 'https://github.com/example/example' - } - }, - servers: options.servers || [{ url: 'https://example.com' }], - tags: options.tags || [ - { - name: 'Example tag', - description: 'This is an example tag provided if you do not specify any tags yourself in the options' - } - ] - }; + async generateAPiDocs(routes, options) { + let docs = { + openapi: options.openapiVersion || '3.0.0', + info: options.info || { + title: 'Example API', + description: 'Example API docs', + version: '1.0.0', + contact: { + url: 'https://github.com/example/example' + } + }, + servers: options.servers || [{ url: 'https://example.com' }], + tags: options.tags || [ + { + name: 'Example tag', + description: 'This is an example tag provided if you do not specify any tags yourself in the options' + } + ] + }; - const mapPathToMethods = {}; // map -> {path -> {post -> {}, put -> {}, delete -> {}, get -> {}}} + const mapPathToMethods = {}; // map -> {path -> {post -> {}, put -> {}, delete -> {}, get -> {}}} - for (const routePath in routes) { - const route = routes[routePath]; - const { spec } = route; + for (const routePath in routes) { + const route = routes[routePath]; + const { spec } = route; - if (spec.exclude) { - continue; - } + if (spec.exclude) { + continue; + } - if (!mapPathToMethods[spec.path]) { - mapPathToMethods[spec.path] = {}; - } + if (!mapPathToMethods[spec.path]) { + mapPathToMethods[spec.path] = {}; + } - mapPathToMethods[spec.path][spec.method.toLowerCase()] = {}; - const operationObj = mapPathToMethods[spec.path][spec.method.toLowerCase()]; - // 1) add tags - operationObj.tags = spec.tags; + mapPathToMethods[spec.path][spec.method.toLowerCase()] = {}; + const operationObj = mapPathToMethods[spec.path][spec.method.toLowerCase()]; + // 1) add tags + operationObj.tags = spec.tags; - // 2) add summary - operationObj.summary = spec.summary; + // 2) add summary + operationObj.summary = spec.summary; - // 3) add description - operationObj.description = spec.description; + // 3) add description + operationObj.description = spec.description; - // 4) add operationId - operationObj.operationId = spec.name || route.name; + // 4) add operationId + operationObj.operationId = spec.name || route.name; - // 5) add requestBody - const applicationType = spec.applicationType || 'application/json'; + // 5) add requestBody + const applicationType = spec.applicationType || 'application/json'; - if (spec.validationObjs?.requestBody && Object.keys(spec.validationObjs.requestBody).length > 0) { - operationObj.requestBody = { - content: { - [applicationType]: { - schema: {} - } - }, - required: true - }; + if (spec.validationObjs?.requestBody && Object.keys(spec.validationObjs.requestBody).length > 0) { + operationObj.requestBody = { + content: { + [applicationType]: { + schema: {} + } + }, + required: true + }; - // convert to Joi object for easier parsing - parseJoiObject('schema', Joi.object(spec.validationObjs?.requestBody), operationObj.requestBody.content[applicationType]); - } + // convert to Joi object for easier parsing + this.parseJoiObject('schema', this.Joi.object(spec.validationObjs?.requestBody), operationObj.requestBody.content[applicationType]); + } - // 6) add parameters (queryParams and pathParams). - operationObj.parameters = []; - for (const paramKey in spec.validationObjs?.pathParams) { - const paramKeyData = spec.validationObjs.pathParams[paramKey]; - - const obj = {}; - obj.name = paramKey; - obj.in = 'path'; - obj.description = paramKeyData._flags.description; - obj.required = paramKeyData._flags.presence === 'required'; - obj.schema = { type: paramKeyData.type }; - operationObj.parameters.push(obj); - } + // 6) add parameters (queryParams and pathParams). + operationObj.parameters = []; + for (const paramKey in spec.validationObjs?.pathParams) { + const paramKeyData = spec.validationObjs.pathParams[paramKey]; + + const obj = {}; + obj.name = paramKey; + obj.in = 'path'; + obj.description = paramKeyData._flags.description; + obj.required = paramKeyData._flags.presence === 'required'; + obj.schema = { type: paramKeyData.type }; + operationObj.parameters.push(obj); + } - for (const paramKey in spec.validationObjs?.queryParams) { - const paramKeyData = spec.validationObjs.queryParams[paramKey]; + for (const paramKey in spec.validationObjs?.queryParams) { + const paramKeyData = spec.validationObjs.queryParams[paramKey]; + + const obj = {}; + obj.name = paramKey; + obj.in = 'query'; + obj.description = paramKeyData._flags.description; + obj.required = paramKeyData._flags.presence === 'required'; + obj.schema = { type: paramKeyData.type }; + + // enum check + if (paramKeyData._valids) { + const enumValues = []; + for (const validEnumValue of paramKeyData._valids._values) { + enumValues.push(validEnumValue); + } + if (enumValues.length > 0) { + obj.schema.enum = enumValues; + } + } - const obj = {}; - obj.name = paramKey; - obj.in = 'query'; - obj.description = paramKeyData._flags.description; - obj.required = paramKeyData._flags.presence === 'required'; - obj.schema = { type: paramKeyData.type }; + // example check + if (paramKeyData.$_terms && paramKeyData.$_terms.examples && paramKeyData.$_terms.examples.length > 0) { + const example = paramKeyData.$_terms.examples[0]; - // enum check - if (paramKeyData._valids) { - const enumValues = []; - for (const validEnumValue of paramKeyData._valids._values) { - enumValues.push(validEnumValue); - } - if (enumValues.length > 0) { - obj.schema.enum = enumValues; + obj.schema.example = example; } - } - - // example check - if (paramKeyData.$_terms && paramKeyData.$_terms.examples && paramKeyData.$_terms.examples.length > 0) { - const example = paramKeyData.$_terms.examples[0]; - obj.schema.example = example; + operationObj.parameters.push(obj); } - operationObj.parameters.push(obj); - } - - // 7) add responses - const responseType = spec.responseType || 'application/json'; - operationObj.responses = {}; + // 7) add responses + const responseType = spec.responseType || 'application/json'; + operationObj.responses = {}; - for (const resHttpCode in spec.validationObjs?.response) { - const resBodyData = spec.validationObjs.response[resHttpCode]; + for (const resHttpCode in spec.validationObjs?.response) { + const resBodyData = spec.validationObjs.response[resHttpCode]; - operationObj.responses[resHttpCode] = { - description: resBodyData.description, - content: { - [responseType]: { - schema: {} + operationObj.responses[resHttpCode] = { + description: resBodyData.description, + content: { + [responseType]: { + schema: {} + } } - } - }; + }; - const obj = operationObj.responses[resHttpCode]; + const obj = operationObj.responses[resHttpCode]; - parseJoiObject('schema', resBodyData.model, obj.content[responseType]); + this.parseJoiObject('schema', resBodyData.model, obj.content[responseType]); + } } - } - const components = { components: { schemas: {} } }; + const components = { components: { schemas: {} } }; - for (const path in mapPathToMethods) { - // for every path - const pathData = mapPathToMethods[path]; + for (const path in mapPathToMethods) { + // for every path + const pathData = mapPathToMethods[path]; - for (const httpMethod in pathData) { - // for every http method (post, put, get, delete) - const innerData = pathData[httpMethod]; + for (const httpMethod in pathData) { + // for every http method (post, put, get, delete) + const innerData = pathData[httpMethod]; - // for every requestBody obj - for (const key in innerData?.requestBody?.content[Object.keys(innerData.requestBody.content)[0]].schema.properties) { - const reqBodyData = innerData.requestBody.content[Object.keys(innerData.requestBody.content)[0]].schema.properties[key]; + // for every requestBody obj + for (const key in innerData?.requestBody?.content[Object.keys(innerData.requestBody.content)[0]].schema.properties) { + const reqBodyData = innerData.requestBody.content[Object.keys(innerData.requestBody.content)[0]].schema.properties[key]; - parseComponetsDecoupled(reqBodyData, components.components.schemas); - replaceWithRefs(reqBodyData); - } + this.parseComponetsDecoupled(reqBodyData, components.components.schemas); + this.replaceWithRefs(reqBodyData); + } - // for every response object - for (const key in innerData.responses) { - // key here is http method (2xx, 4xx, 5xx) - const obj = innerData.responses[key].content[Object.keys(innerData.responses[key].content)[0]].schema; - parseComponetsDecoupled(obj, components.components.schemas); - replaceWithRefs(obj); + // for every response object + for (const key in innerData.responses) { + // key here is http method (2xx, 4xx, 5xx) + const obj = innerData.responses[key].content[Object.keys(innerData.responses[key].content)[0]].schema; + this.parseComponetsDecoupled(obj, components.components.schemas); + this.replaceWithRefs(obj); + } } } - } - // refify components that use other components - for (const obj of Object.values(components.components.schemas)) { - replaceWithRefs(obj); - } + // refify components that use other components + for (const obj of Object.values(components.components.schemas)) { + this.replaceWithRefs(obj); + } - const finalObj = { paths: mapPathToMethods }; + const finalObj = { paths: mapPathToMethods }; - components.components.securitySchemes = options.components.securitySchemes; + components.components.securitySchemes = options.components.securitySchemes; - docs = { ...docs, ...finalObj }; - docs = { ...docs, ...components }; + docs = { ...docs, ...finalObj }; + docs = { ...docs, ...components }; - docs = { - ...docs, - security: options.security - }; + docs = { + ...docs, + security: options.security + }; - await fs.promises.writeFile(__dirname + options.docsPath || '/openapidocs.json', JSON.stringify(docs)); -} + await fs.promises.writeFile(this.dirname + options.docsPath || '/openapidocs.json', JSON.stringify(docs)); + } -function restifyApiGenerate(ctx, options) { - const routes = ctx.router.getRoutes(); + restifyApiGenerate(ctx, options) { + const routes = ctx.router.getRoutes(); - generateAPiDocs(routes, options); + this.generateAPiDocs(routes, options); - return (req, res, next) => next(); + return (req, res, next) => next(); + } } -module.exports = restifyApiGenerate; +module.exports = { RestifyApiGenerate };