UNPKG

@readme/postman-to-openapi

Version:
715 lines (652 loc) 22.1 kB
/* eslint-disable no-param-reassign */ /* eslint-disable unicorn/no-unsafe-regex */ /* eslint-disable default-param-last */ /* eslint-disable default-case */ /* eslint-disable no-underscore-dangle */ /* eslint-disable no-use-before-define */ const { promises: { writeFile, readFile }, } = require('fs'); const { getStatusCodeMessage } = require('@readme/http-status-codes'); const { dump } = require('js-yaml'); const jsonc = require('jsonc-parser'); const camelCase = require('lodash.camelcase'); const { parseMdTable } = require('./md-utils'); const replacePostmanVariables = require('./var-replacer'); async function postmanToOpenAPI( input, output, { info = {}, defaultTag = 'default', pathDepth = 0, auth: optsAuth, servers, externalDocs = {}, folders = {}, responseHeaders = true, replaceVars = false, additionalVars = {}, outputFormat = 'yaml', disabledParams = { includeQuery: false, includeHeader: false, }, operationId = 'off', } = {} ) { // TODO validate? let collectionFile = await resolveInput(input); if (replaceVars) { collectionFile = replacePostmanVariables(collectionFile, additionalVars); } const _postmanJson = JSON.parse(collectionFile); const postmanJson = _postmanJson.collection || _postmanJson; const { item: items, variable = [] } = postmanJson; const paths = {}; const domains = new Set(); const tags = {}; const securitySchemes = {}; // eslint-disable-next-line prefer-const for (let [i, element] of items.entries()) { while (element != null && element.item != null) { // is a folder const { item, description: tagDesc } = element; const tag = calculateFolderTag(element, folders); const tagged = item.map(e => ({ ...e, tag })); tags[tag] = tagDesc; items.splice(i, 1, ...tagged); // Empty folders will have tagged empty element = tagged.length > 0 ? tagged.shift() : items[i]; } // If there are an empty folder at the end of the collection elements could be `undefined` if (element != null) { const { request: { url, method, body, description: rawDesc, header = [], auth }, name, tag = defaultTag, event: events, response, } = element; const { path, query, protocol, host, port, valid, pathVars } = scrapeURL(url); if (valid) { // Remove from name the possible operation id between brackets // eslint-disable-next-line no-useless-escape const summary = name.replace(/ \[([^\[\]]*)\]/gi, ''); domains.add(calculateDomains(protocol, host, port)); const joinedPath = calculatePath(path, pathDepth); if (!paths[joinedPath]) paths[joinedPath] = {}; const { description, paramsMeta } = descriptionParse(rawDesc); paths[joinedPath][method.toLowerCase()] = { tags: [tag], summary, ...calculateOperationId(operationId, name, summary), ...(description ? { description } : {}), ...parseBody(body, method), ...parseOperationAuth(auth, securitySchemes, optsAuth), ...parseParameters(query, header, joinedPath, paramsMeta, pathVars, disabledParams), ...parseResponse(response, events, responseHeaders), }; } } } const openApi = { openapi: '3.0.0', info: compileInfo(postmanJson, info), ...parseExternalDocs(variable, externalDocs), ...parseServers(domains, servers), ...parseAuth(postmanJson, optsAuth, securitySchemes), ...parseTags(tags), paths, }; const openApiDoc = outputFormat === 'json' ? JSON.stringify(openApi, null, 4) : dump(openApi, { skipInvalid: true }); if (output != null) { await writeFile(output, openApiDoc, 'utf8'); } return openApiDoc; } /* Calculate the tags for folders items based on the options */ function calculateFolderTag({ tag, name }, { separator = ' > ', concat = true }) { return tag && concat ? `${tag}${separator}${name}` : name; } function compileInfo(postmanJson, optsInfo) { const { info: { name, description: desc }, variable = [], } = postmanJson; const ver = getVarValue(variable, 'version', '1.0.0'); const { title = name, description = desc, version = ver, termsOfService, license, contact, xLogo } = optsInfo; return { title, description, version, ...parseXLogo(variable, xLogo), ...(termsOfService ? { termsOfService } : {}), ...parseContact(variable, contact), ...parseLicense(variable, license), }; } function parseXLogo(variables, xLogo = {}) { const urlVar = getVarValue(variables, 'x-logo.urlVar'); const backgroundColorVar = getVarValue(variables, 'x-logo.backgroundColorVar'); const altTextVar = getVarValue(variables, 'x-logo.altTextVar'); const hrefVar = getVarValue(variables, 'x-logo.hrefVar'); const { url = urlVar, backgroundColor = backgroundColorVar, altText = altTextVar, href = hrefVar } = xLogo; return url != null ? { 'x-logo': { url, backgroundColor, altText, href } } : {}; } function parseLicense(variables, optsLicense = {}) { const nameVar = getVarValue(variables, 'license.name'); const urlVar = getVarValue(variables, 'license.url'); const { name = nameVar, url = urlVar } = optsLicense; return name != null ? { license: { name, ...(url ? { url } : {}) } } : {}; } function parseContact(variables, optsContact = {}) { const nameVar = getVarValue(variables, 'contact.name'); const urlVar = getVarValue(variables, 'contact.url'); const emailVar = getVarValue(variables, 'contact.email'); const { name = nameVar, url = urlVar, email = emailVar } = optsContact; return [name, url, email].some(e => e != null) ? { contact: { ...(name ? { name } : {}), ...(url ? { url } : {}), ...(email ? { email } : {}), }, } : {}; } function parseExternalDocs(variables, optsExternalDocs) { const descriptionVar = getVarValue(variables, 'externalDocs.description'); const urlVar = getVarValue(variables, 'externalDocs.url'); const { description = descriptionVar, url = urlVar } = optsExternalDocs; return url != null ? { externalDocs: { url, ...(description ? { description } : {}) } } : {}; } function parseBody(body = {}, method) { // Swagger validation return an error if GET has body if (['GET', 'DELETE'].includes(method)) { return {}; } const { mode, raw, options = { raw } } = body; let content = {}; switch (mode) { case 'raw': { const { raw: { language }, } = options; let example = ''; if (language === 'json') { if (raw) { const errors = []; example = jsonc.parse(raw, errors); if (errors.length > 0) { example = raw; } } content = { 'application/json': { schema: { type: 'object', example, }, }, }; } else if (language === 'text') { content = { 'text/plain': { schema: { type: 'string', example: raw, }, }, }; } else { content = { '*/*': { schema: { type: 'string', // To protect from object types we always stringify this example: JSON.stringify(raw), }, }, }; } break; } case 'file': content = { 'text/plain': {}, }; break; case 'formdata': { content = { 'multipart/form-data': parseFormData(body.formdata), }; break; } case 'urlencoded': content = { 'application/x-www-form-urlencoded': parseFormData(body.urlencoded), }; break; } return { requestBody: { content } }; } /** Parse the body for create a form data structure */ function parseFormData(data) { const objectSchema = { schema: { type: 'object', }, }; return data.reduce((obj, { key, type, description, value }) => { const { schema } = obj; if (isRequired(description)) { (schema.required = schema.required || []).push(key); } (schema.properties = schema.properties || {})[key] = { type: inferType(value), ...(description ? { description: description.replace(/ ?\[required\] ?/gi, '') } : {}), ...(value ? { example: value } : {}), ...(type === 'file' ? { format: 'binary' } : {}), }; return obj; }, objectSchema); } /** * Default logic to insert parameters, if parameter exist will not be inserted again. * In Postman this means that only the first parameter is used, the repeated ones are discarded. * This is a separated method to allow make it configurable in the future * @param {Map} parameterMap * @param {Object} param * @returns the modified parameterMap */ const defaultParamInserter = (parameterMap, param) => { if (!parameterMap.has(param.name)) { parameterMap.set(param.name, param); } return parameterMap; }; /* Parse the Postman query and header and transform into OpenApi parameters */ function parseParameters( query, header, paths, paramsMeta = {}, pathVars, { includeQuery = false, includeHeader = false }, paramInserter = defaultParamInserter ) { // parse Headers const parameters = [...header.reduce(mapParameters('header', includeHeader, paramInserter), new Map()).values()]; // parse Query parameters.push(...query.reduce(mapParameters('query', includeQuery, paramInserter), new Map()).values()); // Path params parameters.push(...extractPathParameters(paths, paramsMeta, pathVars)); return parameters.length ? { parameters } : {}; } /* Accumulator function for different types of parameters */ function mapParameters(type, includeDisabled, paramInserter) { return (parameterMap, { key, description, value, disabled }) => { if (!includeDisabled && disabled === true) return parameterMap; const required = /\[required\]/gi.test(description); paramInserter(parameterMap, { name: key, in: type, schema: { type: inferType(value) }, ...(required ? { required } : {}), ...(description ? { description: description.replace(/ ?\[required\] ?/gi, '') } : {}), ...(value ? { example: value } : {}), }); return parameterMap; }; } function extractPathParameters(path, paramsMeta, pathVars) { const matched = path.match(/{\s*[\w-]+\s*}/g) || []; return matched.map(match => { const name = match.slice(1, -1); const { type: varType = 'string', description: desc, value } = pathVars[name] || {}; const { type = varType, description = desc, example = value } = paramsMeta[name] || {}; return { name, in: 'path', schema: { type }, required: true, ...(description ? { description } : {}), ...(example ? { example } : {}), }; }); } function getVarValue(variables, name, def = undefined) { const variable = variables.find(({ key }) => key === name); return variable ? variable.value : def; } /* calculate the type of a variable based on OpenAPI types */ function inferType(value) { if (/^\d+$/.test(value)) return 'integer'; if (/^[+-]?([0-9]*[.])?[0-9]+$/.test(value)) return 'number'; if (/^(true|false)$/.test(value)) return 'boolean'; return 'string'; } /* Calculate the global auth based on options and postman definition */ function parseAuth({ auth }, optAuth, securitySchemes) { if (optAuth != null) { return parseOptsAuth(optAuth); } return parsePostmanAuth(auth, securitySchemes); } /* Parse a postman auth definition */ function parsePostmanAuth(postmanAuth = {}, securitySchemes) { const { type } = postmanAuth; if (type != null) { securitySchemes[`${type}Auth`] = { type: 'http', scheme: type, }; return { components: { securitySchemes }, security: [ { [`${type}Auth`]: [], }, ], }; } return Object.keys(securitySchemes).length === 0 ? {} : { components: { securitySchemes } }; } /* Parse Auth at operation/request level */ function parseOperationAuth(auth, securitySchemes, optsAuth) { if (auth == null || optsAuth != null) { // In case of config auth operation auth is disabled return {}; } const { type } = auth; securitySchemes[`${type}Auth`] = { type: 'http', scheme: type, }; return { security: [{ [`${type}Auth`]: [] }], }; } /* Parse a options global auth */ function parseOptsAuth(optAuth) { const securitySchemes = {}; const security = []; for (const [secName, secDefinition] of Object.entries(optAuth)) { const { type, scheme, ...rest } = secDefinition; if (type === 'http' && ['bearer', 'basic'].includes(scheme)) { securitySchemes[secName] = { type: 'http', scheme, ...rest, }; security.push({ [secName]: [] }); } } return Object.keys(securitySchemes).length === 0 ? {} : { components: { securitySchemes }, security, }; } /* From the path array compose the real path for OpenApi specs */ function calculatePath(paths, pathDepth) { paths = paths.slice(pathDepth); // path depth // replace repeated '{' and '}' chars // replace `:` chars at first return `/${paths .map(path => { path = path.replace(/([{}])\1+/g, '$1'); path = path.replace(/^:(.*)/g, '{$1}'); return path; }) .join('/')}`; } function calculateDomains(protocol, hosts, port) { return `${protocol}://${hosts.join('.')}${port ? `:${port}` : ''}`; } /** * To support postman collection v2 and variable replace we should parse the `url` or `url.raw` data * without trust in the object as in v2 could not exist and if replaceVars = true then values cannot * be correctly parsed * @param {Object | String} url * @returns a url structure as in postman v2.1 collections */ function scrapeURL(url) { // Avoid parse empty url request if (url === undefined || url === '' || url.raw === '') { return { valid: false }; } const rawUrl = typeof url === 'string' || url instanceof String ? url : url.raw; // Fix for issue #136 if replace vars are not used then new URL throw an error // when using variables before the schema const fixedUrl = rawUrl.startsWith('{{') ? `http://${rawUrl}` : rawUrl; const objUrl = new URL(fixedUrl); return { raw: rawUrl, path: decodeURIComponent(objUrl.pathname).slice(1).split('/'), query: compoundQueryParams(objUrl.searchParams, url.query), protocol: objUrl.protocol.slice(0, -1), host: decodeURIComponent(objUrl.hostname).split('.'), port: objUrl.port, valid: true, pathVars: url.variable == null ? {} : url.variable.reduce((obj, { key, value, description }) => { obj[key] = { value, description, type: inferType(value) }; return obj; }, {}), }; } /** * Calculate query parameters in postman collection * @param {*} searchParams The searchParam instance from an URL object * @param {*} queryCollection The postman collection query section * @returns A query params array as created by postman collections Array(Obj) * * NOTE: This method was created because we think that some versions of postman don´t add the `query` * parameter in the url, but after some reasearch the reason why the `query` parameter can not be * present is just because no query parameters are used so we just format the postman `query` array here. */ function compoundQueryParams(searchParams, queryCollection = []) { return queryCollection; } /* Parse domains from operations or options */ function parseServers(domains, serversOpts) { let servers; if (serversOpts != null) { // This map is just to filter not supported fields while no validations are implemented servers = serversOpts.map(({ url, description }) => ({ url, description })); } else { servers = Array.from(domains).map(domain => ({ url: domain })); } return servers.length > 0 ? { servers } : {}; } /* Transform a object of tags in an array of tags */ function parseTags(tagsObj) { const tags = Object.entries(tagsObj).map(([name, description]) => ({ name, description })); return tags.length > 0 ? { tags } : {}; } function descriptionParse(description) { if (description == null) return { description }; const splitDesc = description.split(/# postman-to-openapi/gi); if (splitDesc.length === 1) return { description }; return { description: splitDesc[0].trim(), paramsMeta: parseMdTable(splitDesc[1]), }; } function parseResponse(responses, events, responseHeaders) { if (responses != null && Array.isArray(responses) && responses.length > 0) { return parseResponseFromExamples(responses, responseHeaders); } return { responses: parseResponseFromEvents(events) }; } function parseResponseFromEvents(events = []) { let status = 200; const test = events.filter(event => event.listen === 'test'); if (test.length > 0) { const script = test[0].script.exec.join(); const result = script.match(/\.response\.code\)\.to\.eql\((\d{3})\)|\.to\.have\.status\((\d{3})\)/); status = result && result[1] != null ? result[1] : result && result[2] != null ? result[2] : status; } return { [status]: { description: 'Successful response', content: { 'application/json': {}, }, }, }; } function parseResponseFromExamples(responses, responseHeaders) { // Group responses by status code const statusCodeMap = responses.reduce( (statusMap, { name, code, status: description, header, body, _postman_previewlanguage: language }) => { if (code === undefined) { code = 'default'; } // The OpenAPI spec requires that `description` be present on responses with content so // we'll make it match the message that cooresponds to the HTTP status code. if (!description) { try { description = getStatusCodeMessage({ code }); } catch (err) { description = code; } } if (code in statusMap) { if (!(language in statusMap[code].bodies)) { statusMap[code].bodies[language] = []; } statusMap[code].bodies[language].push({ name, body }); } else { statusMap[code] = { description, header, bodies: { [language]: [{ name, body }] }, }; } return statusMap; }, {} ); // Parse for OpenAPI const parsedResponses = Object.entries(statusCodeMap).reduce((parsed, [status, { description, header, bodies }]) => { parsed[status] = { description, ...parseResponseHeaders(header, responseHeaders), ...parseContent(bodies), }; return parsed; }, {}); return { responses: parsedResponses }; } function parseContent(bodiesByLanguage) { const content = Object.entries(bodiesByLanguage).reduce((contentObj, [language, bodies]) => { if (language === 'json') { contentObj['application/json'] = { schema: { type: 'object' }, ...parseExamples(bodies, 'json'), }; } else { contentObj['text/plain'] = { schema: { type: 'string' }, ...parseExamples(bodies, 'text'), }; } return contentObj; }, {}); return { content }; } function parseExamples(bodies, language) { if (Array.isArray(bodies) && bodies.length > 1) { return { examples: bodies.reduce((ex, { name: summary, body }, i) => { ex[`example-${i}`] = { summary, value: safeSampleParse(body, summary, language), }; return ex; }, {}), }; } const { body, name } = bodies[0]; return { example: safeSampleParse(body, name, language), }; } function safeSampleParse(body, name, language) { if (language === 'json') { const errors = []; const parsedBody = jsonc.parse(body == null || body.trim().length === 0 ? '{}' : body, errors); if (errors.length > 0) { throw new Error(`Error parsing response example "${name}"`); } return parsedBody; } return body; } function parseResponseHeaders(headerArray, responseHeaders) { if (!responseHeaders) { return {}; } headerArray = headerArray || []; const headers = headerArray.reduce((acc, { key, value }) => { acc[key] = { schema: { type: inferType(value), example: value, }, }; return acc; }, {}); return Object.keys(headers).length > 0 ? { headers } : {}; } /** * Just check if is a string collection or a path. * moved to method for allow easy changes in the future like check if it is a collection, validations... */ async function resolveInput(input) { if (input.trim().startsWith('{')) { return input; } return readFile(input, 'utf8'); } /** * return if the provided text contains the '[required]' mark * @param {*} text The text where we should look for the required mark * @returns boolean */ function isRequired(text) { return /\[required\]/gi.test(text); } /** * calculate the operationId based on the user selected `mode` * @param {*} mode - mode to calculate the operation id between `off`, `auto` or `brackets` * @param {*} name - field name of the request/operation in the postman collection without modify. * @param {*} summary - calculated summary of the operation that will be used in the OpenAPI spec. * @returns an operation id */ function calculateOperationId(mode, name, summary) { let operationId; switch (mode) { case 'off': break; case 'auto': operationId = camelCase(summary); break; case 'brackets': { // eslint-disable-next-line no-useless-escape const matches = name.match(/\[([^\[\]]*)\]/); operationId = matches ? matches[1] : undefined; break; } default: // Unknown value in the operationId option break; } return operationId ? { operationId } : {}; } module.exports = postmanToOpenAPI;