docusaurus-plugin-openapi-docs
Version:
OpenAPI plugin for Docusaurus.
435 lines (388 loc) • 12.1 kB
text/typescript
/* ============================================================================
* Copyright (c) Palo Alto Networks
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* ========================================================================== */
// @ts-nocheck
import { RedocNormalizedOptions } from "./RedocNormalizedOptions";
import { OpenAPIRef, OpenAPISchema, OpenAPISpec, Referenced } from "../types";
import { isArray, isBoolean } from "../utils/helpers";
import { JsonPointer } from "../utils/JsonPointer";
import { getDefinitionName, isNamedDefinition } from "../utils/openapi";
export type MergedOpenAPISchema = OpenAPISchema & { parentRefs?: string[] };
/**
* Helper class to keep track of visited references to avoid
* endless recursion because of circular refs
*/
class RefCounter {
_counter = {};
reset(): void {
this._counter = {};
}
visit(ref: string): void {
this._counter[ref] = this._counter[ref] ? this._counter[ref] + 1 : 1;
}
exit(ref: string): void {
this._counter[ref] = this._counter[ref] && this._counter[ref] - 1;
}
visited(ref: string): boolean {
return !!this._counter[ref];
}
}
/**
* Loads and keeps spec. Provides raw spec operations
*/
export class OpenAPIParser {
specUrl?: string;
spec: OpenAPISpec;
private _refCounter: RefCounter = new RefCounter();
private allowMergeRefs: boolean = false;
constructor(
spec: OpenAPISpec,
specUrl?: string,
private options: {} = new RedocNormalizedOptions()
) {
this.validate(spec);
this.spec = spec;
this.allowMergeRefs = spec.openapi.startsWith("3.1");
const href = undefined;
if (typeof specUrl === "string") {
this.specUrl = new URL(specUrl, href).href;
}
}
validate(spec: any) {
if (spec.openapi === undefined) {
throw new Error("Document must be valid OpenAPI 3.0.0 definition");
}
}
/**
* get spec part by JsonPointer ($ref)
*/
byRef = <T extends any = any>(ref: string): T | undefined => {
let res;
if (!this.spec) {
return;
}
if (ref.charAt(0) !== "#") {
ref = "#" + ref;
}
ref = decodeURIComponent(ref);
try {
res = JsonPointer.get(this.spec, ref);
} catch (e) {
// do nothing
}
return res || {};
};
/**
* checks if the object is OpenAPI reference (contains $ref property)
*/
isRef(obj: any): obj is OpenAPIRef {
if (!obj) {
return false;
}
return obj.$ref !== undefined && obj.$ref !== null;
}
/**
* resets visited endpoints. should be run after
*/
resetVisited() {
if (process.env.NODE_ENV !== "production") {
// check in dev mode
for (const k in this._refCounter._counter) {
if (this._refCounter._counter[k] > 0) {
console.warn("Not exited reference: " + k);
}
}
}
this._refCounter = new RefCounter();
}
exitRef<T>(ref: Referenced<T>) {
if (!this.isRef(ref)) {
return;
}
this._refCounter.exit(ref.$ref);
}
/**
* Resolve given reference object or return as is if it is not a reference
* @param obj object to dereference
* @param forceCircular whether to dereference even if it is circular ref
*/
deref<T extends object>(
obj: OpenAPIRef | T,
forceCircular = false,
mergeAsAllOf = false
): T {
if (this.isRef(obj)) {
const schemaName = getDefinitionName(obj.$ref);
if (schemaName && this.options.ignoreNamedSchemas.has(schemaName)) {
return { type: "object", title: schemaName } as T;
}
const resolved = this.byRef<T>(obj.$ref)!;
const visited = this._refCounter.visited(obj.$ref);
this._refCounter.visit(obj.$ref);
if (visited && !forceCircular) {
// circular reference detected
// tslint:disable-next-line
return Object.assign({}, resolved, { "x-circular-ref": true });
}
// deref again in case one more $ref is here
let result = resolved;
if (this.isRef(resolved)) {
result = this.deref(resolved, false, mergeAsAllOf);
this.exitRef(resolved);
}
return this.allowMergeRefs
? this.mergeRefs(obj, resolved, mergeAsAllOf)
: result;
}
return obj;
}
shallowDeref<T extends unknown>(obj: OpenAPIRef | T): T {
if (this.isRef(obj)) {
const schemaName = getDefinitionName(obj.$ref);
if (schemaName && this.options.ignoreNamedSchemas.has(schemaName)) {
return { type: "object", title: schemaName } as T;
}
const resolved = this.byRef<T>(obj.$ref);
return this.allowMergeRefs
? this.mergeRefs(obj, resolved, false)
: (resolved as T);
}
return obj;
}
mergeRefs(ref, resolved, mergeAsAllOf: boolean) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $ref, ...rest } = ref;
const keys = Object.keys(rest);
if (keys.length === 0) {
if (this.isRef(resolved)) {
return this.shallowDeref(resolved);
}
return resolved;
}
if (
mergeAsAllOf &&
keys.some(
(k) => k !== "description" && k !== "title" && k !== "externalDocs"
)
) {
return {
allOf: [rest, resolved],
};
} else {
// small optimization
return {
...resolved,
...rest,
};
}
}
/**
* Merge allOf constraints.
* @param schema schema with allOF
* @param $ref pointer of the schema
* @param forceCircular whether to dereference children even if it is a circular ref
*/
mergeAllOf(
schema: OpenAPISchema,
$ref?: string,
forceCircular: boolean = false,
used$Refs = new Set<string>()
): MergedOpenAPISchema {
if ($ref) {
used$Refs.add($ref);
}
schema = this.hoistOneOfs(schema);
if (schema.allOf === undefined) {
return schema;
}
let receiver: MergedOpenAPISchema = {
...schema,
allOf: undefined,
parentRefs: [],
title: schema.title || getDefinitionName($ref),
};
// avoid mutating inner objects
if (
receiver.properties !== undefined &&
typeof receiver.properties === "object"
) {
receiver.properties = { ...receiver.properties };
}
if (receiver.items !== undefined && typeof receiver.items === "object") {
receiver.items = { ...receiver.items };
}
const allOfSchemas = schema.allOf
.map((subSchema) => {
if (subSchema && subSchema.$ref && used$Refs.has(subSchema.$ref)) {
return undefined;
}
const resolved = this.deref(subSchema, forceCircular, true);
const subRef = subSchema.$ref || undefined;
const subMerged = this.mergeAllOf(
resolved,
subRef,
forceCircular,
used$Refs
);
receiver.parentRefs!.push(...(subMerged.parentRefs || []));
return {
$ref: subRef,
schema: subMerged,
};
})
.filter((child) => child !== undefined) as Array<{
$ref: string | undefined;
schema: MergedOpenAPISchema;
}>;
for (const { $ref: subSchemaRef, schema: subSchema } of allOfSchemas) {
const {
type,
enum: enumProperty,
"x-enumDescription": enumDescription,
properties,
items,
required,
oneOf,
anyOf,
title,
...otherConstraints
} = subSchema;
if (
receiver.type !== type &&
receiver.type !== undefined &&
type !== undefined
) {
console.warn(
`Incompatible types in allOf at "${$ref}": "${receiver.type}" and "${type}"`
);
}
if (type !== undefined) {
if (Array.isArray(type) && Array.isArray(receiver.type)) {
receiver.type = [...type, ...receiver.type];
} else {
receiver.type = type;
}
}
if (enumProperty !== undefined) {
if (Array.isArray(enumProperty) && Array.isArray(receiver.enum)) {
receiver.enum = [...enumProperty, ...receiver.enum];
} else {
receiver.enum = enumProperty;
}
}
if (properties !== undefined) {
receiver.properties = receiver.properties || {};
for (const prop in properties) {
if (!receiver.properties[prop]) {
receiver.properties[prop] = properties[prop];
} else {
// merge inner properties
const mergedProp = this.mergeAllOf(
{ allOf: [receiver.properties[prop], properties[prop]] },
$ref + "/properties/" + prop
);
receiver.properties[prop] = mergedProp;
this.exitParents(mergedProp); // every prop resolution should have separate recursive stack
}
}
}
if (items !== undefined) {
const receiverItems = isBoolean(receiver.items)
? { items: receiver.items }
: receiver.items
? (Object.assign({}, receiver.items) as OpenAPISchema)
: {};
const subSchemaItems = isBoolean(items)
? { items }
: (Object.assign({}, items) as OpenAPISchema);
// merge inner properties
receiver.items = this.mergeAllOf(
{ allOf: [receiverItems, subSchemaItems] },
$ref + "/items"
);
}
if (required !== undefined) {
receiver.required = (receiver.required || []).concat(required);
}
if (oneOf !== undefined) {
receiver.oneOf = oneOf;
}
if (anyOf !== undefined) {
receiver.anyOf = anyOf;
}
// merge rest of constraints
// TODO: do more intelligent merge
receiver = {
...receiver,
title: receiver.title || title,
...otherConstraints,
};
if (subSchemaRef) {
receiver.parentRefs!.push(subSchemaRef);
if (receiver.title === undefined && isNamedDefinition(subSchemaRef)) {
// this is not so correct behaviour. commented out for now
// ref: https://github.com/Redocly/redoc/issues/601
// receiver.title = JsonPointer.baseName(subSchemaRef);
}
}
}
return receiver;
}
/**
* Find all derived definitions among #/components/schemas from any of $refs
* returns map of definition pointer to definition name
* @param $refs array of references to find derived from
*/
findDerived($refs: string[]): Record<string, string[] | string> {
const res: Record<string, string[]> = {};
const schemas =
(this.spec.components && this.spec.components.schemas) || {};
for (const defName in schemas) {
const def = this.deref(schemas[defName]);
if (
def.allOf !== undefined &&
def.allOf.find(
(obj) => obj.$ref !== undefined && $refs.indexOf(obj.$ref) > -1
)
) {
res["#/components/schemas/" + defName] = [
def["x-discriminator-value"] || defName,
];
}
}
return res;
}
exitParents(shema: MergedOpenAPISchema) {
for (const parent$ref of shema.parentRefs || []) {
this.exitRef({ $ref: parent$ref });
}
}
private hoistOneOfs(schema: OpenAPISchema) {
if (schema.allOf === undefined) {
return schema;
}
const allOf = schema.allOf;
for (let i = 0; i < allOf.length; i++) {
const sub = allOf[i];
if (isArray(sub.oneOf)) {
const beforeAllOf = allOf.slice(0, i);
const afterAllOf = allOf.slice(i + 1);
return {
oneOf: sub.oneOf.map((part) => {
const merged = this.mergeAllOf({
allOf: [...beforeAllOf, part, ...afterAllOf],
});
// each oneOf should be independent so exiting all the parent refs
// otherwise it will cause false-positive recursive detection
this.exitParents(merged);
return merged;
}),
};
}
}
return schema;
}
}