import type { OpenAPIRef, OpenAPISchema, OpenAPISpec } from '../types'; import { IS_BROWSER, getDefinitionName, compact, isObject, isObjectEmpty } from '../utils/'; import { JsonPointer } from '../utils/JsonPointer'; import { RedocNormalizedOptions } from './RedocNormalizedOptions'; import type { MergedOpenAPISchema } from './types'; import type { OpenAPIExample } from '../types'; const MAX_DEREF_DEPTH = 999; // prevent circular detection crashes by adding hard limit on deref depth /** * Loads and keeps spec. Provides raw spec operations */ export function pushRef(stack: string[], ref?: string): string[] { return ref && stack[stack.length - 1] !== ref ? [...stack, ref] : stack; } export function concatRefStacks(base: string[], stack?: string[]): string[] { return stack ? base.concat(stack) : base; } export class OpenAPIParser { specUrl?: string; spec: OpenAPISpec; private readonly allowMergeRefs: boolean = false; constructor( spec: OpenAPISpec, specUrl?: string, private options: RedocNormalizedOptions = new RedocNormalizedOptions({}), ) { this.validate(spec); this.spec = spec; this.allowMergeRefs = spec.openapi.startsWith('3.1'); const href = IS_BROWSER ? window.location.href : ''; if (typeof specUrl === 'string') { this.specUrl = href ? new URL(specUrl, href).href : specUrl; } } validate(spec: Record): void { if (spec.openapi === undefined) { throw new Error('Document must be valid OpenAPI 3.0.0 definition'); } } /** * get spec part by JsonPointer ($ref) */ byRef = (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: OpenAPIRef | T): obj is OpenAPIRef { if (!obj) { return false; } obj = obj; return obj.$ref !== undefined && obj.$ref !== null; } /** * 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 * @param mergeAsAllOf */ deref( obj: OpenAPIRef | T, baseRefsStack: string[] = [], mergeAsAllOf = false, ): { resolved: T; refsStack: string[] } { // this can be set by all of when it mergers props from different sources const objRefsStack = obj?.['x-refsStack']; baseRefsStack = concatRefStacks(baseRefsStack, objRefsStack); if (this.isRef(obj)) { const schemaName = getDefinitionName(obj.$ref); if (schemaName && this.options.ignoreNamedSchemas.has(schemaName)) { return { resolved: { type: 'object', title: schemaName } as T, refsStack: baseRefsStack }; } let resolved = this.byRef(obj.$ref); if (!resolved) { throw new Error(`Failed to resolve $ref "${obj.$ref}"`); } let refsStack = baseRefsStack; if (baseRefsStack.includes(obj.$ref) || baseRefsStack.length > MAX_DEREF_DEPTH) { resolved = Object.assign({}, resolved, { 'x-circular-ref': true }); } else if (this.isRef(resolved)) { const res = this.deref(resolved, baseRefsStack, mergeAsAllOf); refsStack = res.refsStack; resolved = res.resolved; } refsStack = pushRef(baseRefsStack, obj.$ref); resolved = this.allowMergeRefs ? this.mergeRefs(obj, resolved, mergeAsAllOf) : resolved; return { resolved, refsStack }; } return { resolved: obj, refsStack: concatRefStacks(baseRefsStack, objRefsStack), }; } mergeRefs(ref: OpenAPIRef, resolved: T, mergeAsAllOf: boolean): T { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { $ref, ...rest } = ref; const keys = Object.keys(rest); if (keys.length === 0) { return resolved; } if ( mergeAsAllOf && keys.some( k => ![ 'description', 'title', 'externalDocs', 'x-refsStack', 'x-parentRefs', 'readOnly', 'writeOnly', ].includes(k), ) ) { const { description, title, readOnly, writeOnly, ...restSchema } = rest as OpenAPISchema; return { allOf: [{ description, title, readOnly, writeOnly }, resolved, restSchema], } as T; } else { // small optimization return { ...(resolved as object), ...rest, } as T; } } /** * 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 * @param used$Refs */ mergeAllOf( schema: MergedOpenAPISchema, $ref: string | undefined, refsStack: string[], ): MergedOpenAPISchema { if (schema['x-circular-ref']) { return schema; } schema = this.hoistOneOfs(schema, refsStack); if (schema.allOf === undefined) { return schema; } let receiver: MergedOpenAPISchema = { ...schema, 'x-parentRefs': [], allOf: undefined, 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 = uniqByPropIncludeMissing( schema.allOf .map((subSchema: OpenAPISchema) => { const { resolved, refsStack: subRefsStack } = this.deref(subSchema, refsStack, true); const subRef = subSchema.$ref || undefined; const subMerged = this.mergeAllOf(resolved, subRef, subRefsStack); if (subMerged['x-circular-ref'] && subMerged.allOf) { // if mergeAllOf is circular and still contains allOf, we should ignore it return undefined; } if (subRef) { // collect information for implicit descriminator lookup receiver['x-parentRefs']?.push(...(subMerged['x-parentRefs'] || []), subRef); } return { $ref: subRef, refsStack: pushRef(subRefsStack, subRef), schema: subMerged, }; }) .filter(child => child !== undefined) as Array<{ schema: MergedOpenAPISchema; refsStack: string[]; $ref?: string; }>, '$ref', ); for (const { schema: subSchema, refsStack: subRefsStack } of allOfSchemas) { const { type, enum: enumProperty, properties, items, required, title, description, readOnly, writeOnly, oneOf, anyOf, 'x-circular-ref': isCircular, ...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 = Array.from(new Set([...enumProperty, ...receiver.enum])); } else { receiver.enum = enumProperty; } } if (properties !== undefined && typeof properties === 'object') { receiver.properties = receiver.properties || {}; for (const prop in properties) { const propRefsStack = concatRefStacks(subRefsStack, properties[prop]?.['x-refsStack']); if (!receiver.properties[prop]) { receiver.properties[prop] = { ...properties[prop], 'x-refsStack': propRefsStack, } as MergedOpenAPISchema; } else if (!isCircular) { // merge inner properties const mergedProp = this.mergeAllOf( { allOf: [ receiver.properties[prop], { ...properties[prop], 'x-refsStack': propRefsStack } as any, ], 'x-refsStack': propRefsStack, }, $ref + '/properties/' + prop, propRefsStack, ); receiver.properties[prop] = mergedProp; } } } if (items !== undefined && !isCircular) { const receiverItems = typeof receiver.items === 'boolean' ? {} : (Object.assign({}, receiver.items) as OpenAPISchema); const subSchemaItems = typeof subSchema.items === 'boolean' ? {} : (Object.assign({}, subSchema.items) as OpenAPISchema); // merge inner properties receiver.items = this.mergeAllOf( { allOf: [receiverItems, subSchemaItems], }, $ref + '/items', subRefsStack, ); } if (oneOf !== undefined) { receiver.oneOf = oneOf; } if (anyOf !== undefined) { receiver.anyOf = anyOf; } if (required !== undefined) { receiver.required = [...(receiver.required || []), ...required]; } // merge rest of constraints // TODO: do more intelligent merge receiver = { ...receiver, title: receiver.title || title, description: receiver.description || description, readOnly: receiver.readOnly !== undefined ? receiver.readOnly : readOnly, writeOnly: receiver.writeOnly !== undefined ? receiver.writeOnly : writeOnly, 'x-circular-ref': receiver['x-circular-ref'] || isCircular, ...otherConstraints, }; } return receiver; } /** * Recursively deref the properties of a schema and attach examples * * @param {MergedOpenAPISchema} schema * @param {OpenAPIExample & OpenAPISchema} example * @returns {OpenAPISchema} */ derefSchemaWithExample( schema: MergedOpenAPISchema, example: OpenAPIExample & OpenAPISchema, ): OpenAPISchema { const { resolved: resolvedSchema } = this.deref(schema); const worker = ( currentSchema: MergedOpenAPISchema, currentExample: OpenAPIExample & OpenAPISchema, ) => { const receiver: OpenAPISchema = { ...currentSchema, }; if (isObject(currentSchema.properties)) { receiver.properties = Object.fromEntries( Object.entries(currentSchema.properties).map(([key, value]) => { let resolvedValue: OpenAPISchema = {}; const exampleForProp = currentExample?.[key]; if (Array.isArray(value.allOf) && !isObjectEmpty(exampleForProp)) { resolvedValue = this.mergeAllOf(value, undefined, value['x-refsStack'] || []); } else if (Array.isArray(value.oneOf) && !isObjectEmpty(exampleForProp)) { resolvedValue = this.deref(value.oneOf[0]).resolved; } else if (value.$ref) { resolvedValue = this.deref(value).resolved; } else if ((value.items as OpenAPISchema)?.$ref) { resolvedValue = { ...value, items: this.deref(value.items as OpenAPISchema, value.items?.['x-refsStack'] || []) .resolved, }; } else if (Array.isArray(value.items)) { resolvedValue = { ...value, items: value.items.map((item, i) => item.properties ? worker(item, exampleForProp[i]) : this.deref(item, item['x-refsStack'] || []).resolved, ), }; } else { resolvedValue = value; } if ( resolvedValue.properties && (!isObjectEmpty(exampleForProp) || exampleForProp.length > 0) ) { resolvedValue = worker(resolvedValue, exampleForProp?.[0] ?? exampleForProp); } if ((resolvedValue.items as OpenAPISchema)?.properties && isObject(exampleForProp[0])) { resolvedValue.items = worker(resolvedValue.items as OpenAPISchema, exampleForProp[0]); } if (!isObject(exampleForProp)) { resolvedValue = { ...resolvedValue, example: exampleForProp, }; } const resolved = compact({ const: resolvedValue.const, description: resolvedValue.description, deprecated: resolvedValue.deprecated, enum: resolvedValue.enum, example: resolvedValue.example, exclusiveMinimum: resolvedValue.exclusiveMinimum, format: resolvedValue.format, items: resolvedValue.items, maximum: resolvedValue.maximum, maxLength: resolvedValue.maxLength, minimum: resolvedValue.minimum, minLength: resolvedValue.minLength, pattern: resolvedValue.pattern, properties: resolvedValue.properties, readOnly: resolvedValue.readOnly, type: resolvedValue.type, writeOnly: resolvedValue.writeOnly, xml: resolvedValue.xml, }); return [key, resolved]; }), ); } return receiver; }; return worker(resolvedSchema, example); } /** * 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 { const res: Record = {}; const schemas = (this.spec.components && this.spec.components.schemas) || {}; for (const defName in schemas) { const { resolved: def } = this.deref(schemas[defName]); if ( def.allOf !== undefined && def.allOf.find( (obj: OpenAPISchema) => obj.$ref !== undefined && $refs.indexOf(obj.$ref) > -1, ) ) { res['#/components/schemas/' + defName] = [def['x-discriminator-value'] || defName]; } } return res; } private hoistOneOfs(schema: OpenAPISchema, refsStack: string[]) { if (schema.allOf === undefined) { return schema; } const allOf = schema.allOf; for (let i = 0; i < allOf.length; i++) { const sub = allOf[i]; if (Array.isArray(sub.oneOf)) { const beforeAllOf = allOf.slice(0, i); const afterAllOf = allOf.slice(i + 1); return { oneOf: sub.oneOf.map((part: OpenAPISchema) => { return { allOf: [...beforeAllOf, part, ...afterAllOf], 'x-refsStack': refsStack, }; }), }; } } return schema; } } /** * Unique array by property, missing properties are included */ function uniqByPropIncludeMissing(arr: T[], prop: keyof T): T[] { const seen = new Set(); return arr.filter(item => { const k = item[prop]; if (!k) return true; return k && !seen.has(k) && seen.add(k); }); }