openapi-merger
Version:
Yet another CLI tool for merging multiple OpenAPI files into a single file.
260 lines (236 loc) • 8.43 kB
JavaScript
;
const Path = require("path");
const Url = require("url");
const Glob = require("glob");
const _ = require("lodash");
const { readYAML } = require("./yaml");
const { getRefType, shouldInclude } = require("./ref");
const { download } = require("./http");
const {
sliceObject,
parseUrl,
filterObject,
appendObjectKeys,
prependObjectKeys,
mergeOrOverwrite,
IncludedArray,
} = require("./util");
const { ComponentManager, ComponentNameResolver } = require("./components");
const log = require("loglevel");
class Merger {
static INCLUDE_PATTERN = /^\$include(#\w+?)?(\.\w+?)?$/;
constructor(config) {
this.config = config;
}
// noinspection JSUnusedGlobalSymbols
/**
* Merge OpenAPI document into the single file.
* @param doc {object} OpenAPI document object
* @param docPath {string} OpenAPI document file path
* @returns merged OpenAPI object
*/
merge = async (doc, docPath) => {
docPath = Path.resolve(process.cwd(), docPath);
this.baseDir = Path.dirname(docPath);
// convert to posix style path.
// this path works with fs module like a charm on both windows and unix.
docPath = parseUrl(docPath).path;
// 1st merge: list all components
this.manager = new ComponentManager();
await this.mergeRefs(doc, docPath, "$");
// resolve component names in case of conflict
const nameResolver = new ComponentNameResolver(this.manager.components);
// 2nd merge: merge them all
this.manager = new ComponentManager(nameResolver);
doc = await this.mergeRefs(doc, docPath, "$");
doc.components = _.merge(doc.components, this.manager.getComponentsSection());
return doc;
};
/**
* Merges remote/URL references and inclusions in an object recursively.
* @param obj a target object or array
* @param file the name of the file containing the target object
* @param jsonPath a JSON path for accessing the target object
* @returns {Promise<*[]|*>} a merged object or array
*/
mergeRefs = async (obj, file, jsonPath) => {
if (!_.isObject(obj)) {
return obj;
}
let ret = _.isArray(obj) ? [] : {};
for (const [key, val] of Object.entries(obj)) {
if (this.isRef(key, jsonPath)) {
await this.handleRef(ret, key, val, file, jsonPath);
} else if (this.isInclude(key)) {
ret = await this.handleInclude(ret, key, val, file, jsonPath);
} else {
// go recursively
const merged = await this.mergeRefs(val, file, `${jsonPath}.${key}`);
// merge arrays or objects according their type
if (merged instanceof IncludedArray && _.isArray(ret)) {
ret = mergeOrOverwrite(ret, merged);
} else {
ret[key] = mergeOrOverwrite(ret[key], merged);
}
}
}
return ret;
};
isRef = (key, jsonPath) => {
return key === "$ref" || jsonPath.endsWith("discriminator.mapping");
};
/**
* Converts a remote/URL reference into local ones.
* @param obj an object with a reference
* @param key the key of the reference
* @param val the value of the reference
* @param file a name of the file containing the target object
* @param jsonPath a JSON path for accessing the target object
*/
handleRef = async (obj, key, val, file, jsonPath) => {
log.debug(`ref : ${jsonPath} file=${Path.relative(this.baseDir, file)}`);
obj[key] = mergeOrOverwrite(obj[key], val);
const pRef = parseUrl(val);
const pFile = parseUrl(file);
const refType = getRefType(jsonPath);
if (shouldInclude(refType)) {
await this.handleInclude(obj, key, val, file, jsonPath);
return;
}
let cmp, nextFile, cmpExists;
if (pRef.isHttp) {
// URL ref
cmpExists = this.manager.exists(pRef.href);
cmp = await this.manager.getOrCreate(refType, pRef.href);
nextFile = pRef.hrefWoHash;
} else if (pRef.isLocal) {
// local ref
// avoid infinite loop
if (this.manager.exists(val)) {
return;
}
const href = pFile.hrefWoHash + (pRef.hash === "#/" ? "" : pRef.hash);
cmpExists = this.manager.exists(href);
cmp = await this.manager.getOrCreate(refType, href);
nextFile = pFile.hrefWoHash;
} else {
// remote ref
let target;
if (pFile.isHttp) {
target = Url.resolve(Path.dirname(pFile.hrefWoHash) + "/", val);
} else {
target = Path.posix.join(Path.posix.dirname(pFile.hrefWoHash), val);
}
const parsedTarget = parseUrl(target);
cmpExists = this.manager.exists(target);
cmp = await this.manager.getOrCreate(refType, target);
nextFile = parsedTarget.hrefWoHash;
}
obj[key] = cmp.getLocalRef();
// avoid infinite loop on recursive definition
if (!cmpExists) {
cmp.content = await this.mergeRefs(cmp.content, nextFile, `${jsonPath}.${key}`);
}
};
isInclude = (key) => {
return key.match(Merger.INCLUDE_PATTERN);
};
/**
* Convert an inclusion into its contents.
* @param obj an object with an inclusion
* @param key the key of the inclusion
* @param val the value of the inclusion
* @param file a name of the file containing the target object
* @param jsonPath a JSON path for accessing the target object
* @returns {Promise<*>} a result object or array
*/
handleInclude = async (obj, key, val, file, jsonPath) => {
log.debug(`include: ${jsonPath} file=${Path.relative(this.baseDir, file)}`);
obj[key] = mergeOrOverwrite(obj[key], val);
const pRef = parseUrl(val);
const pFile = parseUrl(file);
let content, nextFile;
if (pRef.isHttp) {
// URL ref
content = await download(pRef.hrefWoHash);
nextFile = pRef.hrefWoHash;
} else if (pRef.isLocal) {
// local ref
// avoid infinite loop
if (this.manager.get(val)) {
return obj;
}
content = readYAML(file);
nextFile = pFile.hrefWoHash;
} else {
// remote ref
let target;
if (pFile.isHttp) {
target = Url.resolve(Path.dirname(pFile.hrefWoHash) + "/", val);
} else {
target = Path.posix.join(Path.posix.dirname(pFile.hrefWoHash), val);
}
const parsedTarget = parseUrl(target);
if (parsedTarget.isHttp) {
content = await download(parsedTarget.hrefWoHash);
} else {
// handle glob pattern
content = {};
if (parsedTarget.hrefWoHash.includes("*")) {
const matchedFiles = Glob.sync(parsedTarget.hrefWoHash).map((p) =>
Path.relative(Path.dirname(pFile.hrefWoHash), p),
);
// include multiple files
for (const mf of matchedFiles) {
const basename = Path.basename(mf, Path.extname(mf));
content[basename] = await this.handleInclude({ [key]: mf }, key, mf, file, `${jsonPath}.${basename}`);
}
} else {
// include a single file
content = readYAML(parsedTarget.hrefWoHash);
}
}
nextFile = parsedTarget.hrefWoHash;
}
const sliced = sliceObject(content, pRef.hash);
const merged = await this.mergeRefs(sliced, nextFile, jsonPath);
if (_.isArray(merged)) {
if (_.isArray(obj)) {
// merge array
obj = obj.concat(merged);
} else if (Object.keys(obj).length === 1) {
// object having one and only $include key, turn into array.
obj = IncludedArray.from(merged);
} else {
throw new Error(`cannot merge array content object. $include: ${val} at jsonPath=${jsonPath}`);
}
} else {
// merge object
const processed = processInclude(key, merged, this.config);
_.merge(obj, processed);
delete obj[key];
}
return obj;
};
}
function processInclude(key, obj, config) {
const clazz = getIncludeClass(key);
if (!clazz) {
return obj;
}
const clazzConfig = config.include[clazz];
if (!clazzConfig) {
log.warn(`$include classname '${clazz} specified, but no configuration found.`);
return obj;
}
obj = filterObject(obj, clazzConfig.filter);
obj = appendObjectKeys(obj, clazzConfig.prefix);
obj = prependObjectKeys(obj, clazzConfig.suffix);
return obj;
}
function getIncludeClass(key) {
const groups = key.match(Merger.INCLUDE_PATTERN);
const pattern = groups ? groups[2] : null;
return pattern ? pattern.substr(1) : null;
}
module.exports = Merger;