@apiture/api-ref-resolver
Version:
Tool to merge multiple OpenAPI or AsyncAPI documents that use JSON Reference links (`$ref`) to reference API definition elements across source files.
365 lines • 15.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ApiRefResolver = void 0;
const assert_1 = require("assert");
const fs = require("fs");
const path = require("path");
const url_1 = require("url");
const bl = require("bl");
const yaml = require("js-yaml");
const JsonNavigation_1 = require("./JsonNavigation");
const RefVisitor_1 = require("./RefVisitor");
const v8 = require("v8");
class ApiRefResolver {
constructor(uri, apiDocument) {
this.resolvedRefToRefMap = {};
this.urlToApiObjectMap = {};
this.dateTime = new Date().toISOString();
this.alreadyRewritten = { path: {}, fragment: {} };
if (typeof uri === 'string') {
if (/^\w+:/.exec(uri)) {
this.url = new url_1.URL(uri);
}
else {
this.url = (0, url_1.pathToFileURL)(path.resolve(process.cwd(), uri));
}
}
else {
this.url = uri;
}
if (apiDocument) {
this.apiDocument = apiDocument;
}
}
async resolve(options) {
this.options = options || {};
if (!this.apiDocument) {
const apiResource = await this.api(this.url);
this.apiDocument = apiResource.api;
}
if (this.apiDocument['x-resolved-from']) {
return { api: this.apiDocument, options: this.options };
}
this.urlToApiObjectMap[this.url.href] = this.apiDocument;
const refVisitor = (node, nav) => this.refResolvingVisitor(node, nav);
this.changed = true;
let pass = 0;
while (this.changed) {
pass = pass + 1;
this.changed = false;
this.apiDocument = (await (0, RefVisitor_1.visitRefObjects)(this.apiDocument, refVisitor));
if (this.changed) {
this.note(`Pass ${pass} of resolve() resulted in changed $ref. Starting next pass.`);
}
}
this.tag(this.apiDocument, this.url, undefined, true);
this.apiDocument = await this.cleanup(this.apiDocument);
return { api: this.apiDocument, options: this.options };
}
async cleanup(resolved) {
return (await (0, RefVisitor_1.walkObject)(resolved, async (node) => {
if (this.options.noMarkers) {
if (node.hasOwnProperty(ApiRefResolver.RESOLVED_FROM_MARKER)) {
delete node[ApiRefResolver.RESOLVED_FROM_MARKER];
}
if (node.hasOwnProperty(ApiRefResolver.RESOLVED_AT_MARKER)) {
delete node[ApiRefResolver.RESOLVED_AT_MARKER];
}
}
if (node.hasOwnProperty(ApiRefResolver.TEMPORARY_MARKER)) {
delete node[ApiRefResolver.TEMPORARY_MARKER];
}
return node;
}));
}
replacementRefFor(reference) {
return this.resolvedRefToRefMap[reference];
}
rememberReplacementForRef(reference, replacementRef) {
(0, assert_1.strict)(!this.resolvedRefToRefMap[reference], `ref ${reference} already has a replacement, ${this.resolvedRefToRefMap[reference]}`);
this.note(`Replace $ref URL ${reference} with ${replacementRef}`);
this.resolvedRefToRefMap[reference] = ApiRefResolver.deepClone(replacementRef);
}
async readFromFile(url) {
const fileUrl = ApiRefResolver.urlNonFragment(url);
const filePath = (0, url_1.fileURLToPath)(fileUrl);
const text = fs.readFileSync(filePath, { encoding: 'utf8' });
return text;
}
async readFromUrl(url) {
return new Promise((resolve, reject) => {
const protocol = url.protocol === 'http' ? require('http') : require('https');
protocol.get(url, (response) => {
response.setEncoding('utf8');
response.pipe(bl((err, data) => {
if (err) {
reject(err);
}
resolve(data.toString());
}));
});
});
}
async api(uri) {
const url = typeof uri === 'string' ? (0, url_1.pathToFileURL)(uri) : uri;
const urlKey = ApiRefResolver.urlNonFragment(url);
const fragment = ApiRefResolver.urlFragment(url);
const itemPath = fragment ? JsonNavigation_1.JsonNavigation.asKeys(fragment) : undefined;
let api = this.urlToApiObjectMap[urlKey.href];
if (api) {
return {
url,
api,
fragment,
itemPath,
};
}
const text = url.protocol === 'file:' ? await this.readFromFile(url) : await this.readFromUrl(url);
api = yaml.load(text, { filename: url.href, schema: yaml.JSON_SCHEMA });
this.urlToApiObjectMap[urlKey.href] = api;
this.note(`loaded API document from ${url.href}`);
return {
url: urlKey,
api,
fragment,
itemPath,
};
}
note(message) {
if (this.options.verbose) {
console.log(`api-ref-resolver: ${message}`);
}
}
static urlNonFragment(url) {
const urlNonFragment = new url_1.URL(url.href);
urlNonFragment.hash = '';
return urlNonFragment;
}
async refResolvingVisitor(refObject, nav) {
const ref = refObject.$ref;
if (ref.startsWith('#')) {
return refObject;
}
const replacementRef = this.replacementRefFor(ref);
if (replacementRef) {
refObject.$ref = replacementRef;
return refObject;
}
this.changed = true;
const url = this.relativeUrl(ref);
const fragment = ApiRefResolver.urlFragment(url);
if (!fragment) {
return await this.processFullReplacement(url, refObject, nav);
}
if (ApiRefResolver.COMPONENT_REGEXP.exec(fragment)) {
return await this.processComponentReplacement(url, refObject, nav);
}
return await this.processOtherReplacement(url, refObject, ref, nav);
}
static urlFragment(url) {
return url.hash === '' ? undefined : url.hash;
}
relativeUrl(ref, baseUrl) {
if (ref.startsWith('http:') || ref.startsWith('http:')) {
return new url_1.URL(ref);
}
const relUrl = new url_1.URL(ref, baseUrl !== null && baseUrl !== void 0 ? baseUrl : this.url.href);
return relUrl;
}
async rewriteRefPaths(documentUrl, api) {
const nonFragmentUrl = ApiRefResolver.urlNonFragment(documentUrl);
if (this.areRefsAlreadyRewritten(nonFragmentUrl, 'path')) {
return;
}
const refRewriteVisitor = async (node) => {
const refNormalizedUrl = new url_1.URL(node.$ref, nonFragmentUrl);
node.$ref = refNormalizedUrl.href;
return node;
};
await (0, RefVisitor_1.visitRefObjects)(api, refRewriteVisitor);
this.markRefsAlreadyRewritten(nonFragmentUrl, 'path');
}
async rewriteRefFragments(documentUrl, api, nav) {
const nonFragmentUrl = ApiRefResolver.urlNonFragment(documentUrl);
if (this.areRefsAlreadyRewritten(nonFragmentUrl, 'fragment')) {
return;
}
const prefix = nav.asFragment();
const refRewriteVisitor = async (node) => {
const ref = node.$ref;
if (ref.startsWith('#')) {
node.$ref = `${prefix}${ref.substring(1)}`;
}
return node;
};
await (0, RefVisitor_1.visitRefObjects)(api, refRewriteVisitor);
this.markRefsAlreadyRewritten(nonFragmentUrl, 'fragment');
}
areRefsAlreadyRewritten(normalizedApiDocUrl, what) {
const map = this.alreadyRewritten[what];
const key = normalizedApiDocUrl.href;
const alreadyRewritten = !!map[key];
return alreadyRewritten;
}
markRefsAlreadyRewritten(normalizedApiDocUrl, what) {
const map = this.alreadyRewritten[what];
const key = normalizedApiDocUrl.href;
map[key] = true;
}
checkComponentConflict(refObject, componentKeys, originalUrl) {
var _a, _b, _c, _d, _e;
(0, assert_1.strict)(componentKeys[0] === 'components');
const urlNoFragment = ApiRefResolver.urlNonFragment(originalUrl);
const sectionName = componentKeys[1];
const componentName = componentKeys[2];
if (!this.apiDocument['components']) {
this.apiDocument['components'] = {};
}
const components = this.apiDocument['components'];
if (!components[sectionName]) {
components[sectionName] = {};
}
const section = components[sectionName];
const existing = (_c = (_b = (_a = this.apiDocument) === null || _a === void 0 ? void 0 : _a[componentKeys[0]]) === null || _b === void 0 ? void 0 : _b[sectionName]) === null || _c === void 0 ? void 0 : _c[componentKeys[2]];
if (!existing) {
return { section, sectionName, componentName };
}
if (existing === refObject) {
return { section, sectionName, componentName };
}
const resolvedFrom = existing['x-resolved-from'];
const sameResolution = resolvedFrom === urlNoFragment.href;
if (!sameResolution && ((_d = this.options) === null || _d === void 0 ? void 0 : _d.conflictStrategy) === 'error') {
const resolvedFromText = resolvedFrom ? ` from ${resolvedFrom}` : '';
throw new Error(`Cannot embed component ${componentKeys} from ${originalUrl.href}: component already exists${resolvedFromText}`);
}
if (((_e = this.options) === null || _e === void 0 ? void 0 : _e.conflictStrategy) === 'ignore') {
this.note(`Component conflict ignored. ${componentName} found at both ${resolvedFrom} and ${urlNoFragment.href}`);
return { section, sectionName, componentName };
}
let candidateName = componentName;
let suffix = 0;
while (section.hasOwnProperty(candidateName)) {
suffix += 1;
candidateName = `${componentName}${suffix}`;
}
componentKeys[2] = candidateName;
this.note(`Renamed components.${sectionName}.${componentName} from ${urlNoFragment.href} as ${candidateName}`);
return { section, sectionName, componentName: candidateName };
}
mergeRefObject(refObject, apiElement) {
if (typeof apiElement !== 'object') {
return;
}
const refProperties = { ...refObject };
delete refProperties['$ref'];
const clone = ApiRefResolver.deepClone(apiElement);
const merged = { ...clone, ...refProperties };
return merged;
}
async processComponentReplacement(normalizedRefUrl, refObject, nav) {
(0, assert_1.strict)(nav);
(0, assert_1.strict)(normalizedRefUrl.hash);
(0, assert_1.strict)(ApiRefResolver.COMPONENT_REGEXP.exec(normalizedRefUrl.hash));
const reference = normalizedRefUrl.href;
const seen = this.replacementRefFor(reference);
if (seen) {
refObject.$ref = seen;
return refObject;
}
const urlNoFragment = ApiRefResolver.urlNonFragment(normalizedRefUrl);
const { api, itemPath } = await this.api(normalizedRefUrl);
const baseUrl = new url_1.URL(urlNoFragment.href, this.url);
await this.rewriteRefPaths(baseUrl, api);
const item = this.apiItem(api, itemPath);
const componentKeys = JsonNavigation_1.JsonNavigation.asKeys(normalizedRefUrl.hash);
this.tag(item, normalizedRefUrl, nav);
if (nav.isAtComponent() && this.isSimpleRef(refObject) && this.sameComponentName(nav, componentKeys)) {
this.rememberReplacementForRef(reference, nav.asFragment());
return item;
}
const { section, sectionName, componentName } = this.checkComponentConflict(refObject, componentKeys, normalizedRefUrl);
const newVal = ApiRefResolver.deepClone(item);
section[componentName] = newVal;
const resolvedRef = JsonNavigation_1.JsonNavigation.asFragment(['components', sectionName, componentName], true);
this.rememberReplacementForRef(reference, resolvedRef);
refObject.$ref = resolvedRef;
return refObject;
}
apiItem(api, itemPath) {
return JsonNavigation_1.JsonNavigation.itemAtFragment(api, JsonNavigation_1.JsonNavigation.asFragment(itemPath, true));
}
sameComponentName(nav, componentKeys) {
const path = nav.path();
const nameLeft = path[path.length - 1];
const nameRight = componentKeys[componentKeys.length - 1];
return path.length === 3 && componentKeys.length === 3 && nameLeft === nameRight;
}
isSimpleRef(refObject) {
return Object.keys(refObject).length === 1;
}
async processFullReplacement(normalizedRefUrl, refObject, nav) {
(0, assert_1.strict)(normalizedRefUrl.hash === '');
const reference = normalizedRefUrl.href;
const seen = this.replacementRefFor(reference);
if (seen) {
refObject.$ref = seen;
return refObject;
}
const { api } = await this.api(normalizedRefUrl);
const resolvedRef = nav.asFragment();
this.rememberReplacementForRef(reference, resolvedRef);
await this.rewriteRefFragments(normalizedRefUrl, api, nav);
await this.rewriteRefPaths(normalizedRefUrl, api);
const merged = this.mergeRefObject(refObject, api);
this.tag(merged, normalizedRefUrl, nav);
return merged;
}
async processOtherReplacement(normalizedRefUrl, refObject, reference, nav) {
(0, assert_1.strict)(normalizedRefUrl.hash);
(0, assert_1.strict)(!ApiRefResolver.COMPONENT_REGEXP.exec(normalizedRefUrl.hash));
const seen = this.replacementRefFor(reference);
if (seen) {
refObject.$ref = seen;
return refObject;
}
const { api, itemPath } = await this.api(normalizedRefUrl);
const urlNoFragment = ApiRefResolver.urlNonFragment(normalizedRefUrl);
const baseUrl = new url_1.URL(urlNoFragment.href, this.url);
await this.rewriteRefPaths(baseUrl, api);
const item = this.apiItem(api, itemPath);
const resolvedRef = normalizedRefUrl.hash;
this.rememberReplacementForRef(reference, resolvedRef);
this.tag(item, normalizedRefUrl, nav);
const merged = this.mergeRefObject(refObject, item);
return merged;
}
tag(item, normalizedRefUrl, nav, tagDateTime = false) {
if (item != null && typeof item === 'object') {
const taggable = nav === undefined || this.taggable(item, nav);
if (taggable) {
item[ApiRefResolver.RESOLVED_FROM_MARKER] = normalizedRefUrl.href;
if (tagDateTime) {
item[ApiRefResolver.RESOLVED_AT_MARKER] = this.dateTime;
}
}
item[ApiRefResolver.TEMPORARY_MARKER] = true;
}
}
taggable(item, nav) {
if ((0, RefVisitor_1.isRef)(item)) {
const path = nav.path();
return path.length > 2 && ((path[0] === 'components' && path[1] === 'schemas') || path.includes('schema'));
}
return true;
}
}
exports.ApiRefResolver = ApiRefResolver;
ApiRefResolver.COMPONENT_REGEXP = /^#\/components\/[^\\/]+\/[^\\/]+$/;
ApiRefResolver.TEMPORARY_MARKER = 'x__resolved__';
ApiRefResolver.RESOLVED_FROM_MARKER = 'x-resolved-from';
ApiRefResolver.RESOLVED_AT_MARKER = 'x-resolved-at';
ApiRefResolver.deepClone = (obj) => {
return v8.deserialize(v8.serialize(obj));
};
//# sourceMappingURL=ApiRefResolver.js.map