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

575 lines 26.4 kB
import * as path from 'node:path'; import { red, blue, yellow, green } from 'colorette'; import { performance } from 'node:perf_hooks'; import { BaseResolver, formatProblems, getTotals, detectSpec, bundleDocument, isRef, dequal, logger, isString, isPlainObject, keysOf, isEmptyObject, getTypes, } from '@redocly/openapi-core'; import { getFallbackApisOrExit, printExecutionTime, sortTopLevelKeysForOas, getAndValidateFileExtension, writeToFileByExtension, } from '../utils/miscellaneous.js'; import { exitWithError } from '../utils/error.js'; import { COMPONENTS, OPENAPI3_METHOD_NAMES } from './split/types.js'; import { crawl, startsWithComponents } from './split/index.js'; const Tags = 'tags'; const xTagGroups = 'x-tagGroups'; let potentialConflictsTotal = 0; export async function handleJoin({ argv, config, version: packageVersion, collectSpecData, }) { const startedAt = 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 exitWithError(`You use ${yellow(usedTagsOptions.join(', '))} together.\nPlease choose only one!`); } const apis = await getFallbackApisOrExit(argv.apis, config); if (apis.length < 2) { return exitWithError(`At least 2 APIs should be provided.`); } const fileExtension = getAndValidateFileExtension(output || apis[0].path); const specFilename = output || `openapi.${fileExtension}`; const externalRefResolver = new BaseResolver(config.resolve); const documents = await Promise.all(apis.map(({ path }) => externalRefResolver.resolveDocument(null, path, true))); const decorators = new Set([ ...Object.keys(config.decorators.oas3_0), ...Object.keys(config.decorators.oas3_1), ...Object.keys(config.decorators.oas2), ]); config.skipDecorators(Array.from(decorators)); const preprocessors = new Set([ ...Object.keys(config.preprocessors.oas3_0), ...Object.keys(config.preprocessors.oas3_1), ...Object.keys(config.preprocessors.oas2), ]); config.skipPreprocessors(Array.from(preprocessors)); const bundleResults = await Promise.all(documents.map((document) => bundleDocument({ document, config, externalRefResolver: new BaseResolver(config.resolve), types: getTypes(detectSpec(document.parsed)), }).catch((e) => { exitWithError(`${e.message}: ${blue(document.source.absoluteRef)}`); }))); for (const { problems, bundle: document } of bundleResults) { const fileTotals = getTotals(problems); if (fileTotals.errors) { formatProblems(problems, { totals: fileTotals, version: packageVersion, }); exitWithError(`❌ Errors encountered while bundling ${blue(document.source.absoluteRef)}: join will not proceed.`); } } let oasVersion = null; for (const document of documents) { try { const version = detectSpec(document.parsed); collectSpecData?.(document.parsed); if (version !== 'oas3_0' && version !== 'oas3_1' && version !== 'oas3_2') { return exitWithError(`Only OpenAPI 3.0, 3.1, and 3.2 are supported: ${blue(document.source.absoluteRef)}.`); } oasVersion = oasVersion ?? version; if (oasVersion !== version) { return exitWithError(`All APIs must use the same OpenAPI version: ${blue(document.source.absoluteRef)}.`); } } catch (e) { return exitWithError(`${e.message}: ${blue(document.source.absoluteRef)}.`); } } const [first, ...others] = (documents ?? []); const serversAreTheSame = others.every(({ parsed: { paths, servers } }) => { // include only documents with paths if (!paths || isEmptyObject(paths || {})) { return true; } return servers?.every((server) => first.parsed.servers?.find(({ url }) => url === server.url)); }); const joinedDef = {}; const potentialConflicts = { tags: {}, paths: {}, components: {}, webhooks: {}, }; addInfoSectionAndSpecVersion(documents, prefixComponentsWithInfoProp); if (serversAreTheSame && first.parsed.servers) { joinedDef.servers = first.parsed.servers; } for (const document of documents) { const openapi = isPlainObject(document.parsed) ? 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, COMPONENTS); if (openapi.hasOwnProperty('x-tagGroups')) { logger.warn(`warning: x-tagGroups at ${blue(api)} will be skipped \n`); } const context = { api, apiFilename, apiTitle: info?.title, tags, potentialConflicts, tagsPrefix, componentsPrefix, oasVersion, }; if (tags) { populateTags(context); } collectExternalDocs(openapi, context); collectPaths(openapi, context, serversAreTheSame); collectComponents(openapi, context); collectWebhooks(openapi, context); if (componentsPrefix) { replace$Refs(openapi, componentsPrefix); } } iteratePotentialConflicts(potentialConflicts, withoutXTagGroups); const noRefs = true; if (potentialConflictsTotal) { return exitWithError(`Please fix conflicts before running ${yellow('join')}.`); } writeToFileByExtension(sortTopLevelKeysForOas(joinedDef), specFilename, noRefs); printExecutionTime('join', startedAt, specFilename); function populateTags({ api, apiFilename, apiTitle, tags, potentialConflicts, tagsPrefix, componentsPrefix, oasVersion, }) { 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; if (oasVersion === 'oas3_0' || oasVersion === 'oas3_1') { tag['x-displayName'] = tag['x-displayName'] || tag.name; } else if (oasVersion === 'oas3_2') { tag.summary = tag.summary || tag.name; } tag.name = entrypointTagName; joinedDef.tags.push(tag); if (withoutXTagGroups) { potentialConflicts.tags.description[entrypointTagName] = [api]; } } if (!withoutXTagGroups && oasVersion !== 'oas3_2') { 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 collectExternalDocs(openapi, { api }) { const { externalDocs } = openapi; if (externalDocs) { if (joinedDef.hasOwnProperty('externalDocs')) { logger.warn(`warning: skip externalDocs from ${blue(path.basename(api))} \n`); return; } joinedDef['externalDocs'] = externalDocs; } } function collectPaths(openapi, { apiFilename, apiTitle, api, potentialConflicts, tagsPrefix, componentsPrefix, oasVersion, }, serversAreTheSame) { const { paths, servers: rootServers } = openapi; const operationsSet = new Set(OPENAPI3_METHOD_NAMES); if (paths) { if (!joinedDef.hasOwnProperty('paths')) { joinedDef['paths'] = {}; } for (const path of keysOf(paths)) { if (!joinedDef.paths.hasOwnProperty(path)) { joinedDef.paths[path] = {}; } if (!potentialConflicts.paths.hasOwnProperty(path)) { potentialConflicts.paths[path] = {}; } const pathItem = paths[path]; const servers = serversAreTheSame ? pathItem.servers : pathItem.servers || rootServers || []; if (servers) { collectPathServers(servers, path); } for (const field of keysOf(pathItem)) { if (operationsSet.has(field)) { collectPathOperation(pathItem, path, field); } 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) { logger.warn(`warning: different ${field} values in ${path}\n`); return; } joinedDef.paths[path][field] = fieldValue; } function collectPathServers(servers, path) { if (!servers) { return; } if (!joinedDef.paths[path].hasOwnProperty('servers')) { joinedDef.paths[path].servers = []; } for (const server of servers) { let isFoundServer = false; for (const pathServer of joinedDef.paths[path].servers) { if (pathServer.url === server.url) { if (!isServersEqual(pathServer, server)) { 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 (isRef(pathParameter) && isRef(parameter)) { if (pathParameter['$ref'] === parameter['$ref']) { isFoundParameter = true; } } // Compare properties only if both are reference objects if (!isRef(pathParameter) && !isRef(parameter)) { if (pathParameter.name === parameter.name && pathParameter.in === parameter.in) { if (!dequal(pathParameter.schema, parameter.schema)) { 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, oasVersion, }); } else { joinedDef.paths[path][operation]['tags'] = [addPrefix('other', tagsPrefix || apiFilename)]; populateTags({ api, apiFilename, apiTitle, tags: formatTags(['other']), potentialConflicts, tagsPrefix: tagsPrefix || apiFilename, componentsPrefix, oasVersion, }); } 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 dequal(serverOne.variables, serverTwo.variables); } return false; } function collectComponents(openapi, { api, potentialConflicts, componentsPrefix }) { const { components } = openapi; if (components) { if (!joinedDef.hasOwnProperty(COMPONENTS)) { joinedDef[COMPONENTS] = {}; } for (const [component, componentObj] of Object.entries(components)) { if (!potentialConflicts[COMPONENTS].hasOwnProperty(component)) { potentialConflicts[COMPONENTS][component] = {}; joinedDef[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(openapi, { apiFilename, apiTitle, api, potentialConflicts, tagsPrefix, componentsPrefix, oasVersion, }) { const webhooks = oasVersion === 'oas3_0' ? 'x-webhooks' : '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, oasVersion, }); } } } } } function addInfoSectionAndSpecVersion(documents, prefixComponentsWithInfoProp) { const firstApi = documents[0]; const openapi = firstApi.parsed; const componentsPrefix = getInfoPrefix(openapi.info, prefixComponentsWithInfoProp, COMPONENTS); if (!openapi.openapi) exitWithError('Version of specification is not found.'); if (!openapi.info) 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 !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 === COMPONENTS) { for (const [_, conflict] of Object.entries(conflicts)) { if (validateComponentsDifference(conflict[1])) { conflict[1] = conflict[1].map((c) => Object.keys(c)[0]); showConflicts(green(group) + ' => ' + key, [conflict]); potentialConflictsTotal += 1; } } } else { if (withoutXTagGroups && group === 'tags') { duplicateTagDescriptionWarning(conflicts); } else { potentialConflictsTotal += conflicts.length; showConflicts(green(group) + ' => ' + key, conflicts); } } if (group === 'tags' && !withoutXTagGroups) { prefixTagSuggestion(conflicts.length); } } } } } function duplicateTagDescriptionWarning(conflicts) { const tagsKeys = conflicts.map(([tagName]) => `\`${tagName}\``); const joinString = yellow(', '); logger.warn(`\nwarning: ${tagsKeys.length} conflict(s) on the ${red(tagsKeys.join(joinString))} tags description.\n`); } function prefixTagSuggestion(conflictsLength) { logger.info(green(`\n${conflictsLength} conflict(s) on tags.\nSuggestion: please use ${blue('prefix-tags-with-filename')}, ${blue('prefix-tags-with-info-prop')} or ${blue('without-x-tag-groups')} to prevent naming conflicts.\n\n`)); } function showConflicts(key, conflicts) { for (const [path, files] of conflicts) { logger.warn(`Conflict on ${key} : ${red(path)} in files: ${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) exitWithError('Info section is not found in specification.'); if (!info[prefixArg]) exitWithError(`${yellow(`prefix-${type}-with-info-prop`)} argument value is not found in info section.`); if (!isString(info[prefixArg])) exitWithError(`${yellow(`prefix-${type}-with-info-prop`)} argument value should be string.`); if (info[prefixArg].length > 50) exitWithError(`${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) { crawl(obj, (node) => { if (isRef(node) && startsWithComponents(node.$ref)) { const name = path.basename(node.$ref); node.$ref = node.$ref.replace(name, componentsPrefix + '_' + name); } else if (isPlainObject(node.discriminator) && isPlainObject(node.discriminator.mapping)) { const { mapping } = node.discriminator; for (const name of Object.keys(mapping)) { const mappingPointer = mapping[name]; if (typeof mappingPointer === 'string' && startsWithComponents(mappingPointer)) { mapping[name] = mappingPointer .split('/') .map((name, i, arr) => { return arr.length - 1 === i && !name.includes(componentsPrefix) ? componentsPrefix + '_' + name : name; }) .join('/'); } } } }); } //# sourceMappingURL=join.js.map