@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
259 lines • 12.2 kB
JavaScript
import { red, blue, green } from 'colorette';
import * as fs from 'node:fs';
import { parseYaml, slash, isRef, isTruthy, dequal, logger, isEmptyObject, isPlainObject, } from '@redocly/openapi-core';
import * as path from 'node:path';
import { performance } from 'perf_hooks';
import { printExecutionTime, pathToFilename, readYaml, escapeLanguageName, langToExt, writeToFileByExtension, getAndValidateFileExtension, } from '../../utils/miscellaneous.js';
import { exitWithError } from '../../utils/error.js';
import { COMPONENTS, OPENAPI3_METHOD_NAMES, OPENAPI3_COMPONENT_NAMES } from './types.js';
export async function handleSplit({ argv, collectSpecData }) {
const startedAt = performance.now();
const { api, outDir, separator } = argv;
validateDefinitionFileName(api);
const ext = getAndValidateFileExtension(api);
const openapi = readYaml(api);
collectSpecData?.(openapi);
splitDefinition(openapi, outDir, separator, ext);
logger.info(`🪓 Document: ${blue(api)} ${green('is successfully split')}
and all related files are saved to the directory: ${blue(outDir)} \n`);
printExecutionTime('split', startedAt, api);
}
function splitDefinition(openapi, openapiDir, pathSeparator, ext) {
fs.mkdirSync(openapiDir, { recursive: true });
const componentsFiles = {};
iterateComponents(openapi, openapiDir, componentsFiles, ext);
iteratePathItems(openapi.paths, openapiDir, path.join(openapiDir, 'paths'), componentsFiles, pathSeparator, undefined, ext);
const webhooks = openapi.webhooks || openapi['x-webhooks'];
// use webhook_ prefix for code samples to prevent potential name-clashes with paths samples
iteratePathItems(webhooks, openapiDir, path.join(openapiDir, 'webhooks'), componentsFiles, pathSeparator, 'webhook_', ext);
replace$Refs(openapi, openapiDir, componentsFiles);
writeToFileByExtension(openapi, path.join(openapiDir, `openapi.${ext}`));
}
export function startsWithComponents(node) {
return node.startsWith(`#/${COMPONENTS}/`);
}
function isSupportedExtension(filename) {
return filename.endsWith('.yaml') || filename.endsWith('.yml') || filename.endsWith('.json');
}
function loadFile(fileName) {
try {
return parseYaml(fs.readFileSync(fileName, 'utf8'));
}
catch (e) {
return exitWithError(e.message);
}
}
function validateDefinitionFileName(fileName) {
if (!fs.existsSync(fileName))
exitWithError(`File ${blue(fileName)} does not exist.`);
const file = loadFile(fileName);
if (file.swagger)
exitWithError('OpenAPI 2 is not supported by this command.');
if (!file.openapi)
exitWithError('File does not conform to the OpenAPI Specification. OpenAPI version is not specified.');
return true;
}
function traverseDirectoryDeep(directory, callback, componentsFiles) {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory())
return;
const files = fs.readdirSync(directory);
for (const f of files) {
const filename = path.join(directory, f);
if (fs.statSync(filename).isDirectory()) {
traverseDirectoryDeep(filename, callback, componentsFiles);
}
else {
callback(filename, directory, componentsFiles);
}
}
}
function traverseDirectoryDeepCallback(filename, directory, componentsFiles) {
if (!isSupportedExtension(filename))
return;
const pathData = readYaml(filename);
replace$Refs(pathData, directory, componentsFiles);
writeToFileByExtension(pathData, filename);
}
export function crawl(object, visitor) {
if (isPlainObject(object)) {
visitor(object);
for (const key of Object.keys(object)) {
crawl(object[key], visitor);
}
}
else if (Array.isArray(object)) {
for (const item of object) {
crawl(item, visitor);
}
}
}
function replace$Refs(obj, relativeFrom, componentFiles = {}) {
crawl(obj, (node) => {
if (isRef(node) && startsWithComponents(node.$ref)) {
replace(node, '$ref');
}
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)) {
replace(node.discriminator.mapping, name);
}
}
}
});
function replace(node, key) {
const splittedNode = node[key].split('/');
const name = splittedNode.pop();
const groupName = splittedNode[2];
const filesGroupName = componentFiles[groupName];
if (!filesGroupName || !filesGroupName[name])
return;
let filename = slash(path.relative(relativeFrom, filesGroupName[name].filename));
if (!filename.startsWith('.')) {
filename = './' + filename;
}
node[key] = filename;
}
}
function implicitlyReferenceDiscriminator(obj, defName, filename, schemaFiles) {
if (!obj.discriminator)
return;
const defPtr = `#/${COMPONENTS}/${'schemas'}/${defName}`;
const implicitMapping = {};
for (const [name, { inherits, filename: parentFilename }] of Object.entries(schemaFiles)) {
if (inherits.indexOf(defPtr) > -1) {
const res = slash(path.relative(path.dirname(filename), parentFilename));
implicitMapping[name] = res.startsWith('.') ? res : './' + res;
}
}
if (isEmptyObject(implicitMapping))
return;
const discriminatorPropSchema = obj.properties[obj.discriminator.propertyName];
const discriminatorEnum = discriminatorPropSchema && discriminatorPropSchema.enum;
const mapping = (obj.discriminator.mapping = obj.discriminator.mapping || {});
for (const name of Object.keys(implicitMapping)) {
if (discriminatorEnum && !discriminatorEnum.includes(name)) {
continue;
}
if (mapping[name] && mapping[name] !== implicitMapping[name]) {
logger.warn(`warning: explicit mapping overlaps with local mapping entry ${red(name)} at ${blue(filename)}. Please check it.`);
}
mapping[name] = implicitMapping[name];
}
}
function isNotSecurityComponentType(componentType) {
return componentType !== 'securitySchemes';
}
function findComponentTypes(components) {
return OPENAPI3_COMPONENT_NAMES.filter((item) => isNotSecurityComponentType(item) && Object.keys(components).includes(item));
}
function doesFileDiffer(filename, componentData) {
return fs.existsSync(filename) && !dequal(readYaml(filename), componentData);
}
function removeEmptyComponents(openapi, componentType) {
if (openapi.components && isEmptyObject(openapi.components[componentType])) {
delete openapi.components[componentType];
}
if (isEmptyObject(openapi.components)) {
delete openapi.components;
}
}
function createComponentDir(componentDirPath, componentType) {
if (isNotSecurityComponentType(componentType)) {
fs.mkdirSync(componentDirPath, { recursive: true });
}
}
function extractFileNameFromPath(filename) {
return path.basename(filename, path.extname(filename));
}
function getFileNamePath(componentDirPath, componentName, ext) {
return path.join(componentDirPath, componentName) + `.${ext}`;
}
function gatherComponentsFiles(components, componentsFiles, componentType, componentName, filename) {
let inherits = [];
if (componentType === 'schemas') {
inherits = (components?.[componentType]?.[componentName]?.allOf || [])
.map(({ $ref }) => $ref)
.filter(isTruthy);
}
componentsFiles[componentType] = componentsFiles[componentType] || {};
componentsFiles[componentType][componentName] = { inherits, filename };
}
function iteratePathItems(pathItems, openapiDir, outDir, componentsFiles, pathSeparator, codeSamplesPathPrefix = '', ext) {
if (!pathItems)
return;
fs.mkdirSync(outDir, { recursive: true });
for (const pathName of Object.keys(pathItems)) {
const pathFile = `${path.join(outDir, pathToFilename(pathName, pathSeparator))}.${ext}`;
const pathData = pathItems[pathName];
if (isRef(pathData))
continue;
for (const method of OPENAPI3_METHOD_NAMES) {
const methodData = pathData[method];
const methodDataXCode = methodData?.['x-code-samples'] || methodData?.['x-codeSamples'];
if (!methodDataXCode || !Array.isArray(methodDataXCode)) {
continue;
}
for (const sample of methodDataXCode) {
if (sample.source && sample.source.$ref)
continue;
const sampleFileName = path.join(openapiDir, 'code_samples', escapeLanguageName(sample.lang), codeSamplesPathPrefix + pathToFilename(pathName, pathSeparator), method + langToExt(sample.lang));
fs.mkdirSync(path.dirname(sampleFileName), { recursive: true });
fs.writeFileSync(sampleFileName, sample.source);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
sample.source = {
$ref: slash(path.relative(outDir, sampleFileName)),
};
}
}
writeToFileByExtension(pathData, pathFile);
pathItems[pathName] = {
$ref: slash(path.relative(openapiDir, pathFile)),
};
traverseDirectoryDeep(outDir, traverseDirectoryDeepCallback, componentsFiles);
}
}
function iterateComponents(openapi, openapiDir, componentsFiles, ext) {
const { components } = openapi;
if (components) {
const componentsDir = path.join(openapiDir, COMPONENTS);
fs.mkdirSync(componentsDir, { recursive: true });
const componentTypes = findComponentTypes(components);
componentTypes.forEach(iterateAndGatherComponentsFiles);
componentTypes.forEach(iterateComponentTypes);
// eslint-disable-next-line no-inner-declarations
function iterateAndGatherComponentsFiles(componentType) {
const componentDirPath = path.join(componentsDir, componentType);
for (const componentName of Object.keys(components?.[componentType] || {})) {
const filename = getFileNamePath(componentDirPath, componentName, ext);
gatherComponentsFiles(components, componentsFiles, componentType, componentName, filename);
}
}
// eslint-disable-next-line no-inner-declarations
function iterateComponentTypes(componentType) {
const componentDirPath = path.join(componentsDir, componentType);
createComponentDir(componentDirPath, componentType);
for (const componentName of Object.keys(components?.[componentType] || {})) {
const filename = getFileNamePath(componentDirPath, componentName, ext);
const componentData = components?.[componentType]?.[componentName];
replace$Refs(componentData, path.dirname(filename), componentsFiles);
implicitlyReferenceDiscriminator(componentData, extractFileNameFromPath(filename), filename, componentsFiles.schemas || {});
if (doesFileDiffer(filename, componentData)) {
logger.warn(`warning: conflict for ${componentName} - file already exists with different content: ${blue(filename)} ... Skip.\n`);
}
else {
writeToFileByExtension(componentData, filename);
}
if (isNotSecurityComponentType(componentType)) {
// security schemas must referenced from components
delete openapi.components?.[componentType]?.[componentName];
}
}
removeEmptyComponents(openapi, componentType);
}
}
}
export { iteratePathItems };
//# sourceMappingURL=index.js.map