@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.
904 lines (844 loc) • 33.9 kB
text/typescript
/**
* ApiRefResolver main source file.
* This contains the ApiRefResolver and support types/interfaces
*/
import { strict as assert } from 'assert';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath, pathToFileURL, URL } from 'url';
import * as bl from 'bl';
import * as yaml from 'js-yaml';
import { JsonNavigation, JsonKey, JsonItem } from './JsonNavigation';
import { walkObject, visitRefObjects, RefVisitor, isRef } from './RefVisitor';
import type { Node, RefObject } from './RefVisitor';
import * as v8 from 'v8';
/**
* ApiObject represents an OpenAPI or Async API object
*/
export type ApiObject = object | [] | string | boolean | null | number;
/**
* Location of a component.
* For example for the component at `#/components/schemas/mySchema`
* `.section` is the components/schemas object
* `.sectionName` is `'schemas``
* `.componentName` is `'mySchema'`
*/
interface ComponentLocation {
section: { [sectionName: string]: ApiObject };
sectionName: string;
componentName: string;
}
/**
* An ApiObject read from a URL and optional fragment
*/
interface ApiResource {
/** The URL that was used to fetch the resource */
url: URL;
/** The API document at the URL */
api: ApiObject;
/** The URL fragment, if it existed in the `url` */
fragment?: string;
/** The path to an item within the API document at the given fragment, if any */
itemPath: JsonKey[];
}
export interface ApiRefOptions {
/** If true, log more info to console.warn */
verbose?: boolean;
/** If true, do not inject x-resolved-from and x-resolved-at markers */
noMarkers?: boolean;
/**
* What to do id two different resolutions define the same component,
* either rename the second one by adding a unique integer suffix, or
* throw an error. The default is `rename`. The result includes a list
* of renamed components.
*/
conflictStrategy?: 'error' | 'rename' | 'ignore';
/**
* Output format for stdout; default is `yaml`
*/
outputFormat?: 'yaml' | 'json';
}
export interface ApiRefResolution {
api: ApiObject;
options: ApiRefOptions;
}
/**
* ApiRefResolver resolves multi-file API definition documents by replacing
* external `{$ref: "uri"}` [JSON Reference](https://datatracker.ietf.org/doc/html/draft-pbryan-zyp-json-ref-03)
* objects with the object referenced at the `uri`.
*/
export class ApiRefResolver {
/**
* A `RegExp` which only matches an API component relative-uri fragment `#/components/<section>/<componentName>`
*/
static readonly COMPONENT_REGEXP = /^#\/components\/[^\\/]+\/[^\\/]+$/;
/** The URL of the current API being processed */
private url: URL;
// The protocol of `this.url`; usually one of '`file'|'http'|'https'`
// private urlProtocol : string;
private options: ApiRefOptions;
private apiDocument: ApiObject;
/**
* Maps normalized path names or URLs to API document objects
*/
private urlToApiObjectMap: { [path: string]: ApiObject };
private alreadyRewritten: {
path: { [path: string]: boolean };
fragment: { [path: string]: boolean };
};
/**
* Maps $ref strings to their resolved $ref strings
*/
private resolvedRefToRefMap: { [path: string]: string };
/**
* Date-time when we resolved this API
*/
private dateTime: string;
/**
* Tracks whether the resolve function changed anything
*/
private changed;
/**
* Temporary marker added to object to prevent re-resolving them.
* Removed in cleanup().
*/
static readonly TEMPORARY_MARKER = 'x__resolved__';
/**
* Marker to indicate where an object was resolved from (unless options.noMarker is true)
* See tag()
*/
static readonly RESOLVED_FROM_MARKER = 'x-resolved-from';
/**
* Marker to indicate when an object was resolved (unless options.noMarker is true)
* See tag()
*/
static readonly RESOLVED_AT_MARKER = 'x-resolved-at';
/**
* Build a new `$ref` resolver
* @param uri The location of the API document: a file name or URL
* @param apiDocument Optional document object. If omitted, the
* {@link resolve()} function will read it.
*/
public constructor(uri: string | URL, apiDocument?: ApiObject) {
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(uri);
} else {
this.url = pathToFileURL(path.resolve(process.cwd(), uri));
}
} else {
this.url = uri;
}
if (apiDocument) {
this.apiDocument = apiDocument;
}
}
async resolve(options?: ApiRefOptions): Promise<ApiRefResolution> {
// this.urlProtocol = this.url.protocol;
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: RefVisitor = (node: RefObject, nav: JsonNavigation) => this.refResolvingVisitor(node, nav);
this.changed = true;
let pass = 0;
while (this.changed) {
pass = pass + 1;
this.changed = false;
this.apiDocument = (await visitRefObjects(this.apiDocument, refVisitor)) as ApiObject;
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 };
}
/**
* Cleanup the final resolved object by removing temporary `x__resolved__` tags
* @param resolved the APi document after resolving the `$ref` objects
* @returns the cleansed `resolved` object
*/
async cleanup(resolved: ApiObject): Promise<object> {
return (await walkObject(resolved, async (node: object) => {
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;
})) as object;
}
public static deepClone = (obj) => {
return v8.deserialize(v8.serialize(obj)); // kinda simple way to clone, but it works...
};
/**
* @param reference a `$ref` URI
* @returns the replacement `$ref` to a previously process/resolved `$ref` string
*/
private replacementRefFor(reference: string): string {
return this.resolvedRefToRefMap[reference];
}
/**
* Remember that `ref` should now be replaced with `replacementRef`.
* Look up replacements with `replacementRefRef(reference)`.
* @param reference an external `$ref` URI that has been resolved
* @param replacementRef the new reference. It could be relative
* to the current document (`#/components/schemas/mySchema`)
* or it could be a ref in another API document
* (`../apis/other.yaml#/components/schemas/mySchema` or
* `https://api.eample.com/apis/other.yaml#/components/schemas/mySchema`)
*/
private rememberReplacementForRef(reference: string, replacementRef: string) {
assert(
!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);
}
/**
* Read an API document from a file: URL
* @param url the URL where the API is located
*/
private async readFromFile(url: URL): Promise<string> {
const fileUrl = ApiRefResolver.urlNonFragment(url);
const filePath = fileURLToPath(fileUrl);
const text = fs.readFileSync(filePath, { encoding: 'utf8' });
return text;
}
private async readFromUrl(url: URL): Promise<string> {
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());
}),
);
});
});
}
/**
* Read an API document
* @param uri The string or URL of then API document to read
* @returns the object at that URL and optionally an API element
* at the fragment from the API document
*/
public async api(uri: string | URL): Promise<ApiResource> {
const url = typeof uri === 'string' ? pathToFileURL(uri) : uri;
const urlKey = ApiRefResolver.urlNonFragment(url);
const fragment = ApiRefResolver.urlFragment(url);
const itemPath = fragment ? 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 });
// Cache the api object by the URL
this.urlToApiObjectMap[urlKey.href] = api;
this.note(`loaded API document from ${url.href}`);
return {
url: urlKey,
api,
fragment,
itemPath,
};
}
/**
* Log a message if this.options.verbose is true
* @param message message text
*/
note(message: string) {
if (this.options.verbose) {
console.log(`api-ref-resolver: ${message}`);
}
}
static urlNonFragment(url: URL) {
const urlNonFragment = new URL(url.href);
urlNonFragment.hash = '';
return urlNonFragment;
}
/**
* Process a JSON reference object and possibly replace the node
* as per the replacement rules outlined in README.me
* @param refObject a JSON Reference object
* @param jsonKeys the path to this JSON reference in the containing API document
* @param ancestry the chain of ancestor objects.
*/
private async refResolvingVisitor(refObject: RefObject, nav: JsonNavigation): Promise<JsonItem> {
const ref = refObject.$ref as string;
// console.log(`seen $ref ${ref} at path ${nav.toJsonPointer()}`);
if (ref.startsWith('#')) {
return refObject;
}
const replacementRef = this.replacementRefFor(ref);
if (replacementRef) {
refObject.$ref = replacementRef;
return refObject;
}
// below process*Replacement operations will inline content
// that must be resolved again with a second scan in resolve()
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);
}
/**
* Return the URL fragment part of the URL (with the `#`)
* or `undefined` if there is no fragment.
* @param url a URL
* @returns the fragment string or undefined if there is no fragment
*/
private static urlFragment(url: URL): string | undefined {
return url.hash === '' ? undefined : url.hash;
}
/**
* Construct a URL to the reference `ref` relative to a base URL.
* @param ref a `$ref` reference path to an API element, such as
* and absolute URL `https://host/path/to/resource.json` or a relative
* URL such as '../alt-path/resource.yaml`
* @param baseUrl the base URL from which a relative path is resolved.
* If not passed, use `this.url`
* @returns the URL of the referenced API object
*/
private relativeUrl(ref: string, baseUrl?: string) {
if (ref.startsWith('http:') || ref.startsWith('http:')) {
return new URL(ref);
}
const relUrl = new URL(ref, baseUrl ?? this.url.href);
return relUrl;
}
/**
* Process a JSON API document, updating all of its `$ref` objects
* to be relative to the document URL where we read the document.
* For example, consider `/path/to/apis/api-a/api.yaml` which has a `{ $ref: "../models/b.yaml#/components/schemas/thing" }
* and `b.yaml` contains `{ $ref: "./c.yaml#/components/schemas/anotherThing" }`,
* when we resolve the $ref in `a.yaml` and load `../models/b.yaml`
* the reference from `b.yaml` must be changed
* to `../c.yaml#/components/schemas/anotherThing` so that it is a correct `$ref` in the
* context of `a.yaml`. Similarly, local refs such as `"#/components/schemas/thing"`
* are rewritten as `"../c.yaml#/components/schemas/thing"`
* TODO: Presently, this uses absolute URLs, but for files the href should be relative to the current file.
* @param documentUrl the normalized URL of the document being scanned, such as `'file://path/to/apis/models/b.yaml'`
* in the example.
* @param api An API object that was read from `url`
*/
private async rewriteRefPaths(documentUrl: URL, api: ApiObject) {
const nonFragmentUrl = ApiRefResolver.urlNonFragment(documentUrl);
if (this.areRefsAlreadyRewritten(nonFragmentUrl, 'path')) {
return;
}
const refRewriteVisitor = async (node: RefObject): Promise<Node> => {
// TODO: fix this to use relative URLs, not absolute URLs.
const refNormalizedUrl = new URL(node.$ref, nonFragmentUrl);
node.$ref = refNormalizedUrl.href;
return node;
};
await visitRefObjects(api, refRewriteVisitor);
this.markRefsAlreadyRewritten(nonFragmentUrl, 'path');
}
/**
* Process a JSON document, updating all of its '#/....' local `$ref` objects to
* the location it was embedded in the target ApAPI document.
* @param documentUrl the normalized URL of the document being scanned, such as `'file://path/to/apis/models/b.yaml'`
* in the example.
* @param api A JSON object that was read from a URL or file
* @param nav Points to where we are in the containing API document.
* We extract a fragment from this and insert that as a prefix in the
* local REF urls. For example, if `nav` is at `/paths/~1health/get`
* we will insert `/paths/~1health/get` before any `#/...` `$ref` objects.
*/
private async rewriteRefFragments(documentUrl: URL, api: ApiObject, nav: JsonNavigation) {
const nonFragmentUrl = ApiRefResolver.urlNonFragment(documentUrl);
if (this.areRefsAlreadyRewritten(nonFragmentUrl, 'fragment')) {
return;
}
const prefix = nav.asFragment();
const refRewriteVisitor = async (node: RefObject): Promise<Node> => {
const ref = node.$ref;
if (ref.startsWith('#')) {
node.$ref = `${prefix}${ref.substring(1)}`;
}
return node;
};
await visitRefObjects(api, refRewriteVisitor);
this.markRefsAlreadyRewritten(nonFragmentUrl, 'fragment');
}
/**
* Track whether we have already rewritten all the `$ref` objects in an API document
* by its URL.
* @param normalizedApiDocUrl the (normalized) URL of the API document
* @param what which type of update to track: `path` for {@link rewriteRefPaths} or `fragment` for {@link rewriteRefFragments}
* @returns `false` if we have not rewritten then.
*/
private areRefsAlreadyRewritten(normalizedApiDocUrl: URL, what: 'path' | 'fragment'): boolean {
const map = this.alreadyRewritten[what];
const key = normalizedApiDocUrl.href;
const alreadyRewritten = !!map[key];
return alreadyRewritten;
}
/**
* Track that we have already rewritten all the `$ref` objects in an API document
* by its URL.
* @param normalizedApiDocUrl the (normalized) URL of the API document
* @param what which type of update to track: `path` for {@link rewriteRefPaths} or `fragment` for {@link rewriteRefFragments}
*/
private markRefsAlreadyRewritten(normalizedApiDocUrl: URL, what: 'path' | 'fragment') {
const map = this.alreadyRewritten[what];
const key = normalizedApiDocUrl.href;
map[key] = true;
}
/**
* Check if the inlined component already exists.
* If it exists and it was resolved from a different URL, then:
* * if the component conflictStrategy in the option is `error`, throw an error
* * if the policy is `rename`, change the name by adding a unique suffix
* Also create the components object and components section (componentKeys[1])
* object if they do not exist on `this.apiObject`.
* @param refObject the current reference object
* @param componentKeys the JSON keys to the component, [components, sectionName, componentName]
* @param originalUrl $ref object URL of the component
* @returns component location
*/
private checkComponentConflict(refObject: ApiObject, componentKeys: JsonKey[], originalUrl: URL): ComponentLocation {
assert(componentKeys[0] === 'components');
const urlNoFragment = ApiRefResolver.urlNonFragment(originalUrl);
const sectionName = componentKeys[1] as string;
const componentName = componentKeys[2] as string;
if (!this.apiDocument['components']) {
this.apiDocument['components'] = {};
}
const components: object = this.apiDocument['components'];
if (!components[sectionName]) {
components[sectionName] = {};
}
const section = components[sectionName];
const existing = this.apiDocument?.[componentKeys[0]]?.[sectionName]?.[componentKeys[2]];
if (!existing) {
return { section, sectionName, componentName };
}
if (existing === refObject) {
// The component is defined by a ref and we're processing it!
return { section, sectionName, componentName };
}
const resolvedFrom = existing['x-resolved-from'];
const sameResolution = resolvedFrom === urlNoFragment.href;
if (!sameResolution && this.options?.conflictStrategy === 'error') {
const resolvedFromText = resolvedFrom ? ` from ${resolvedFrom}` : '';
throw new Error(
`Cannot embed component ${componentKeys} from ${originalUrl.href}: component already exists${resolvedFromText}`,
);
}
if (this.options?.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 };
}
/**
* Merge the `$ref` object with the API object read from the URL.
* For example, when resolving the reference in the following:
*
* ```
* { components: {
* responses: {
* '422':
* description: "Describes the error",
* $ref: "#/components/schemas/problemResponse"
* }
* }
* }
* ```
*
* `refObject` is the `{ description: "...", $ref: "#/components/schemas/problemResponse" }` object.
* Note that we can't simply _replace_ the entire `$ref` object with the API object at the URL;
* that would lose the "description".
* Instead, we delete the `$ref` from the `refObject` within the API document,
* then merge in any additional properties from the original `refObject` into the API document
* (if it is an object).
*
* @param refObject a `$ref` object
* @param apiElement the API element read from the `url`
*/
mergeRefObject(refObject: RefObject, apiElement: JsonItem): ApiObject {
if (typeof apiElement !== 'object') {
return;
}
const refProperties = { ...refObject };
delete refProperties['$ref'];
// matching properties from refProperties will override those from apiElement
// Clone the object to prevent injection of YAML *ref_0 / &ref_0 objects
// in case this element is referenced multiple times
const clone = ApiRefResolver.deepClone(apiElement);
const merged = { ...clone, ...refProperties };
return merged;
}
/**
* Inline the content for a `{ $ref: "http://path/to/resource#/components/section/componentName"}`
* or `{ $ref: "../path/to/resource#/components/section/componentName"}` where
* just a component from an API is referenced.
* For example,
*
* ```
* paths:
* /thing:
* post:
* operationId: createThing
* requestBody:
* description: A new thing.
* content:
* application/json:
* schema:
* $ref: '../api-a/api.yaml#/components/schemas/thing'
* components:
* securitySchemes:
* apiKey:
* $ref: '../api-a/api.yaml#/components/securitySchemes/apiKey'
* ```
* In the first case (`$ref: '../api-a/api.yaml#/components/schemas/thing'`), we
* add the schema component `thing` from `../api-a/api.yaml`
* to this API's `components/schemas` object, and replace the remote `$ref` object
* with a local reference, $ref: '#/components/schemas/thing'.
*
* If there is a name conflict (i.e. the component `thing` already exists
* and it came from a _different_ normalized URL), then apply the `conflictStrategy` from
* the `options`.
*
* In the second place (a reference directly in a component), simply
* replace the `$ref` object (the `apiKey` security scheme) with the corresponding referenced object directly.
*
* The result of processing both component `#ref` objects is:
* ```
* paths:
* /thing:
* post:
* operationId: createThing
* requestBody:
* description: A new thing.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/thing'
* components:
* securitySchemes:
* apiKey:
* type: apiKey
* name: API-Key
* in: header
* description: 'API Key based client identification.'
* $ref: '../api-a/api.yaml#/components/securitySchemes/apiKey'
* schemas:
* thing:
* title: Thing
* description: A Thing!
* type: object
* ...
* ```
*
* Before the content is merged into the current API document, update base URL of
* all the `$ref` objects within the referenced API document.
*
* @param normalizedRefUrl the URL in the `$ref` object after normalizing it against the URL of the current target API document
* @param refObject the `$ref` object
* @param nav The location of the `$ref` object in the target API document
* @returns the updated JSON item (usually an object, but may be an array or primitive)
*/
private async processComponentReplacement(
normalizedRefUrl: URL,
refObject: RefObject,
nav: JsonNavigation,
): Promise<JsonItem> {
assert(nav);
assert(normalizedRefUrl.hash);
assert(ApiRefResolver.COMPONENT_REGEXP.exec(normalizedRefUrl.hash));
const reference: string = 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(urlNoFragment.href, this.url);
await this.rewriteRefPaths(baseUrl, api);
const item = this.apiItem(api, itemPath);
const componentKeys = JsonNavigation.asKeys(normalizedRefUrl.hash);
this.tag(item, normalizedRefUrl, nav);
// Simplest case:
// components/foo/bar: { $ref: uri:/components/foo/bar }
if (nav.isAtComponent() && this.isSimpleRef(refObject) && this.sameComponentName(nav, componentKeys)) {
this.rememberReplacementForRef(reference, nav.asFragment());
return item; // item is already safely cloned cia this.api()
}
const { section, sectionName, componentName } = this.checkComponentConflict(
refObject,
componentKeys,
normalizedRefUrl,
); // this may rename the new resolved component
const newVal = ApiRefResolver.deepClone(item);
section[componentName] = newVal;
const resolvedRef = JsonNavigation.asFragment(['components', sectionName, componentName], true);
this.rememberReplacementForRef(reference, resolvedRef);
refObject.$ref = resolvedRef;
return refObject;
}
apiItem(api: ApiObject, itemPath: JsonKey[]): JsonItem {
return JsonNavigation.itemAtFragment(api, JsonNavigation.asFragment(itemPath, true));
}
/**
* Check if the nav and the resolved component are the same component name
* @param nav The location of the element contains a $ref
* @param componentKeys The path of keys in a reference component
* @returns `true` iff the current component location has the same name as the referenced component.
* For example, `/components/securitySchemes/accessToken` contains just
* `$ref: uriToOtherDocument#/components/securitySchemes/accessToken
* Both locations must be exactly 3 elements long, [ `components`, _sectionName_, _componentsName_ ].
*/
sameComponentName(nav: JsonNavigation, componentKeys: JsonKey[]) {
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;
}
/**
* Return `true` if `refObject` does not contains any additional properties.
* @param refObject an object with a `$ref` key
* @returns `true` iff `refObject` does not contains any additional properties.
*/
private isSimpleRef(refObject: RefObject) {
return Object.keys(refObject).length === 1;
}
/**
* Inline the content for a `{ $ref: "http://path/to/resource" }`
* or `{ $ref: "../path/to/resource"}` where the entire
* file contents are embedded at the place of the `$ref` object
* indicated by the `nav` location. For example,
* ```
* components:
* schemas:
* range:
* $ref: ../schemas/percentageRange.yaml
* ```
* the current `nav` location of the `$ref` object is `/components/schemas/range`
* Fetch the API document from the location, then replace
* the `$ref` object with the API contents, then update
* all the `{ $ref` : "#/path" } objects within that object, adjusting
* the path to account for the new location. For example,
* if `percentageRange.yaml` contains
*
* ```
* properties:
* low:
* description: The lower-bound of the percentage range.
* $ref: 'percentage.yaml'
* high:
* description: The lower-bound of the percentage range.
* $ref: '#/properties/low'
* ```
*
* we adjust all the `$ref` objects , yielding corrected
* locations
*
* ```
* components:
* schemas:
* range:
* type: object
* description: A range of low and high percentages.
* properties:
* low:
* description: The lower-bound of the percentage range.
* $ref: '../schemas/percentage.yaml'
* high:
* description: The lower-bound of the percentage range.
* $ref: '#/components/schemas/range/properties/low'
* ```
* The first `$ref` is relative to the the current API;
* the second `$ref` is updated with the prefix of the current `nav` location.
* @param normalizedRefUrl the URL in the `$ref` object after normalizing it against the URL of the current target API document
* @param refObject the `$ref` object
* @param nav The location of the `$ref` object in the target API document
* @returns the updated JSON item (usually an object, but may be an array or primitive)
*/
private async processFullReplacement(
normalizedRefUrl: URL,
refObject: RefObject,
nav: JsonNavigation,
): Promise<JsonItem> {
assert(normalizedRefUrl.hash === '');
const reference: string = normalizedRefUrl.href;
const seen = this.replacementRefFor(reference);
if (seen) {
refObject.$ref = seen;
return refObject;
}
const { api } = await this.api(normalizedRefUrl); // no fragment or item
// remember the mapping from the original `$ref` to the new inline
// location of the current object from the target API document navigation
const resolvedRef = nav.asFragment();
this.rememberReplacementForRef(reference, resolvedRef);
await this.rewriteRefFragments(normalizedRefUrl, api, nav);
await this.rewriteRefPaths(normalizedRefUrl, api); // always call this after rewriteLocalRefsWithPrefix
const merged = this.mergeRefObject(refObject, api);
this.tag(merged, normalizedRefUrl, nav);
return merged;
}
/**
* Inline the content for a `{ $ref: "path/to/resource#/path/to/non-component"}`
* where the `$ref` URL contains a non-empty `#` fragment
* (Use `processFullReplacement` if the fragment is empty,
* and use `processComponentReplacement` if the fragment is
* of the form `/components/section/componentName`.)
* For example,
*
* ```
* paths:
* /health:
* $ref: '../root.yaml#/paths/~1health/get'
* ```
*
* We read the APi document (`../root.yaml` in this case),
* scan it to redirect any $ref in it so that they are relative
* to the current API document, then extract the API element
* at the fragment and return it.
*
* If the `GET /heath` operation in root.yaml
*
* ```
* /health:
* get:
* operationId: apiHealth
* description: Return API Health
* tags:
* - Health
* responses:
* '200':
* description: OK. The API is alive and active.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/health'
* ```
*
* the result will be
*
* ```
* /health:
* get:
* operationId: apiHealth
* description: Return API Health
* tags:
* - Health
* responses:
* '200':
* description: OK. The API is alive and active.
* content:
* application/json:
* schema:
* $ref: '#../root.yaml/components/schemas/health'
* ```
*
* Note that the `$ref` to the `health` schema, which was a local `#/...` reference
* within `../root.yaml`, was re-written as a remote `$ref` to the `health`
* schema in that document, because `health` does not (yet) exist in the target
* API document. However, that `$ref` will get resolved in a later stage.
*
* @param normalizedRefUrl the URL in the `$ref` object after normalizing it against the URL of the current target API document
* @param refObject the `$ref` object
* @param reference the $ref value
* @param nav where in the API document the refObject resides
* @returns the updated JSON item (usually an object, but may be an array or primitive)
*/
private async processOtherReplacement(
normalizedRefUrl: URL,
refObject: RefObject,
reference: string,
nav: JsonNavigation,
): Promise<JsonItem> {
assert(normalizedRefUrl.hash);
assert(!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(urlNoFragment.href, this.url);
// await this.rewriteRefFragments(baseUrl, api, nav); // always call this before rewriteRefPaths
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: JsonItem, normalizedRefUrl: URL, nav: JsonNavigation | undefined, 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; // temporary marker to be removed
}
}
/**
* Indicate if the location is taggable.
* The location is taggable if not a $ref object or it's nav is a schema.
* (OpenAPI does not allow x- specification extension in reference objects,
* but JSON Schema does.)
* @param nav the navigation to the current location
* @returns if the object at this spot can be tagged with an x-resolved-from marker
*/
taggable(item: JsonItem, nav: JsonNavigation) {
if (isRef(item)) {
const path = nav.path();
return path.length > 2 && ((path[0] === 'components' && path[1] === 'schemas') || path.includes('schema'));
}
return true;
}
}