UNPKG

@redocly/cli

Version:

[@Redocly](https://redocly.com) CLI is your all-in-one OpenAPI utility. It builds, manages, improves, and quality-checks your OpenAPI descriptions, all of which comes in handy for various phases of the API Lifecycle. Create your own rulesets to make API g

567 lines (566 loc) 27.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.handleJoin = handleJoin; const path = require("path"); const colorette_1 = require("colorette"); const perf_hooks_1 = require("perf_hooks"); const openapi_core_1 = require("@redocly/openapi-core"); const utils_1 = require("@redocly/openapi-core/lib/utils"); const miscellaneous_1 = require("../utils/miscellaneous"); const js_utils_1 = require("../utils/js-utils"); const types_1 = require("./split/types"); const split_1 = require("./split"); const Tags = 'tags'; const xTagGroups = 'x-tagGroups'; let potentialConflictsTotal = 0; async function handleJoin({ argv, config, version: packageVersion, collectSpecData, }) { const startedAt = perf_hooks_1.performance.now(); const { 'prefix-components-with-info-prop': prefixComponentsWithInfoProp, 'prefix-tags-with-filename': prefixTagsWithFilename, 'prefix-tags-with-info-prop': prefixTagsWithInfoProp, 'without-x-tag-groups': withoutXTagGroups, output, } = argv; const usedTagsOptions = [ prefixTagsWithFilename && 'prefix-tags-with-filename', prefixTagsWithInfoProp && 'prefix-tags-with-info-prop', withoutXTagGroups && 'without-x-tag-groups', ].filter(Boolean); if (usedTagsOptions.length > 1) { return (0, miscellaneous_1.exitWithError)(`You use ${(0, colorette_1.yellow)(usedTagsOptions.join(', '))} together.\nPlease choose only one!`); } const apis = await (0, miscellaneous_1.getFallbackApisOrExit)(argv.apis, config); if (apis.length < 2) { return (0, miscellaneous_1.exitWithError)(`At least 2 APIs should be provided.`); } const fileExtension = (0, miscellaneous_1.getAndValidateFileExtension)(output || apis[0].path); const specFilename = output || `openapi.${fileExtension}`; const externalRefResolver = new openapi_core_1.BaseResolver(config.resolve); const documents = await Promise.all(apis.map(({ path }) => externalRefResolver.resolveDocument(null, path, true))); const decorators = new Set([ ...Object.keys(config.styleguide.decorators.oas3_0), ...Object.keys(config.styleguide.decorators.oas3_1), ...Object.keys(config.styleguide.decorators.oas2), ]); config.styleguide.skipDecorators(Array.from(decorators)); const preprocessors = new Set([ ...Object.keys(config.styleguide.preprocessors.oas3_0), ...Object.keys(config.styleguide.preprocessors.oas3_1), ...Object.keys(config.styleguide.preprocessors.oas2), ]); config.styleguide.skipPreprocessors(Array.from(preprocessors)); const bundleResults = await Promise.all(documents.map((document) => (0, openapi_core_1.bundleDocument)({ document, config: config.styleguide, externalRefResolver: new openapi_core_1.BaseResolver(config.resolve), }).catch((e) => { (0, miscellaneous_1.exitWithError)(`${e.message}: ${(0, colorette_1.blue)(document.source.absoluteRef)}`); }))); for (const { problems, bundle: document } of bundleResults) { const fileTotals = (0, openapi_core_1.getTotals)(problems); if (fileTotals.errors) { (0, openapi_core_1.formatProblems)(problems, { totals: fileTotals, version: packageVersion, }); (0, miscellaneous_1.exitWithError)(`❌ Errors encountered while bundling ${(0, colorette_1.blue)(document.source.absoluteRef)}: join will not proceed.`); } } let oasVersion = null; for (const document of documents) { try { const version = (0, openapi_core_1.detectSpec)(document.parsed); collectSpecData?.(document.parsed); if (version !== openapi_core_1.SpecVersion.OAS3_0 && version !== openapi_core_1.SpecVersion.OAS3_1) { return (0, miscellaneous_1.exitWithError)(`Only OpenAPI 3.0 and OpenAPI 3.1 are supported: ${(0, colorette_1.blue)(document.source.absoluteRef)}.`); } oasVersion = oasVersion ?? version; if (oasVersion !== version) { return (0, miscellaneous_1.exitWithError)(`All APIs must use the same OpenAPI version: ${(0, colorette_1.blue)(document.source.absoluteRef)}.`); } } catch (e) { return (0, miscellaneous_1.exitWithError)(`${e.message}: ${(0, colorette_1.blue)(document.source.absoluteRef)}.`); } } const joinedDef = {}; const potentialConflicts = { tags: {}, paths: {}, components: {}, webhooks: {}, }; addInfoSectionAndSpecVersion(documents, prefixComponentsWithInfoProp); for (const document of documents) { const openapi = document.parsed; const { tags, info } = openapi; const api = path.relative(process.cwd(), document.source.absoluteRef); const apiFilename = getApiFilename(api); const tagsPrefix = prefixTagsWithFilename ? apiFilename : getInfoPrefix(info, prefixTagsWithInfoProp, 'tags'); const componentsPrefix = getInfoPrefix(info, prefixComponentsWithInfoProp, types_1.COMPONENTS); if (openapi.hasOwnProperty('x-tagGroups')) { process.stderr.write((0, colorette_1.yellow)(`warning: x-tagGroups at ${(0, colorette_1.blue)(api)} will be skipped \n`)); } const context = { api, apiFilename, apiTitle: info?.title, tags, potentialConflicts, tagsPrefix, componentsPrefix, }; if (tags) { populateTags(context); } collectServers(openapi); collectExternalDocs(openapi, context); collectPaths(openapi, context); collectComponents(openapi, context); collectWebhooks(oasVersion, openapi, context); if (componentsPrefix) { replace$Refs(openapi, componentsPrefix); } } iteratePotentialConflicts(potentialConflicts, withoutXTagGroups); const noRefs = true; if (potentialConflictsTotal) { return (0, miscellaneous_1.exitWithError)(`Please fix conflicts before running ${(0, colorette_1.yellow)('join')}.`); } (0, miscellaneous_1.writeToFileByExtension)((0, miscellaneous_1.sortTopLevelKeysForOas)(joinedDef), specFilename, noRefs); (0, miscellaneous_1.printExecutionTime)('join', startedAt, specFilename); function populateTags({ api, apiFilename, apiTitle, tags, potentialConflicts, tagsPrefix, componentsPrefix, }) { if (!joinedDef.hasOwnProperty(Tags)) { joinedDef[Tags] = []; } if (!potentialConflicts.tags.hasOwnProperty('all')) { potentialConflicts.tags['all'] = {}; } if (withoutXTagGroups && !potentialConflicts.tags.hasOwnProperty('description')) { potentialConflicts.tags['description'] = {}; } for (const tag of tags) { const entrypointTagName = addPrefix(tag.name, tagsPrefix); if (tag.description) { tag.description = addComponentsPrefix(tag.description, componentsPrefix); } const tagDuplicate = joinedDef.tags.find((t) => t.name === entrypointTagName); if (tagDuplicate && withoutXTagGroups) { // If tag already exist and `without-x-tag-groups` option, // check if description are different for potential conflicts warning. const isTagDescriptionNotEqual = tag.hasOwnProperty('description') && tagDuplicate.description !== tag.description; potentialConflicts.tags.description[entrypointTagName].push(...(isTagDescriptionNotEqual ? [api] : [])); } else if (!tagDuplicate) { // Instead add tag to joinedDef if there no duplicate; tag['x-displayName'] = tag['x-displayName'] || tag.name; tag.name = entrypointTagName; joinedDef.tags.push(tag); if (withoutXTagGroups) { potentialConflicts.tags.description[entrypointTagName] = [api]; } } if (!withoutXTagGroups) { const groupName = apiTitle || apiFilename; createXTagGroups(groupName); if (!tagDuplicate) { populateXTagGroups(entrypointTagName, getIndexGroup(groupName)); } } const doesEntrypointExist = !potentialConflicts.tags.all[entrypointTagName] || (potentialConflicts.tags.all[entrypointTagName] && !potentialConflicts.tags.all[entrypointTagName].includes(api)); potentialConflicts.tags.all[entrypointTagName] = [ ...(potentialConflicts.tags.all[entrypointTagName] || []), ...(!withoutXTagGroups && doesEntrypointExist ? [api] : []), ]; } } function getIndexGroup(name) { return joinedDef[xTagGroups].findIndex((item) => item.name === name); } function createXTagGroups(name) { if (!joinedDef.hasOwnProperty(xTagGroups)) { joinedDef[xTagGroups] = []; } if (!joinedDef[xTagGroups].some((g) => g.name === name)) { joinedDef[xTagGroups].push({ name, tags: [] }); } const indexGroup = getIndexGroup(name); if (!joinedDef[xTagGroups][indexGroup].hasOwnProperty(Tags)) { joinedDef[xTagGroups][indexGroup][Tags] = []; } } function populateXTagGroups(entrypointTagName, indexGroup) { if (!joinedDef[xTagGroups][indexGroup][Tags].find((t) => t.name === entrypointTagName)) { joinedDef[xTagGroups][indexGroup][Tags].push(entrypointTagName); } } function collectServers(openapi) { const { servers } = openapi; if (servers) { if (!joinedDef.hasOwnProperty('servers')) { joinedDef['servers'] = []; } for (const server of servers) { if (!joinedDef.servers.some((s) => s.url === server.url)) { joinedDef.servers.push(server); } } } } function collectExternalDocs(openapi, { api }) { const { externalDocs } = openapi; if (externalDocs) { if (joinedDef.hasOwnProperty('externalDocs')) { process.stderr.write((0, colorette_1.yellow)(`warning: skip externalDocs from ${(0, colorette_1.blue)(path.basename(api))} \n`)); return; } joinedDef['externalDocs'] = externalDocs; } } function collectPaths(openapi, { apiFilename, apiTitle, api, potentialConflicts, tagsPrefix, componentsPrefix, }) { const { paths } = openapi; const operationsSet = new Set((0, js_utils_1.keysOf)(types_1.OPENAPI3_METHOD)); if (paths) { if (!joinedDef.hasOwnProperty('paths')) { joinedDef['paths'] = {}; } for (const path of (0, js_utils_1.keysOf)(paths)) { if (!joinedDef.paths.hasOwnProperty(path)) { joinedDef.paths[path] = {}; } if (!potentialConflicts.paths.hasOwnProperty(path)) { potentialConflicts.paths[path] = {}; } const pathItem = paths[path]; for (const field of (0, js_utils_1.keysOf)(pathItem)) { if (operationsSet.has(field)) { collectPathOperation(pathItem, path, field); } if (field === 'servers') { collectPathServers(pathItem, path); } if (field === 'parameters') { collectPathParameters(pathItem, path); } if (typeof pathItem[field] === 'string') { collectPathStringFields(pathItem, path, field); } } } } function collectPathStringFields(pathItem, path, field) { const fieldValue = pathItem[field]; if (joinedDef.paths[path].hasOwnProperty(field) && joinedDef.paths[path][field] !== fieldValue) { process.stderr.write((0, colorette_1.yellow)(`warning: different ${field} values in ${path}\n`)); return; } joinedDef.paths[path][field] = fieldValue; } function collectPathServers(pathItem, path) { if (!pathItem.servers) { return; } if (!joinedDef.paths[path].hasOwnProperty('servers')) { joinedDef.paths[path].servers = []; } for (const server of pathItem.servers) { let isFoundServer = false; for (const pathServer of joinedDef.paths[path].servers) { if (pathServer.url === server.url) { if (!isServersEqual(pathServer, server)) { (0, miscellaneous_1.exitWithError)(`Different server values for (${server.url}) in ${path}.`); } isFoundServer = true; } } if (!isFoundServer) { joinedDef.paths[path].servers.push(server); } } } function collectPathParameters(pathItem, path) { if (!pathItem.parameters) { return; } if (!joinedDef.paths[path].hasOwnProperty('parameters')) { joinedDef.paths[path].parameters = []; } for (const parameter of pathItem.parameters) { let isFoundParameter = false; for (const pathParameter of joinedDef.paths[path] .parameters) { // Compare $ref only if both are reference objects if ((0, openapi_core_1.isRef)(pathParameter) && (0, openapi_core_1.isRef)(parameter)) { if (pathParameter['$ref'] === parameter['$ref']) { isFoundParameter = true; } } // Compare properties only if both are reference objects if (!(0, openapi_core_1.isRef)(pathParameter) && !(0, openapi_core_1.isRef)(parameter)) { if (pathParameter.name === parameter.name && pathParameter.in === parameter.in) { if (!(0, utils_1.dequal)(pathParameter.schema, parameter.schema)) { (0, miscellaneous_1.exitWithError)(`Different parameter schemas for (${parameter.name}) in ${path}.`); } isFoundParameter = true; } } } if (!isFoundParameter) { joinedDef.paths[path].parameters.push(parameter); } } } function collectPathOperation(pathItem, path, operation) { const pathOperation = pathItem[operation]; if (!pathOperation) { return; } joinedDef.paths[path][operation] = pathOperation; potentialConflicts.paths[path][operation] = [ ...(potentialConflicts.paths[path][operation] || []), api, ]; const { operationId } = pathOperation; if (operationId) { if (!potentialConflicts.paths.hasOwnProperty('operationIds')) { potentialConflicts.paths['operationIds'] = {}; } potentialConflicts.paths.operationIds[operationId] = [ ...(potentialConflicts.paths.operationIds[operationId] || []), api, ]; } const { tags, security } = joinedDef.paths[path][operation]; if (tags) { joinedDef.paths[path][operation].tags = tags.map((tag) => addPrefix(tag, tagsPrefix)); populateTags({ api, apiFilename, apiTitle, tags: formatTags(tags), potentialConflicts, tagsPrefix, componentsPrefix, }); } else { joinedDef.paths[path][operation]['tags'] = [addPrefix('other', tagsPrefix || apiFilename)]; populateTags({ api, apiFilename, apiTitle, tags: formatTags(['other']), potentialConflicts, tagsPrefix: tagsPrefix || apiFilename, componentsPrefix, }); } if (!security && openapi.hasOwnProperty('security')) { joinedDef.paths[path][operation]['security'] = addSecurityPrefix(openapi.security, componentsPrefix); } else if (pathOperation.security) { joinedDef.paths[path][operation].security = addSecurityPrefix(pathOperation.security, componentsPrefix); } } } function isServersEqual(serverOne, serverTwo) { if (serverOne.description === serverTwo.description) { return (0, utils_1.dequal)(serverOne.variables, serverTwo.variables); } return false; } function collectComponents(openapi, { api, potentialConflicts, componentsPrefix }) { const { components } = openapi; if (components) { if (!joinedDef.hasOwnProperty(types_1.COMPONENTS)) { joinedDef[types_1.COMPONENTS] = {}; } for (const [component, componentObj] of Object.entries(components)) { if (!potentialConflicts[types_1.COMPONENTS].hasOwnProperty(component)) { potentialConflicts[types_1.COMPONENTS][component] = {}; joinedDef[types_1.COMPONENTS][component] = {}; } for (const item of Object.keys(componentObj)) { const componentPrefix = addPrefix(item, componentsPrefix); potentialConflicts.components[component][componentPrefix] = [ ...(potentialConflicts.components[component][item] || []), { [api]: componentObj[item] }, ]; joinedDef.components[component][componentPrefix] = componentObj[item]; } } } } function collectWebhooks(oasVersion, openapi, { apiFilename, apiTitle, api, potentialConflicts, tagsPrefix, componentsPrefix, }) { const webhooks = oasVersion === openapi_core_1.SpecVersion.OAS3_1 ? 'webhooks' : 'x-webhooks'; const openapiWebhooks = openapi[webhooks]; if (openapiWebhooks) { if (!joinedDef.hasOwnProperty(webhooks)) { joinedDef[webhooks] = {}; } for (const webhook of Object.keys(openapiWebhooks)) { joinedDef[webhooks][webhook] = openapiWebhooks[webhook]; if (!potentialConflicts.webhooks.hasOwnProperty(webhook)) { potentialConflicts.webhooks[webhook] = {}; } for (const operation of Object.keys(openapiWebhooks[webhook])) { potentialConflicts.webhooks[webhook][operation] = [ ...(potentialConflicts.webhooks[webhook][operation] || []), api, ]; } for (const operationKey of Object.keys(joinedDef[webhooks][webhook])) { const { tags } = joinedDef[webhooks][webhook][operationKey]; if (tags) { joinedDef[webhooks][webhook][operationKey].tags = tags.map((tag) => addPrefix(tag, tagsPrefix)); populateTags({ api, apiFilename, apiTitle, tags: formatTags(tags), potentialConflicts, tagsPrefix, componentsPrefix, }); } } } } } function addInfoSectionAndSpecVersion(documents, prefixComponentsWithInfoProp) { const firstApi = documents[0]; const openapi = firstApi.parsed; const componentsPrefix = getInfoPrefix(openapi.info, prefixComponentsWithInfoProp, types_1.COMPONENTS); if (!openapi.openapi) (0, miscellaneous_1.exitWithError)('Version of specification is not found.'); if (!openapi.info) (0, miscellaneous_1.exitWithError)('Info section is not found in specification.'); if (openapi.info?.description) { openapi.info.description = addComponentsPrefix(openapi.info.description, componentsPrefix); } joinedDef.openapi = openapi.openapi; joinedDef.info = openapi.info; } } function doesComponentsDiffer(curr, next) { return !(0, utils_1.dequal)(Object.values(curr)[0], Object.values(next)[0]); } function validateComponentsDifference(files) { let isDiffer = false; for (let i = 0, len = files.length; i < len; i++) { const next = files[i + 1]; if (next && doesComponentsDiffer(files[i], next)) { isDiffer = true; } } return isDiffer; } function iteratePotentialConflicts(potentialConflicts, withoutXTagGroups) { for (const group of Object.keys(potentialConflicts)) { for (const [key, value] of Object.entries(potentialConflicts[group])) { const conflicts = filterConflicts(value); if (conflicts.length) { if (group === types_1.COMPONENTS) { for (const [_, conflict] of Object.entries(conflicts)) { if (validateComponentsDifference(conflict[1])) { conflict[1] = conflict[1].map((c) => Object.keys(c)[0]); showConflicts((0, colorette_1.green)(group) + ' => ' + key, [conflict]); potentialConflictsTotal += 1; } } } else { if (withoutXTagGroups && group === 'tags') { duplicateTagDescriptionWarning(conflicts); } else { potentialConflictsTotal += conflicts.length; showConflicts((0, colorette_1.green)(group) + ' => ' + key, conflicts); } } if (group === 'tags' && !withoutXTagGroups) { prefixTagSuggestion(conflicts.length); } } } } } function duplicateTagDescriptionWarning(conflicts) { const tagsKeys = conflicts.map(([tagName]) => `\`${tagName}\``); const joinString = (0, colorette_1.yellow)(', '); process.stderr.write((0, colorette_1.yellow)(`\nwarning: ${tagsKeys.length} conflict(s) on the ${(0, colorette_1.red)(tagsKeys.join(joinString))} tags description.\n`)); } function prefixTagSuggestion(conflictsLength) { process.stderr.write((0, colorette_1.green)(`\n${conflictsLength} conflict(s) on tags.\nSuggestion: please use ${(0, colorette_1.blue)('prefix-tags-with-filename')}, ${(0, colorette_1.blue)('prefix-tags-with-info-prop')} or ${(0, colorette_1.blue)('without-x-tag-groups')} to prevent naming conflicts.\n\n`)); } function showConflicts(key, conflicts) { for (const [path, files] of conflicts) { process.stderr.write((0, colorette_1.yellow)(`Conflict on ${key} : ${(0, colorette_1.red)(path)} in files: ${(0, colorette_1.blue)(files)} \n`)); } } function filterConflicts(entities) { return Object.entries(entities).filter(([_, files]) => files.length > 1); } function getApiFilename(filePath) { return path.basename(filePath, path.extname(filePath)); } function addPrefix(tag, tagsPrefix) { return tagsPrefix ? tagsPrefix + '_' + tag : tag; } function formatTags(tags) { return tags.map((tag) => ({ name: tag })); } function addComponentsPrefix(description, componentsPrefix) { return description.replace(/"(#\/components\/.*?)"/g, (match) => { const componentName = path.basename(match); return match.replace(componentName, addPrefix(componentName, componentsPrefix)); }); } function addSecurityPrefix(security, componentsPrefix) { return componentsPrefix ? security?.map((s) => { const joinedSecuritySchema = {}; for (const [key, value] of Object.entries(s)) { Object.assign(joinedSecuritySchema, { [componentsPrefix + '_' + key]: value }); } return joinedSecuritySchema; }) : security; } function getInfoPrefix(info, prefixArg, type) { if (!prefixArg) return ''; if (!info) (0, miscellaneous_1.exitWithError)('Info section is not found in specification.'); if (!info[prefixArg]) (0, miscellaneous_1.exitWithError)(`${(0, colorette_1.yellow)(`prefix-${type}-with-info-prop`)} argument value is not found in info section.`); if (!(0, js_utils_1.isString)(info[prefixArg])) (0, miscellaneous_1.exitWithError)(`${(0, colorette_1.yellow)(`prefix-${type}-with-info-prop`)} argument value should be string.`); if (info[prefixArg].length > 50) (0, miscellaneous_1.exitWithError)(`${(0, colorette_1.yellow)(`prefix-${type}-with-info-prop`)} argument value length should not exceed 50 characters.`); return info[prefixArg].replaceAll(/\s/g, '_'); } function replace$Refs(obj, componentsPrefix) { (0, split_1.crawl)(obj, (node) => { if (node.$ref && typeof node.$ref === 'string' && (0, split_1.startsWithComponents)(node.$ref)) { const name = path.basename(node.$ref); node.$ref = node.$ref.replace(name, componentsPrefix + '_' + name); } else if ((0, js_utils_1.isObject)(node.discriminator) && (0, js_utils_1.isObject)(node.discriminator.mapping)) { const { mapping } = node.discriminator; for (const name of Object.keys(mapping)) { const mappingPointer = mapping[name]; if (typeof mappingPointer === 'string' && (0, split_1.startsWithComponents)(mappingPointer)) { mapping[name] = mappingPointer .split('/') .map((name, i, arr) => { return arr.length - 1 === i && !name.includes(componentsPrefix) ? componentsPrefix + '_' + name : name; }) .join('/'); } } } }); }