UNPKG

@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
"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