UNPKG

serverless-openapi-documenter

Version:

Generate OpenAPI v3 documentation and Postman Collections from your Serverless Config

1,116 lines (911 loc) 31 kB
"use strict"; const isEqual = require("node:util").isDeepStrictEqual; const path = require("path"); const { Config, lintFromString, stringifyYaml, createConfig, } = require("@redocly/openapi-core"); const { v4: uuid } = require("uuid"); const SchemaHandler = require("./schemaHandler"); const oWASP = require("./owasp"); class DefinitionGenerator { constructor(serverless, logger) { this.logger = logger; this.version = serverless?.processedInput?.options?.openApiVersion || "3.0.0"; this.serverless = serverless; this.httpKeys = { http: "http", httpAPI: "httpApi", }; this.componentsSchemas = { requestBody: "requestBodies", responses: "responses", }; this.openAPI = { openapi: this.version, components: { schemas: {}, }, }; this.schemaHandler = new SchemaHandler( serverless, this.openAPI, this.logger ); this.operationIdMap = {}; this.functionMap = {}; this.operationIds = []; this.schemaIDs = []; this.componentTypes = { schemas: "schemas", securitySchemes: "securitySchemes", }; this.DEFAULT_CORS_HEADERS = { "Access-Control-Allow-Origin": { description: "The Access-Control-Allow-Origin response header indicates whether the response can be shared with requesting code from the given [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin). - [MDN Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin)", schema: { type: "string", default: "*", example: "https://developer.mozilla.org", }, }, "Access-Control-Allow-Credentials": { description: `The Access-Control-Allow-Credentials response header tells browsers whether to expose the response to the frontend JavaScript code when the request's credentials mode ([Request.credentials](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials)) is include. - [MDN Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials)`, schema: { type: "boolean", default: true, }, }, }; try { this.logger.verbose( `Trying to resolve Redocly rules from: ${path.resolve( "options", "redocly.json" )}` ); this.REDOCLY_RULES = require(path.resolve("options", "redocly.json")); } catch (err) { this.REDOCLY_RULES = { struct: "error", "path-parameters-defined": "error", "operation-2xx-response": "error", "operation-4xx-response": "error", "operation-operationId-unique": "error", "path-declaration-must-exist": "error", }; } try { this.logger.verbose( `Trying to resolve Ref-Parser options from: ${path.resolve( "options", "ref-parser.js" )}` ); this.refParserOptions = require(path.resolve("options", "ref-parser.js")); } catch (err) { this.refParserOptions = {}; } } async parse() { this.createInfo(); await oWASP.getLatest(); await this.schemaHandler.addModelsToOpenAPI().catch((err) => { throw err; }); if (this.serverless.service.custom.documentation.securitySchemes) { this.createSecuritySchemes( this.serverless.service.custom.documentation.securitySchemes ); if (this.serverless.service.custom.documentation.security) { this.openAPI.security = this.serverless.service.custom.documentation.security; } } await this.createPaths().catch((err) => { throw err; }); this.cleanupLinks(); if (this.serverless.service.custom.documentation.servers) { const servers = this.createServers( this.serverless.service.custom.documentation.servers ); Object.assign(this.openAPI, { servers: servers }); } if (this.serverless.service.custom.documentation.tags) { this.createTags(); } if (this.serverless.service.custom.documentation.externalDocumentation) { const extDoc = this.createExternalDocumentation( this.serverless.service.custom.documentation.externalDocumentation ); Object.assign(this.openAPI, { externalDocs: extDoc }); } } createInfo() { const service = this.serverless.service; const documentation = this.serverless.service.custom.documentation; const info = { title: documentation?.title || service.service, description: documentation?.description || "", version: documentation?.version || uuid(), }; if (documentation.termsOfService) info.termsOfService = documentation.termsOfService; if (documentation.contact) { const contactObj = {}; contactObj.name = documentation.contact.name || ""; if (documentation.contact.url) contactObj.url = documentation.contact.url; contactObj.email = documentation.contact.email || ""; const extendedSpec = this.extendSpecification(documentation.contact); if (Object.keys(extendedSpec).length) { Object.assign(contactObj, extendedSpec); } Object.assign(info, { contact: contactObj }); } if (documentation.license && documentation.license.name) { const licenseObj = {}; licenseObj.name = documentation.license.name || ""; if (documentation.license.url) licenseObj.url = documentation.license.url || ""; const extendedSpec = this.extendSpecification(documentation.license); if (Object.keys(extendedSpec).length) { Object.assign(licenseObj, extendedSpec); } Object.assign(info, { license: licenseObj }); } if (documentation["x-tagGroups"]) { Object.assign(this.openAPI, { "x-tagGroups": documentation["x-tagGroups"], }); delete documentation["x-tagGroups"]; } const extendedSpec = this.extendSpecification(documentation); if (Object.keys(extendedSpec).length) { Object.assign(info, extendedSpec); } Object.assign(this.openAPI, { info }); } async createPaths() { const paths = {}; const httpFunctions = this.getHTTPFunctions(); for (const httpFunction of httpFunctions) { for (const event of httpFunction.event) { if (event?.http?.documentation || event?.httpApi?.documentation) { this.currentEvent = event?.http || event?.httpApi; const documentation = event?.http?.documentation || event?.httpApi?.documentation; this.currentFunctionName = httpFunction.functionInfo.name; this.operationName = httpFunction.operationName; const path = await this.createOperationObject( event?.http?.method || event?.httpApi?.method, documentation ).catch((err) => { throw err; }); // if (httpFunction.functionInfo?.summary) // path.summary = httpFunction.functionInfo.summary; // if (httpFunction.functionInfo?.description) // path.description = httpFunction.functionInfo.description; if (httpFunction.functionInfo?.servers) { const servers = this.createServers( httpFunction.functionInfo.servers ); path.servers = servers; } let slashPath = (event?.http?.path || event.httpApi?.path) ?? "/"; const pathStart = new RegExp(/^\//, "g"); if (pathStart.test(slashPath) === false) { slashPath = `/${(event?.http?.path || event.httpApi?.path) ?? ""}`; } if (paths[slashPath]) { Object.assign(paths[slashPath], path); } else { Object.assign(paths, { [slashPath]: path }); } } } } Object.assign(this.openAPI, { paths }); } createServers(servers) { const serverDoc = servers; const newServers = []; const variableManipulation = (variables) => { for (const key of Object.keys(variables)) { if (variables[key].enum) { const strEnum = variables[key].enum.map((enumVar) => enumVar.toString() ); variables[key].enum = strEnum; } if (variables[key].default) variables[key].default = variables[key].default.toString(); const extendedSpec = this.extendSpecification(variables[key]); if (Object.keys(extendedSpec).length) { Object.assign(variables[key], extendedSpec); } } return variables; }; if (Array.isArray(serverDoc)) { for (const server of serverDoc) { const obj = { url: server.url, }; if (server.description) { obj.description = server.description; } if (server.variables) { obj.variables = variableManipulation(server.variables); } const extendedSpec = this.extendSpecification(server); if (Object.keys(extendedSpec).length) { Object.assign(obj, extendedSpec); } newServers.push(obj); } } else { const obj = { url: servers.url, }; if (servers.description) { obj.description = servers.description; } if (servers.variables) { obj.variables = variableManipulation(servers.variables); } const extendedSpec = this.extendSpecification(servers); if (Object.keys(extendedSpec).length) { Object.assign(obj, extendedSpec); } newServers.push(obj); } return newServers; } createExternalDocumentation(docs) { return { ...docs }; // const documentation = this.serverless.service.custom.documentation // if (documentation.externalDocumentation) { // // Object.assign(this.openAPI, {externalDocs: {...documentation.externalDocumentation}}) // return // } } createTags() { const tags = []; for (const tag of this.serverless.service.custom.documentation.tags) { const obj = { name: tag.name, }; if (tag.description) { obj.description = tag.description; } if (tag.externalDocumentation) { obj.externalDocs = this.createExternalDocumentation( tag.externalDocumentation ); } const extendedSpec = this.extendSpecification(tag); if (Object.keys(extendedSpec).length) { Object.assign(obj, extendedSpec); } tags.push(obj); } Object.assign(this.openAPI, { tags: tags }); } async createOperationObject(method, documentation) { let operationId = documentation?.operationId || this.operationName; if (this.operationIds.includes(operationId)) { operationId += `-${uuid()}`; } const arr = this.functionMap[this.operationName]; arr.push(operationId); this.functionMap[this.operationName] = arr; this.operationIds.push(operationId); Object.assign(this.operationIdMap, { [operationId]: this.operationName, }); const obj = { summary: documentation.summary || "", description: documentation.description || "", operationId: operationId, parameters: [], tags: documentation.tags || [], }; if (documentation.pathParams) { const paramObject = await this.createParamObject( "path", documentation ).catch((err) => { throw err; }); obj.parameters = obj.parameters.concat(paramObject); } if (documentation.queryParams) { const paramObject = await this.createParamObject( "query", documentation ).catch((err) => { throw err; }); obj.parameters = obj.parameters.concat(paramObject); } if (documentation.headerParams) { const paramObject = await this.createParamObject( "header", documentation ).catch((err) => { throw err; }); obj.parameters = obj.parameters.concat(paramObject); } if (documentation.cookieParams) { const paramObject = await this.createParamObject( "cookie", documentation ).catch((err) => { throw err; }); obj.parameters = obj.parameters.concat(paramObject); } if (documentation.externalDocumentation) { obj.externalDocs = documentation.externalDocumentation; } if (Object.keys(documentation).includes("security")) { obj.security = documentation.security; } if (this.currentEvent?.private && this.currentEvent.private === true) { let apiKeyName = "x-api-key"; let hasXAPIKey = false; if (this.openAPI?.components?.[this.componentTypes.securitySchemes]) { for (const [schemeName, schemeValue] of Object.entries( this.openAPI.components[this.componentTypes.securitySchemes] )) { if ( schemeValue.type === "apiKey" && schemeValue.name === "x-api-key" ) { apiKeyName = schemeName; hasXAPIKey = true; } } } if (hasXAPIKey === false) { this.createSecuritySchemes({ [apiKeyName]: { type: "apiKey", name: apiKeyName, in: "header" }, }); } if (obj.security) { obj.security.push({ [apiKeyName]: [] }); } else { obj.security = [{ [apiKeyName]: [] }]; } } if (Object.keys(documentation).includes("deprecated")) obj.deprecated = documentation.deprecated; if (documentation.requestBody || this.currentEvent?.request?.schemas) { const requestModel = {}; if (documentation.requestBody) { Object.assign(requestModel, { description: documentation.requestBody.description, models: documentation.requestModels, required: documentation.requestBody.required, }); } else { Object.assign(requestModel, { description: "", models: this.currentEvent?.request?.schemas, }); } obj.requestBody = await this.createRequestBody(requestModel).catch( (err) => { throw err; } ); } if (documentation.methodResponses) obj.responses = await this.createResponses(documentation).catch((err) => { throw err; }); if (documentation.servers) { const servers = this.createServers(documentation.servers); obj.servers = servers; } const extendedSpec = this.extendSpecification(documentation); if (Object.keys(extendedSpec).length) { Object.assign(obj, extendedSpec); } return { [method.toLowerCase()]: obj }; } async createResponses(documentation) { const responses = {}; for (const response of documentation.methodResponses) { const obj = { description: response?.responseBody?.description || "", }; this.currentStatusCode = response.statusCode; if (response?.responseModels) { obj.content = await this.createMediaTypeObject( response.responseModels, "responses" ).catch((err) => { throw err; }); } if (response.responseHeaders) { obj.headers = await this.createResponseHeaders( response.responseHeaders ).catch((err) => { throw err; }); } let owaspHeaders = {}; if (response.owasp) { if (typeof response.owasp === "boolean") { owaspHeaders = await this.createResponseHeaders( oWASP.DEFAULT_OWASP_HEADERS ).catch((err) => { throw err; }); } else { if (Object.keys(response.owasp).includes("pragma")) { this.logger.warn( "Pragma has been deprecated by owasp (https://owasp.org/www-project-secure-headers/#pragma) and support for defaults will be dropped by this plugin." ); } owaspHeaders = await this.createResponseHeaders( oWASP.getHeaders(response.owasp) ).catch((err) => { throw err; }); } } const corsHeaders = await this.corsHeaders().catch((err) => { throw err; }); const addHeaders = (headers) => { for (const key in headers) { if (!(key in obj.headers) && (obj.headers[key] = {})) { obj.headers[key] = headers[key]; } } }; if (obj.headers) { addHeaders(corsHeaders); addHeaders(owaspHeaders); } else { if (Object.keys(corsHeaders).length) { obj.headers = corsHeaders; addHeaders(owaspHeaders); } else { obj.headers = owaspHeaders; } } if (response.links) { obj.links = this.createLinks(response.links); } Object.assign(responses, { [response.statusCode]: obj }); } return responses; } async corsHeaders() { let headers = {}; if (this.currentEvent?.cors === true) { headers = await this.createResponseHeaders( this.DEFAULT_CORS_HEADERS ).catch((err) => { throw err; }); } else if (this.currentEvent?.cors) { const newHeaders = {}; for (const key of Object.keys(this.DEFAULT_CORS_HEADERS)) { if ( key === "Access-Control-Allow-Credentials" && (this.currentEvent.cors.allowCredentials === undefined || this.currentEvent.cors?.allowCredentials === false) ) { continue; } const obj = structuredClone(this.DEFAULT_CORS_HEADERS[key]); if (key === "Access-Control-Allow-Origin") { if ( this.currentEvent.cors?.origins || this.currentEvent.cors?.origin ) { obj.schema.example = this.currentEvent.cors?.origins?.toString() || this.currentEvent.cors?.origin?.toString(); } else if (this.currentEvent.cors?.allowedOrigins) { obj.schema.example = this.currentEvent.cors.allowedOrigins.toString(); } } Object.assign(newHeaders, { [key]: obj }); } headers = await this.createResponseHeaders(newHeaders).catch((err) => { throw err; }); } return headers; } async createResponseHeaders(headers) { const obj = {}; for (const header of Object.keys(headers)) { const newHeader = {}; newHeader.description = headers[header].description || ""; if (headers[header].schema) { const schemaRef = await this.schemaHandler .createSchema(header, headers[header].schema) .catch((err) => { throw err; }); newHeader.schema = { $ref: schemaRef, }; } Object.assign(obj, { [header]: newHeader }); } return obj; } async createRequestBody(requestBodyDetails) { const obj = { description: requestBodyDetails.description, required: requestBodyDetails.required || false, }; obj.content = await this.createMediaTypeObject( requestBodyDetails.models ).catch((err) => { throw err; }); return obj; } async createMediaTypeObject(models, type) { const mediaTypeObj = {}; for (const mediaTypeDocumentation of this.schemaHandler.models) { if (models === undefined || models === null) { throw new Error( `${this.currentFunctionName} is missing a Response Model for statusCode ${this.currentStatusCode}` ); } if (Object.values(models).includes(mediaTypeDocumentation.name)) { let contentKey = ""; for (const [key, value] of Object.entries(models)) { if (value === mediaTypeDocumentation.name) contentKey = key; } const obj = {}; let schema; if (mediaTypeDocumentation?.content) { if (mediaTypeDocumentation.content[contentKey]?.example) obj.example = mediaTypeDocumentation.content[contentKey].example; if (mediaTypeDocumentation.content[contentKey]?.examples) obj.examples = this.createExamples( mediaTypeDocumentation.content[contentKey].examples ); schema = mediaTypeDocumentation.content[contentKey].schema; } else if ( mediaTypeDocumentation?.contentType && mediaTypeDocumentation.schema ) { if (mediaTypeDocumentation?.example) obj.example = mediaTypeDocumentation.example; if (mediaTypeDocumentation?.examples) obj.examples = this.createExamples(mediaTypeDocumentation.examples); schema = mediaTypeDocumentation.schema; } const schemaRef = await this.schemaHandler .createSchema(mediaTypeDocumentation.name) .catch((err) => { throw err; }); obj.schema = { $ref: schemaRef, }; Object.assign(mediaTypeObj, { [contentKey]: obj }); } } if (Object.keys(mediaTypeObj).length === 0) { for (const contentKey of Object.keys(models)) { const obj = {}; const schema = models[contentKey]?.schema ? models[contentKey].schema : models[contentKey]; const name = models[contentKey]?.name ? models[contentKey].name : uuid(); const schemaRef = await this.schemaHandler .createSchema(name, schema) .catch((err) => { throw err; }); obj.schema = { $ref: schemaRef, }; Object.assign(mediaTypeObj, { [contentKey]: obj }); } } return mediaTypeObj; } async createParamObject(paramIn, documentation) { const params = []; for (const param of documentation[`${paramIn}Params`]) { const obj = { name: param.name, in: paramIn, description: param.description || "", required: paramIn === "path" ? true : param.required || false, }; if (Object.keys(param).includes("deprecated")) { obj.deprecated = param.deprecated; } if ( paramIn === "query" && Object.keys(param).includes("allowEmptyValue") ) { obj.allowEmptyValue = param.allowEmptyValue; } if (param.style) obj.style = param.style; if (Object.keys(param).includes("explode")) obj.explode = param.explode; if (paramIn === "query" && param.allowReserved) obj.allowReserved = param.allowReserved; if (param.example) obj.example = param.example; if (param.examples) obj.examples = this.createExamples(param.examples); if (param.schema) { const schemaRef = await this.schemaHandler .createSchema(param.name, param.schema) .catch((err) => { throw err; }); obj.schema = { $ref: schemaRef, }; } params.push(obj); } return params; } createLinks(links) { const linksObj = {}; for (const link in links) { const linkObj = links[link]; const obj = {}; obj.operationId = linkObj.operation; if (linkObj.description) { obj.description = linkObj.description; } if (linkObj.server) { obj.server = this.createServers(linkObj.server); } if (linkObj.parameters) { obj.parameters = linkObj.parameters; } if (linkObj.requestBody) { obj.requestBody = linkObj.requestBody; } Object.assign(linksObj, { [link]: obj }); } return linksObj; } addToComponents(type, schema, name) { const schemaObj = { [name]: schema, }; let newName = name; if (this.openAPI?.components) { if (this.openAPI.components[type]) { if ( this.openAPI.components[type][name] && isEqual(schemaObj[name], this.openAPI.components[type][name]) === false ) { delete schemaObj[name]; newName = `${name}-${uuid()}`; schemaObj[newName] = schema; } Object.assign(this.openAPI.components[type], schemaObj); } else { Object.assign(this.openAPI.components, { [type]: schemaObj }); } } else { const components = { components: { [type]: schemaObj, }, }; Object.assign(this.openAPI, components); } return newName; } createSecuritySchemes(securitySchemes) { for (const scheme of Object.keys(securitySchemes)) { const securityScheme = securitySchemes[scheme]; const schema = {}; if (securityScheme.description) schema.description = securityScheme.description; switch (securityScheme.type.toLowerCase()) { case "apikey": const apiKeyScheme = this.createAPIKeyScheme(securityScheme); schema.type = "apiKey"; Object.assign(schema, apiKeyScheme); break; case "http": const HTTPScheme = this.createHTTPScheme(securityScheme); schema.type = "http"; Object.assign(schema, HTTPScheme); break; case "openidconnect": const openIdConnectScheme = this.createOpenIDConnectScheme(securityScheme); schema.type = "openIdConnect"; Object.assign(schema, openIdConnectScheme); break; case "oauth2": const oAuth2Scheme = this.createOAuth2Scheme(securityScheme); schema.type = "oauth2"; Object.assign(schema, oAuth2Scheme); break; } this.addToComponents(this.componentTypes.securitySchemes, schema, scheme); } } createAPIKeyScheme(securitySchema) { const schema = {}; if (securitySchema.name) schema.name = securitySchema.name; else throw new Error( 'Security Scheme for "apiKey" requires the name of the header, query or cookie parameter to be used' ); if (securitySchema.in) schema.in = securitySchema.in; else throw new Error( 'Security Scheme for "apiKey" requires the location of the API key: header, query or cookie parameter' ); return schema; } createHTTPScheme(securitySchema) { const schema = {}; if (securitySchema.scheme) schema.scheme = securitySchema.scheme; else throw new Error('Security Scheme for "http" requires scheme'); if (securitySchema.bearerFormat) schema.bearerFormat = securitySchema.bearerFormat; return schema; } createOpenIDConnectScheme(securitySchema) { const schema = {}; if (securitySchema.openIdConnectUrl) schema.openIdConnectUrl = securitySchema.openIdConnectUrl; else throw new Error( 'Security Scheme for "openIdConnect" requires openIdConnectUrl' ); return schema; } createOAuth2Scheme(securitySchema) { const schema = {}; if (securitySchema.flows) { const flows = this.createOAuthFlows(securitySchema.flows); Object.assign(schema, { flows: flows }); } else throw new Error('Security Scheme for "oauth2" requires flows'); return schema; } createOAuthFlows(flows) { const obj = {}; for (const flow of Object.keys(flows)) { const schema = {}; if (["implicit", "authorizationCode"].includes(flow)) if (flows[flow].authorizationUrl) schema.authorizationUrl = flows[flow].authorizationUrl; else throw new Error(`oAuth2 ${flow} flow requires an authorizationUrl`); if ( ["password", "clientCredentials", "authorizationCode"].includes(flow) ) { if (flows[flow].tokenUrl) schema.tokenUrl = flows[flow].tokenUrl; else throw new Error(`oAuth2 ${flow} flow requires a tokenUrl`); } if (flows[flow].refreshUrl) schema.refreshUrl = flows[flow].refreshUrl; if (flows[flow].scopes) schema.scopes = flows[flow].scopes; else throw new Error(`oAuth2 ${flow} flow requires scopes`); Object.assign(obj, { [flow]: schema }); } return obj; } createExamples(examples) { const examplesObj = {}; for (const example of examples) { const { name, ...partialExample } = example; const componentName = this.addToComponents( "examples", partialExample, example.name ); Object.assign(examplesObj, { [example.name]: { $ref: `#/components/examples/${componentName}` }, }); } return examplesObj; } cleanupLinks() { for (const path of Object.keys(this.openAPI.paths)) { for (const [name, value] of Object.entries(this.openAPI.paths[path])) { if ( RegExp(/(get|put|post|delete|options|head|patch|trace)/i).test(name) ) { for (const [statusCode, responseObj] of Object.entries( value?.responses )) { if (responseObj.links) { for (const [linkName, linkObj] of Object.entries( responseObj.links )) { const opId = linkObj.operationId; if (this.functionMap[opId]) { linkObj.operationId = this.functionMap[opId][0]; } } } } } } } } extendSpecification(spec) { const obj = {}; for (const key of Object.keys(spec)) { if (/^[x\-]/i.test(key)) { Object.assign(obj, { [key]: spec[key] }); } } return obj; } getHTTPFunctions() { const isHttpFunction = (funcType) => { const keys = Object.keys(funcType); if ( keys.includes(this.httpKeys.http) || keys.includes(this.httpKeys.httpAPI) ) return true; }; const functionNames = this.serverless.service.getAllFunctions(); return functionNames .map((functionName) => { return this.serverless.service.getFunction(functionName); }) .filter((functionType) => { if (functionType?.events.some(isHttpFunction)) return functionType; }) .map((functionType) => { const event = functionType.events.filter(isHttpFunction); const operationName = functionType.name.split("-").slice(-1).pop(); Object.assign(this.functionMap, { [operationName]: [], }); return { operationName: operationName, functionInfo: functionType, handler: functionType.handler, name: functionType.name, event, }; }); } async validate() { const config = await createConfig({ apis: {}, rules: this.REDOCLY_RULES, }); const apiDesc = stringifyYaml(this.openAPI); return await lintFromString({ source: apiDesc, config: config, }).catch((err) => { console.error(err); throw err; }); } } module.exports = DefinitionGenerator;