From dd0a6cbf4f3764575d8358bba941a2d635eab033 Mon Sep 17 00:00:00 2001 From: Alex Varchuk Date: Wed, 6 Jul 2022 12:25:15 +0300 Subject: [PATCH 1/3] fix: rewrite recursive checks Co-authored-by: Roman Hotsiy --- package-lock.json | 13 + package.json | 7 +- src/components/ContentItems/ContentItems.tsx | 4 +- src/components/Schema/RecursiveSchema.tsx | 16 + src/components/Schema/Schema.tsx | 18 +- src/components/SearchBox/SearchBox.tsx | 10 +- src/components/SideMenu/MenuItem.tsx | 3 +- src/components/SideMenu/MenuItems.tsx | 2 +- src/components/SideMenu/SideMenu.tsx | 3 +- .../DiscriminatorDropdown.test.tsx.snap | 163 +++++- src/services/AppStore.ts | 18 +- src/services/Labels.ts | 23 +- src/services/MarkdownRenderer.ts | 17 +- src/services/MenuBuilder.ts | 39 +- src/services/MenuStore.ts | 30 +- src/services/OpenAPIParser.ts | 277 +++++----- src/services/RedocNormalizedOptions.ts | 11 +- src/services/ScrollService.ts | 2 +- src/services/SearchStore.ts | 4 +- src/services/SearchWorker.worker.ts | 12 +- src/services/SpecStore.ts | 7 +- .../__tests__/MarkdownRenderer.test.ts | 3 +- src/services/__tests__/OpenAPIParser.test.ts | 10 +- .../__snapshots__/OpenAPIParser.test.ts.snap | 106 ++-- .../__tests__/models/Schema.circular.test.ts | 486 ++++++++++++++++++ .../models/__snapshots__/Schema.test.ts.snap | 35 +- src/services/__tests__/models/helpers.ts | 76 +++ src/services/index.ts | 1 + src/services/models/ApiInfo.ts | 4 +- src/services/models/Callback.ts | 9 +- src/services/models/Example.ts | 9 +- src/services/models/Field.ts | 12 +- src/services/models/Group.model.ts | 7 +- src/services/models/MediaContent.ts | 7 +- src/services/models/MediaType.ts | 8 +- src/services/models/Operation.ts | 21 +- src/services/models/RequestBody.ts | 9 +- src/services/models/Response.ts | 9 +- src/services/models/Schema.ts | 69 ++- src/services/models/SecurityRequirement.ts | 6 +- src/services/models/SecuritySchemes.ts | 6 +- src/services/models/Webhook.ts | 9 +- src/services/types.ts | 131 +++++ src/standalone.tsx | 3 +- src/types/open-api.ts | 3 + src/utils/openapi.ts | 8 +- 46 files changed, 1226 insertions(+), 500 deletions(-) create mode 100644 src/components/Schema/RecursiveSchema.tsx create mode 100644 src/services/__tests__/models/Schema.circular.test.ts create mode 100644 src/services/__tests__/models/helpers.ts create mode 100644 src/services/types.ts diff --git a/package-lock.json b/package-lock.json index e818de0d06..a779927aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "license-checker": "^25.0.1", "lodash.noop": "^3.0.1", "mobx": "^6.3.2", + "outdent": "^0.8.0", "prettier": "^2.3.2", "pretty-quick": "^3.0.0", "raf": "^3.4.1", @@ -14282,6 +14283,12 @@ "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=", "dev": true }, + "node_modules/outdent": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", + "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", + "dev": true + }, "node_modules/p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -29936,6 +29943,12 @@ "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=", "dev": true }, + "outdent": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", + "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", + "dev": true + }, "p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", diff --git a/package.json b/package.json index 799d55f304..7bdc4bb6c8 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "license-checker": "^25.0.1", "lodash.noop": "^3.0.1", "mobx": "^6.3.2", + "outdent": "^0.8.0", "prettier": "^2.3.2", "pretty-quick": "^3.0.0", "raf": "^3.4.1", @@ -186,10 +187,12 @@ "coveragePathIgnorePatterns": [ "\\.d\\.ts$", "/benchmark/", - "/node_modules/" + "/node_modules/", + "src/services/__tests__/models/helpers.ts" ], "modulePathIgnorePatterns": [ - "/benchmark/" + "/benchmark/", + "src/services/__tests__/models/helpers.ts" ], "snapshotSerializers": [ "enzyme-to-json/serializer" diff --git a/src/components/ContentItems/ContentItems.tsx b/src/components/ContentItems/ContentItems.tsx index 2766887af7..9c2eae74e5 100644 --- a/src/components/ContentItems/ContentItems.tsx +++ b/src/components/ContentItems/ContentItems.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation'; import { AdvancedMarkdown } from '../Markdown/AdvancedMarkdown'; import { H1, H2, MiddlePanel, Row, Section, ShareLink } from '../../common-elements'; -import { ContentItemModel } from '../../services/MenuBuilder'; -import { GroupModel, OperationModel } from '../../services/models'; +import type { ContentItemModel } from '../../services'; +import type { GroupModel, OperationModel } from '../../services/models'; import { Operation } from '../Operation/Operation'; @observer diff --git a/src/components/Schema/RecursiveSchema.tsx b/src/components/Schema/RecursiveSchema.tsx new file mode 100644 index 0000000000..ad730d5ff4 --- /dev/null +++ b/src/components/Schema/RecursiveSchema.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; + +import { RecursiveLabel, TypeName, TypeTitle } from '../../common-elements/fields'; +import { l } from '../../services/Labels'; +import type { SchemaProps } from '.'; + +export const RecursiveSchema = observer(({ schema }: SchemaProps) => { + return ( +
+ {schema.displayType} + {schema.title && {schema.title} } + {l('recursive')} +
+ ); +}); diff --git a/src/components/Schema/Schema.tsx b/src/components/Schema/Schema.tsx index badd2abe5f..c0d38b1ea8 100644 --- a/src/components/Schema/Schema.tsx +++ b/src/components/Schema/Schema.tsx @@ -1,7 +1,6 @@ import { observer } from 'mobx-react'; import * as React from 'react'; -import { RecursiveLabel, TypeName, TypeTitle } from '../../common-elements/fields'; import { FieldDetails } from '../Fields/FieldDetails'; import { FieldModel, SchemaModel } from '../../services/models'; @@ -9,8 +8,8 @@ import { FieldModel, SchemaModel } from '../../services/models'; import { ArraySchema } from './ArraySchema'; import { ObjectSchema } from './ObjectSchema'; import { OneOfSchema } from './OneOfSchema'; +import { RecursiveSchema } from './RecursiveSchema'; -import { l } from '../../services/Labels'; import { isArray } from '../../utils/helpers'; export interface SchemaOptions { @@ -36,13 +35,7 @@ export class Schema extends React.Component> { const { type, oneOf, discriminatorProp, isCircular } = schema; if (isCircular) { - return ( -
- {schema.displayType} - {schema.title && {schema.title} } - {l('recursive')} -
- ); + return ; } if (discriminatorProp !== undefined) { @@ -52,11 +45,14 @@ export class Schema extends React.Component> { ); return null; } - return ( + const activeSchema = oneOf[schema.activeOneOf]; + return activeSchema.isCircular ? ( + + ) : ( ; +import type { LabelsConfig, LabelsConfigRaw } from './types'; const labels: LabelsConfig = { enum: 'Enum', diff --git a/src/services/MarkdownRenderer.ts b/src/services/MarkdownRenderer.ts index 08b5e6922c..4764e129e9 100644 --- a/src/services/MarkdownRenderer.ts +++ b/src/services/MarkdownRenderer.ts @@ -1,9 +1,8 @@ -import * as React from 'react'; import { marked } from 'marked'; import { highlight, safeSlugify, unescapeHTMLChars } from '../utils'; -import { AppStore } from './AppStore'; import { RedocNormalizedOptions } from './RedocNormalizedOptions'; +import type { MarkdownHeading, MDXComponentMeta } from './types'; const renderer = new marked.Renderer(); @@ -22,20 +21,6 @@ export const MDX_COMPONENT_REGEXP = '(?:^ {0,3}<({component})([\\s\\S]*?)>([\\s\ export const COMPONENT_REGEXP = '(?:' + LEGACY_REGEXP + '|' + MDX_COMPONENT_REGEXP + ')'; -export interface MDXComponentMeta { - component: React.ComponentType; - propsSelector: (store?: AppStore) => any; - props?: object; -} - -export interface MarkdownHeading { - id: string; - name: string; - level: number; - items?: MarkdownHeading[]; - description?: string; -} - export function buildComponentComment(name: string) { return ``; } diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 56c4ad70ea..fb8cf5a5c7 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -1,41 +1,12 @@ -import { - OpenAPIOperation, - OpenAPIParameter, - OpenAPISpec, - OpenAPITag, - Referenced, - OpenAPIServer, - OpenAPIPaths, -} from '../types'; +import type { OpenAPISpec, OpenAPIPaths } from '../types'; import { isOperationName, JsonPointer, alphabeticallyByProp } from '../utils'; import { MarkdownRenderer } from './MarkdownRenderer'; import { GroupModel, OperationModel } from './models'; -import { OpenAPIParser } from './OpenAPIParser'; -import { RedocNormalizedOptions } from './RedocNormalizedOptions'; - -export type TagInfo = OpenAPITag & { - operations: ExtendedOpenAPIOperation[]; - used?: boolean; -}; - -export type ExtendedOpenAPIOperation = { - pointer: string; - pathName: string; - httpVerb: string; - pathParameters: Array>; - pathServers: Array | undefined; - isWebhook: boolean; -} & OpenAPIOperation; - -export type TagsInfoMap = Record; - -export interface TagGroup { - name: string; - tags: string[]; -} +import type { OpenAPIParser } from './OpenAPIParser'; +import type { RedocNormalizedOptions } from './RedocNormalizedOptions'; +import type { ContentItemModel, TagGroup, TagInfo, TagsInfoMap } from './types'; export const GROUP_DEPTH = 0; -export type ContentItemModel = GroupModel | OperationModel; export class MenuBuilder { /** @@ -239,7 +210,7 @@ export class MenuBuilder { for (const operationName of operations) { const operationInfo = path[operationName]; if (path.$ref) { - const resolvedPaths = parser.deref(path as OpenAPIPaths); + const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths); getTags(parser, { [pathName]: resolvedPaths }, isWebhook); continue; } diff --git a/src/services/MenuStore.ts b/src/services/MenuStore.ts index 75a9956d03..34fb5e083d 100644 --- a/src/services/MenuStore.ts +++ b/src/services/MenuStore.ts @@ -1,37 +1,15 @@ import { action, observable, makeObservable } from 'mobx'; import { querySelector } from '../utils/dom'; -import { SpecStore } from './models'; +import { escapeHTMLAttrChars, flattenByProp, SECURITY_SCHEMES_SECTION_PREFIX } from '../utils'; import { history as historyInst, HistoryService } from './HistoryService'; -import { ScrollService } from './ScrollService'; - -import { escapeHTMLAttrChars, flattenByProp, SECURITY_SCHEMES_SECTION_PREFIX } from '../utils'; import { GROUP_DEPTH } from './MenuBuilder'; -export type MenuItemGroupType = 'group' | 'tag' | 'section'; -export type MenuItemType = MenuItemGroupType | 'operation'; +import type { SpecStore } from './models'; +import type { ScrollService } from './ScrollService'; +import type { IMenuItem } from './types'; /** Generic interface for MenuItems */ -export interface IMenuItem { - id: string; - absoluteIdx?: number; - name: string; - sidebarLabel: string; - description?: string; - depth: number; - active: boolean; - expanded: boolean; - items: IMenuItem[]; - parent?: IMenuItem; - deprecated?: boolean; - type: MenuItemType; - - deactivate(): void; - activate(): void; - - collapse(): void; - expand(): void; -} export const SECTION_ATTR = 'data-section-id'; diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index 21da1ef260..5894b0f4c6 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -1,45 +1,28 @@ -import { OpenAPIRef, OpenAPISchema, OpenAPISpec, Referenced } from '../types'; - -import { isArray, isBoolean, IS_BROWSER } from '../utils'; +import type { OpenAPIRef, OpenAPISchema, OpenAPISpec } from '../types'; +import { IS_BROWSER, getDefinitionName } from '../utils/'; import { JsonPointer } from '../utils/JsonPointer'; -import { getDefinitionName, isNamedDefinition } from '../utils/openapi'; -import { RedocNormalizedOptions } from './RedocNormalizedOptions'; -export type MergedOpenAPISchema = OpenAPISchema & { parentRefs?: string[] }; +import { RedocNormalizedOptions } from './RedocNormalizedOptions'; +import type { MergedOpenAPISchema } from './types'; /** - * Helper class to keep track of visited references to avoid - * endless recursion because of circular refs + * Loads and keeps spec. Provides raw spec operations */ -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; - } +export function pushRef(stack: string[], ref?: string): string[] { + return ref && stack[stack.length - 1] !== ref ? [...stack, ref] : stack; +} - visited(ref: string): boolean { - return !!this._counter[ref]; - } +export function concatRefStacks(base: string[], stack?: string[]): string[] { + return stack ? base.concat(stack) : base; } -/** - * Loads and keeps spec. Provides raw spec operations - */ export class OpenAPIParser { specUrl?: string; spec: OpenAPISpec; - private _refCounter: RefCounter = new RefCounter(); - private allowMergeRefs: boolean = false; + // private _refCounter: RefCounter = new RefCounter(); + private readonly allowMergeRefs: boolean = false; constructor( spec: OpenAPISpec, @@ -51,13 +34,13 @@ export class OpenAPIParser { this.spec = spec; this.allowMergeRefs = spec.openapi.startsWith('3.1'); - const href = IS_BROWSER ? window.location.href : undefined; + const href = IS_BROWSER ? window.location.href : ''; if (typeof specUrl === 'string') { this.specUrl = new URL(specUrl, href).href; } } - validate(spec: any) { + validate(spec: GenericObject): void { if (spec.openapi === undefined) { throw new Error('Document must be valid OpenAPI 3.0.0 definition'); } @@ -86,101 +69,82 @@ export class OpenAPIParser { /** * checks if the object is OpenAPI reference (contains $ref property) */ - isRef(obj: any): obj is OpenAPIRef { + isRef(obj: OpenAPIRef | T): obj is OpenAPIRef { if (!obj) { return false; } + obj = obj; 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(ref: Referenced) { - 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 + * @param mergeAsAllOf */ - deref(obj: OpenAPIRef | T, forceCircular = false, mergeAsAllOf = false): T { + 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 { type: 'object', title: schemaName } as T; + return { resolved: { type: 'object', title: schemaName } as T, refsStack: baseRefsStack }; } - const resolved = this.byRef(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); + let resolved = this.byRef(obj.$ref); + if (!resolved) { + throw new Error(`Failed to resolve $ref "${obj.$ref}"`); } - return this.allowMergeRefs ? this.mergeRefs(obj, resolved, mergeAsAllOf) : result; - } - return obj; - } - shallowDeref(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; + let refsStack = baseRefsStack; + if (baseRefsStack.includes(obj.$ref)) { + 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; } - const resolved = this.byRef(obj.$ref); - return this.allowMergeRefs ? this.mergeRefs(obj, resolved, false) : (resolved as T); + + refsStack = pushRef(baseRefsStack, obj.$ref); + resolved = this.allowMergeRefs ? this.mergeRefs(obj, resolved, mergeAsAllOf) : resolved; + + return { resolved, refsStack }; } - return obj; + return { + resolved: obj, + refsStack: concatRefStacks(baseRefsStack, objRefsStack), + }; } - mergeRefs(ref, resolved, mergeAsAllOf: boolean) { + 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) { - if (this.isRef(resolved)) { - return this.shallowDeref(resolved); - } return resolved; } if ( mergeAsAllOf && - keys.some(k => k !== 'description' && k !== 'title' && k !== 'externalDocs') + keys.some( + k => !['description', 'title', 'externalDocs', 'x-refsStack', 'x-parentRefs'].includes(k), + ) ) { return { allOf: [resolved, rest], - }; + } as T; } else { // small optimization return { - ...resolved, + ...(resolved as GenericObject), ...rest, - }; + } as T; } } @@ -189,15 +153,15 @@ export class OpenAPIParser { * @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: OpenAPISchema, - $ref?: string, - forceCircular: boolean = false, - used$Refs = new Set(), + schema: MergedOpenAPISchema, + $ref: string | undefined, + refsStack: string[], ): MergedOpenAPISchema { - if ($ref) { - used$Refs.add($ref); + if (schema['x-circular-ref']) { + return schema; } schema = this.hoistOneOfs(schema); @@ -208,8 +172,8 @@ export class OpenAPIParser { let receiver: MergedOpenAPISchema = { ...schema, + 'x-parentRefs': [], allOf: undefined, - parentRefs: [], title: schema.title || getDefinitionName($ref), }; @@ -222,36 +186,41 @@ export class OpenAPIParser { } const allOfSchemas = schema.allOf - .map(subSchema => { - if (subSchema && subSchema.$ref && used$Refs.has(subSchema.$ref)) { - return undefined; - } + .map((subSchema: OpenAPISchema) => { + const { resolved, refsStack: subRefsStack } = this.deref(subSchema, refsStack, true); - 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 || [])); + 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<{ - $ref: string | undefined; schema: MergedOpenAPISchema; + refsStack: string[]; }>; - for (const { $ref: subSchemaRef, schema: subSchema } of allOfSchemas) { + for (const { schema: subSchema, refsStack: subRefsStack } of allOfSchemas) { const { type, - format, enum: enumProperty, properties, items, required, + title, oneOf, anyOf, - title, + 'x-circular-ref': isCircular, ...otherConstraints } = subSchema; @@ -264,7 +233,6 @@ export class OpenAPIParser { receiver.type = [...type, ...receiver.type]; } else { receiver.type = type; - receiver.format = format; } } @@ -276,43 +244,51 @@ export class OpenAPIParser { } } - if (properties !== undefined) { + 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]; - } else { + 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]] }, + { + allOf: [receiver.properties[prop], properties[prop]], + 'x-refsStack': propRefsStack, + }, $ref + '/properties/' + prop, + propRefsStack, ); 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); + if (items !== undefined && !isCircular) { + // FIXME: this is invalid here, we need to fix it in separate PR + const receiverItems = + typeof receiver.items === 'boolean' + ? { items: receiver.items } + : receiver.items + ? (Object.assign({}, receiver.items) as OpenAPISchema) + : {}; + const subSchemaItems = + typeof subSchema.items === 'boolean' + ? { items: subSchema.items } + : (Object.assign({}, subSchema.items) as OpenAPISchema); // merge inner properties receiver.items = this.mergeAllOf( - { allOf: [receiverItems, subSchemaItems] }, + { + allOf: [receiverItems, subSchemaItems], + }, $ref + '/items', + subRefsStack, ); } - - if (required !== undefined) { - receiver.required = (receiver.required || []).concat(required); - } - if (oneOf !== undefined) { receiver.oneOf = oneOf; } @@ -321,18 +297,18 @@ export class OpenAPIParser { 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, ...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); - } - } + receiver = { + ...receiver, + title: receiver.title || title, + 'x-circular-ref': receiver['x-circular-ref'] || isCircular, + ...otherConstraints, + }; } return receiver; @@ -347,10 +323,12 @@ export class OpenAPIParser { const res: Record = {}; const schemas = (this.spec.components && this.spec.components.schemas) || {}; for (const defName in schemas) { - const def = this.deref(schemas[defName]); + const { resolved: def } = this.deref(schemas[defName]); if ( def.allOf !== undefined && - def.allOf.find(obj => obj.$ref !== undefined && $refs.indexOf(obj.$ref) > -1) + def.allOf.find( + (obj: OpenAPISchema) => obj.$ref !== undefined && $refs.indexOf(obj.$ref) > -1, + ) ) { res['#/components/schemas/' + defName] = [def['x-discriminator-value'] || defName]; } @@ -358,12 +336,6 @@ export class OpenAPIParser { 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; @@ -372,19 +344,14 @@ export class OpenAPIParser { const allOf = schema.allOf; for (let i = 0; i < allOf.length; i++) { const sub = allOf[i]; - if (isArray(sub.oneOf)) { + if (Array.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({ + oneOf: sub.oneOf.map((part: OpenAPISchema) => { + return { 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; + }; }), }; } diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 3efcbac71e..4a219eef16 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -2,14 +2,9 @@ import defaultTheme, { ResolvedThemeInterface, resolveTheme, ThemeInterface } fr import { querySelector } from '../utils/dom'; import { isArray, isNumeric, mergeObjects } from '../utils/helpers'; -import { LabelsConfigRaw, setRedocLabels } from './Labels'; -import { MDXComponentMeta } from './MarkdownRenderer'; - -export enum SideNavStyleEnum { - SummaryOnly = 'summary-only', - PathOnly = 'path-only', - IdOnly = 'id-only', -} +import { setRedocLabels } from './Labels'; +import { SideNavStyleEnum } from './types'; +import type { LabelsConfigRaw, MDXComponentMeta } from './types'; export interface RedocRawOptions { theme?: ThemeInterface; diff --git a/src/services/ScrollService.ts b/src/services/ScrollService.ts index 1843ea3756..bbfa20fc4d 100644 --- a/src/services/ScrollService.ts +++ b/src/services/ScrollService.ts @@ -2,7 +2,7 @@ import { bind } from 'decko'; import * as EventEmitter from 'eventemitter3'; import { IS_BROWSER, querySelector, Throttle } from '../utils'; -import { RedocNormalizedOptions } from './RedocNormalizedOptions'; +import type { RedocNormalizedOptions } from './RedocNormalizedOptions'; const EVENT = 'scroll'; diff --git a/src/services/SearchStore.ts b/src/services/SearchStore.ts index 927bc14cf9..4600c0ec6c 100644 --- a/src/services/SearchStore.ts +++ b/src/services/SearchStore.ts @@ -1,6 +1,6 @@ import { IS_BROWSER } from '../utils/'; -import { IMenuItem } from './MenuStore'; -import { OperationModel } from './models'; +import type { IMenuItem } from './types'; +import type { OperationModel } from './models'; import Worker from './SearchWorker.worker'; diff --git a/src/services/SearchWorker.worker.ts b/src/services/SearchWorker.worker.ts index a4a6da4b34..764370f190 100644 --- a/src/services/SearchWorker.worker.ts +++ b/src/services/SearchWorker.worker.ts @@ -1,4 +1,5 @@ import * as lunr from 'lunr'; +import type { SearchResult } from './types'; /* just for better typings */ export default class Worker { @@ -11,17 +12,6 @@ export default class Worker { fromExternalJS = fromExternalJS; } -export interface SearchDocument { - title: string; - description: string; - id: string; -} - -export interface SearchResult { - meta: T; - score: number; -} - let store: any[] = []; lunr.tokenizer.separator = /\s+/; diff --git a/src/services/SpecStore.ts b/src/services/SpecStore.ts index 20023ce057..d0de7a9c86 100644 --- a/src/services/SpecStore.ts +++ b/src/services/SpecStore.ts @@ -1,11 +1,12 @@ -import { OpenAPIExternalDocumentation, OpenAPIPath, OpenAPISpec, Referenced } from '../types'; +import type { OpenAPIExternalDocumentation, OpenAPIPath, OpenAPISpec, Referenced } from '../types'; -import { ContentItemModel, MenuBuilder } from './MenuBuilder'; +import { MenuBuilder } from './MenuBuilder'; import { ApiInfoModel } from './models/ApiInfo'; import { WebhookModel } from './models/Webhook'; import { SecuritySchemesModel } from './models/SecuritySchemes'; import { OpenAPIParser } from './OpenAPIParser'; -import { RedocNormalizedOptions } from './RedocNormalizedOptions'; +import type { RedocNormalizedOptions } from './RedocNormalizedOptions'; +import type { ContentItemModel } from './types'; /** * Store that contains all the specification related information in the form of tree */ diff --git a/src/services/__tests__/MarkdownRenderer.test.ts b/src/services/__tests__/MarkdownRenderer.test.ts index efd0d1fb3a..669a8fb0e2 100644 --- a/src/services/__tests__/MarkdownRenderer.test.ts +++ b/src/services/__tests__/MarkdownRenderer.test.ts @@ -1,4 +1,5 @@ -import { MarkdownRenderer, MDXComponentMeta } from '../MarkdownRenderer'; +import type { MDXComponentMeta } from '../types'; +import { MarkdownRenderer } from '../MarkdownRenderer'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; const TestComponent = () => null; diff --git a/src/services/__tests__/OpenAPIParser.test.ts b/src/services/__tests__/OpenAPIParser.test.ts index 24fbc33094..d32e0f8ecf 100644 --- a/src/services/__tests__/OpenAPIParser.test.ts +++ b/src/services/__tests__/OpenAPIParser.test.ts @@ -41,14 +41,14 @@ describe('Models', () => { expect(schema.title).toEqual('Foo'); }); - test('should merge oneOff to inside allOff', () => { + test('should merge oneOf to inside allOff', () => { // TODO: should hoist const spec = require('./fixtures/mergeAllOf.json'); parser = new OpenAPIParser(spec, undefined, opts); const schema = parser.mergeAllOf(spec.components.schemas.Case4); expect(schema.title).toEqual('Foo'); - expect(schema.parentRefs).toHaveLength(1); - expect(schema.parentRefs[0]).toEqual('#/components/schemas/Ref'); + expect(schema['x-parentRefs']).toHaveLength(1); + expect(schema['x-parentRefs'][0]).toEqual('#/components/schemas/Ref'); expect(schema.oneOf).toEqual([{ title: 'Bar' }, { title: 'Baz' }]); }); @@ -60,7 +60,7 @@ describe('Models', () => { description: 'Overriden description', }; - expect(parser.shallowDeref(schemaOrRef)).toMatchSnapshot(); + expect(parser.deref(schemaOrRef)).toMatchSnapshot(); }); test('should correct resolve double $ref if no need sibling', () => { @@ -70,7 +70,7 @@ describe('Models', () => { $ref: '#/components/schemas/Parent', }; - expect(parser.deref(schemaOrRef, false, true)).toMatchSnapshot(); + expect(parser.deref(schemaOrRef, [], true)).toMatchSnapshot(); }); }); }); diff --git a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap index 8b5ea0181d..889d23592b 100644 --- a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap +++ b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap @@ -2,12 +2,17 @@ exports[`Models Schema should correct resolve double $ref if no need sibling 1`] = ` Object { - "properties": Object { - "test": Object { - "type": "string", + "refsStack": Array [ + "#/components/schemas/Parent", + ], + "resolved": Object { + "properties": Object { + "test": Object { + "type": "string", + }, }, + "type": "object", }, - "type": "object", } `; @@ -15,82 +20,80 @@ exports[`Models Schema should hoist oneOfs when mergin allOf 1`] = ` Object { "oneOf": Array [ Object { - "oneOf": Array [ + "allOf": Array [ Object { - "allOf": undefined, - "parentRefs": Array [], "properties": Object { - "extra": Object { - "type": "string", - }, - "password": Object { - "description": "The user's password", - "type": "string", - }, "username": Object { "description": "The user's name", "type": "string", }, }, - "title": undefined, }, Object { - "allOf": undefined, - "parentRefs": Array [], "properties": Object { "extra": Object { "type": "string", }, - "mobile": Object { - "description": "The user's mobile", - "type": "string", + }, + }, + Object { + "oneOf": Array [ + Object { + "properties": Object { + "password": Object { + "description": "The user's password", + "type": "string", + }, + }, }, - "username": Object { - "description": "The user's name", - "type": "string", + Object { + "properties": Object { + "mobile": Object { + "description": "The user's mobile", + "type": "string", + }, + }, }, - }, - "title": undefined, + ], }, ], }, Object { - "oneOf": Array [ + "allOf": Array [ Object { - "allOf": undefined, - "parentRefs": Array [], "properties": Object { "email": Object { "description": "The user's email", "type": "string", }, - "extra": Object { - "type": "string", - }, - "password": Object { - "description": "The user's password", - "type": "string", - }, }, - "title": undefined, }, Object { - "allOf": undefined, - "parentRefs": Array [], "properties": Object { - "email": Object { - "description": "The user's email", - "type": "string", - }, "extra": Object { "type": "string", }, - "mobile": Object { - "description": "The user's mobile", - "type": "string", - }, }, - "title": undefined, + }, + Object { + "oneOf": Array [ + Object { + "properties": Object { + "password": Object { + "description": "The user's password", + "type": "string", + }, + }, + }, + Object { + "properties": Object { + "mobile": Object { + "description": "The user's mobile", + "type": "string", + }, + }, + }, + ], }, ], }, @@ -100,7 +103,12 @@ Object { exports[`Models Schema should override description from $ref of the referenced component, when sibling description exists 1`] = ` Object { - "description": "Overriden description", - "type": "object", + "refsStack": Array [ + "#/components/schemas/Test", + ], + "resolved": Object { + "description": "Overriden description", + "type": "object", + }, } `; diff --git a/src/services/__tests__/models/Schema.circular.test.ts b/src/services/__tests__/models/Schema.circular.test.ts new file mode 100644 index 0000000000..11f4012733 --- /dev/null +++ b/src/services/__tests__/models/Schema.circular.test.ts @@ -0,0 +1,486 @@ +import outdent from 'outdent'; +import { parseYaml } from '@redocly/openapi-core'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +import { SchemaModel } from '../../models'; +import { OpenAPIParser } from '../../OpenAPIParser'; +import { RedocNormalizedOptions } from '../../RedocNormalizedOptions'; + +import { circularDetailsPrinter, printSchema } from './helpers'; + +const opts = new RedocNormalizedOptions({}) as RedocNormalizedOptions; + +describe('Models', () => { + describe.only('Schema Circular tracking', () => { + let parser; + + expect.addSnapshotSerializer({ + test: val => typeof val === 'string', + print: v => v as string, + }); + + test('should detect circular for array nested in allOf', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Schema: + type: object + properties: + a: { $ref: '#/components/schemas/Schema' } + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Schema, + '#/components/schemas/Schema', + opts, + ); + + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot( + `a: !circular`, + ); + }); + + test('should not detect circular refs when ref used multiple times across allOf', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Foo: + type: object + properties: + foo: { type: string } + Schema: + allOf: + - $ref: '#/components/schemas/Foo' + - type: object + properties: + foobar: { $ref: '#/components/schemas/Foo' } + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.Schema, '', opts); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + foo: + foobar: + foo: + `); + }); + + test('should detect circular for array with self-reference', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Array: + type: "array" + items: { "$ref": "#/components/schemas/Array" } + Schema: + allOf: [{ "$ref": "#/components/schemas/Array" }] + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Schema, + '#/components/schemas/Schema', + opts, + ); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot( + `[ !circular]`, + ); + }); + + test('should detect circular for object nested in allOf', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Object: + allOf: + - $ref: '#/components/schemas/Object' + - type: "object" + properties: { "a": { "$ref": "#/components/schemas/Object" } } + Schema: + allOf: [{ "$ref": "#/components/schemas/Object" }] + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Schema, + '#/components/schemas/Schema', + opts, + ); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot( + `a: !circular`, + ); + }); + + test('should not detect circular for base DTO case', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + BaseDTO: + type: object + properties: + id: {type: string} + BaseB: + type: object + allOf: + - $ref: '#/components/schemas/BaseDTO' + - type: object + properties: + fieldB: { type: string } + BaseA: + type: object + allOf: + - $ref: '#/components/schemas/BaseDTO' + - type: object + properties: + b: { $ref: '#/components/schemas/BaseB' } + fieldA: { type: string } + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.BaseA, '', opts); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + id: + b: + id: + fieldB: + fieldA: + `); + }); + + test('should detect circular ref for self referencing discriminator', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + SelfComponentDto: + type: object + properties: + self: + type: object + discriminator: + propertyName: schemaId + mapping: + title: '#/components/schemas/SelfComponentDto' + oneOf: + - $ref: '#/components/schemas/SelfComponentDto' + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.SelfComponentDto, '', opts); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + self: oneOf + title -> + self: oneOf + title -> !circular + `); + }); + + test('should detect circular with nested oneOf hoisting', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Node: + type: 'object' + allOf: + - oneOf: + - type: object + properties: + parent: + $ref: '#/components/schemas/Node' + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.Node, '', opts); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + oneOf + object -> + parent: oneOf + object -> + parent: !circular + `); + }); + + test('should detect simple props recursion', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + PropRecursion: + properties: + children: + type: object + properties: + a: + $ref: '#/components/schemas/PropRecursion' + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.PropRecursion, '', opts); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + children: + a: + children: + a: !circular + `); + }); + + test('should detect recursion for props with type array', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + PropsRecursion: + properties: + children: + type: array + items: + $ref: '#/components/schemas/PropsRecursion' + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.PropsRecursion, '', opts); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + children: [ + children: [ !circular] + ] + `); + }); + + test('should detect and ignore allOf recursion', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Parent: + $ref: '#/components/schemas/Child' + Child: + allOf: + - $ref: '#/components/schemas/Parent' + - type: object + properties: + a: + type: string + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Child, + '#/components/schemas/Child', + opts, + ); + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(`a: `); + }); + + test('should detect and ignore allOf recursion in nested prop', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + ExternalReference: + type: object + allOf: + - $ref: '#/components/schemas/CompanyReference' + - type: object + properties: + externalId: { type: string } + CompanyReference: + type: object + required: [ guid, externalId ] + properties: + guid: { type: string } + nestedRecursive: { $ref: '#/components/schemas/ExternalReference' } + Entity: + type: object + allOf: + - $ref: '#/components/schemas/ExternalReference' + - type: object + properties: + directRecursive: { $ref: '#/components/schemas/ExternalReference' } + selfRecursive: { $ref: '#/components/schemas/Entity' } + anotherField: { $ref: '#/components/schemas/AnotherEntity' } + AnotherEntity: + type: object + allOf: + - $ref: '#/components/schemas/CompanyReference' + - type: object + properties: + someField: { type: number } + anotherSelfRecursive: { $ref: '#/components/schemas/AnotherEntity' } + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Entity, + '#/components/schemas/Entity', + opts, + ); + + // TODO: this has a little issue with too early detection in anotherField -> nestedRecursive + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + guid*: + nestedRecursive: !circular + externalId*: + directRecursive: + guid*: + nestedRecursive: !circular + externalId*: + selfRecursive: !circular + anotherField: + guid*: + nestedRecursive: !circular + someField: + anotherSelfRecursive: !circular + `); + }); + + test('should detect and ignore allOf with discriminator recursion', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Pet: + type: object + required: [ petType ] + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' + properties: + category: { $ref: '#/components/schemas/Category' } + status: { type: string } + friend: + allOf: [{ $ref: '#/components/schemas/Pet' }] + petType: { type: string } + Cat: + description: A representation of a cat + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + huntingSkill: { type: string } + Dog: + description: A representation of a dog + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + packSize: { type: integer } + Category: + type: object + properties: + name: { type: string } + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Pet, + '#/components/schemas/Pet', + opts, + ); + + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + oneOf + cat -> + category: + name: + status: + friend: !circular + petType*: + huntingSkill: + dog -> + category: + name: + status: + friend: !circular + petType*: + packSize: + `); + }); + + test('should detect and recursion on the right level with array of discriminators', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + Pet: + type: object + required: [ petType ] + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' + properties: + category: { $ref: '#/components/schemas/Category' } + status: { type: string } + friend: + allOf: [{ $ref: '#/components/schemas/Pet' }] + petType: { type: string } + Cat: + description: A representation of a cat + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + huntingSkill: { type: string } + Dog: + description: A representation of a dog + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + packSize: { type: integer } + Category: + type: object + properties: + name: { type: string } + Response: + type: array + items: + $ref: '#/components/schemas/Pet' + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.Response, + '#/components/schemas/Response', + opts, + ); + + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + [ + oneOf + cat -> + category: + name: + status: + friend: !circular + petType*: + huntingSkill: + dog -> + category: + name: + status: + friend: !circular + petType*: + packSize: + ] + `); + }); + }); +}); diff --git a/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap b/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap index 102231d6ae..63396ec6c3 100644 --- a/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap +++ b/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap @@ -4,20 +4,21 @@ exports[`Models Schema schemaDefinition should resolve field with conditional op Object { "allOf": undefined, "default": undefined, - "format": undefined, "items": Object { "allOf": undefined, "format": "url", - "parentRefs": Array [], "title": undefined, "type": "string", + "x-circular-ref": undefined, + "x-parentRefs": Array [], }, "maxItems": 20, "minItems": 1, - "parentRefs": Array [], "title": "isString", "type": "string", + "x-circular-ref": undefined, "x-displayName": "isString", + "x-parentRefs": Array [], } `; @@ -25,17 +26,16 @@ exports[`Models Schema schemaDefinition should resolve field with conditional op Object { "allOf": undefined, "default": undefined, - "format": undefined, "items": Object { "allOf": undefined, "format": "url", - "parentRefs": Array [], "title": undefined, "type": "string", + "x-circular-ref": undefined, + "x-parentRefs": Array [], }, "maxItems": 10, "minItems": 1, - "parentRefs": Array [], "pattern": "\\\\d+", "title": "notString", "type": Array [ @@ -43,16 +43,16 @@ Object { "integer", "null", ], + "x-circular-ref": undefined, "x-displayName": "notString", + "x-parentRefs": Array [], } `; exports[`Models Schema schemaDefinition should resolve schema with conditional operators 1`] = ` Object { "allOf": undefined, - "format": undefined, "maxItems": 2, - "parentRefs": Array [], "properties": Object { "test": Object { "allOf": undefined, @@ -60,36 +60,40 @@ Object { "enum": Array [ 10, ], - "format": undefined, "items": Object { "allOf": undefined, "format": "url", - "parentRefs": Array [], "title": undefined, "type": "string", + "x-circular-ref": undefined, + "x-parentRefs": Array [], }, "maxItems": 20, "minItems": 1, - "parentRefs": Array [], "title": undefined, "type": Array [ "string", "integer", "null", ], + "x-circular-ref": undefined, + "x-parentRefs": Array [], + "x-refsStack": Array [ + "/oneOf/0", + ], }, }, "title": "=== 10", "type": "object", + "x-circular-ref": undefined, + "x-parentRefs": Array [], } `; exports[`Models Schema schemaDefinition should resolve schema with conditional operators 2`] = ` Object { "allOf": undefined, - "format": undefined, "maxItems": 20, - "parentRefs": Array [], "properties": Object { "test": Object { "description": "The list of URL to a cute photos featuring pet", @@ -104,9 +108,14 @@ Object { "integer", "null", ], + "x-refsStack": Array [ + "/oneOf/1", + ], }, }, "title": "case 2", "type": "object", + "x-circular-ref": undefined, + "x-parentRefs": Array [], } `; diff --git a/src/services/__tests__/models/helpers.ts b/src/services/__tests__/models/helpers.ts new file mode 100644 index 0000000000..60455c6145 --- /dev/null +++ b/src/services/__tests__/models/helpers.ts @@ -0,0 +1,76 @@ +import type { SchemaModel } from '../../models'; + +function printType(type: string | string[]): string { + return `<${type}>`; +} + +function printDescription(description: string | string[]): string { + return description ? ` (${description})` : ''; +} + +export function circularDetailsPrinter(schema: SchemaModel): string { + return schema.isCircular ? ' !circular' : ''; +} + +export function printSchema( + schema: SchemaModel, + detailsPrinter: (schema: SchemaModel) => string = () => '', + identLevel = 0, + inline = false, +): string { + if (!schema) return ''; + const ident = ' '.repeat(identLevel); + + if (schema.isPrimitive || schema.isCircular) { + if (schema.type === 'array' && schema.items) { + return `${inline ? ' ' : ident}[${printType(schema.items.type)}${detailsPrinter( + schema.items, + )}]${printDescription(schema.items.description)}`; + } else { + return `${inline ? ' ' : ident}${printType(schema.displayType)}${detailsPrinter( + schema, + )}${printDescription(schema.description)}`; + } + } + + if (schema.oneOf) { + return ( + `${inline ? ' ' : ident}oneOf\n` + + schema.oneOf + .map(sub => { + return ( + `${ident} ${sub.title || sub.displayType} ->` + + printSchema(sub, detailsPrinter, identLevel + 2, true) + ); + }) + .join('\n') + ); + } + + if (schema.fields) { + const prefix = inline ? '\n' : ''; + return ( + prefix + + schema.fields + .map(f => { + return `${ident}${f.name}${f.required ? '*' : ''}:${printSchema( + f.schema, + detailsPrinter, + identLevel + 1, + true, + )}`; + }) + .join('\n') + ); + } + + if (schema.items) { + return ( + `${inline ? ' ' : ident}[\n` + + printSchema(schema.items, detailsPrinter, identLevel) + + `\n${inline ? ident.slice(0, -2) : ident}]` + ); + } + + return ' error'; +} diff --git a/src/services/index.ts b/src/services/index.ts index 70493d7dea..4115701272 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -11,3 +11,4 @@ export * from './RedocNormalizedOptions'; export * from './MenuBuilder'; export * from './SearchStore'; export * from './MarkerService'; +export * from './types'; diff --git a/src/services/models/ApiInfo.ts b/src/services/models/ApiInfo.ts index 0c4d91ebd2..0b8e347d34 100644 --- a/src/services/models/ApiInfo.ts +++ b/src/services/models/ApiInfo.ts @@ -1,6 +1,6 @@ -import { OpenAPIContact, OpenAPIInfo, OpenAPILicense } from '../../types'; +import type { OpenAPIContact, OpenAPIInfo, OpenAPILicense } from '../../types'; import { IS_BROWSER } from '../../utils/'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPIParser } from '../OpenAPIParser'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; export class ApiInfoModel implements OpenAPIInfo { diff --git a/src/services/models/Callback.ts b/src/services/models/Callback.ts index 05499f5afc..49583a2909 100644 --- a/src/services/models/Callback.ts +++ b/src/services/models/Callback.ts @@ -1,10 +1,10 @@ import { action, observable, makeObservable } from 'mobx'; -import { OpenAPICallback, Referenced } from '../../types'; import { isOperationName, JsonPointer } from '../../utils'; -import { OpenAPIParser } from '../OpenAPIParser'; import { OperationModel } from './Operation'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPICallback, Referenced } from '../../types'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; export class CallbackModel { @observable @@ -23,8 +23,7 @@ export class CallbackModel { makeObservable(this); this.name = name; - const paths = parser.deref(infoOrRef); - parser.exitRef(infoOrRef); + const { resolved: paths } = parser.deref(infoOrRef); for (const pathName of Object.keys(paths)) { const path = paths[pathName]; diff --git a/src/services/models/Example.ts b/src/services/models/Example.ts index 1eb81847f6..b5f7e0ff97 100644 --- a/src/services/models/Example.ts +++ b/src/services/models/Example.ts @@ -1,6 +1,6 @@ -import { OpenAPIEncoding, OpenAPIExample, Referenced } from '../../types'; +import type { OpenAPIEncoding, OpenAPIExample, Referenced } from '../../types'; import { isFormUrlEncoded, isJsonLike, urlFormEncodePayload } from '../../utils/openapi'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPIParser } from '../OpenAPIParser'; const externalExamplesCache: { [url: string]: Promise } = {}; @@ -16,14 +16,13 @@ export class ExampleModel { public mime: string, encoding?: { [field: string]: OpenAPIEncoding }, ) { - const example = parser.deref(infoOrRef); + const { resolved: example } = parser.deref(infoOrRef); this.value = example.value; this.summary = example.summary; this.description = example.description; if (example.externalValue) { this.externalValueUrl = new URL(example.externalValue, parser.specUrl).href; } - parser.exitRef(infoOrRef); if (isFormUrlEncoded(mime) && this.value && typeof this.value === 'object') { this.value = urlFormEncodePayload(this.value, encoding); @@ -35,7 +34,7 @@ export class ExampleModel { return Promise.resolve(undefined); } - if (externalExamplesCache[this.externalValueUrl]) { + if (this.externalValueUrl in externalExamplesCache) { return externalExamplesCache[this.externalValueUrl]; } diff --git a/src/services/models/Field.ts b/src/services/models/Field.ts index 83c6ad6d9c..537fdbeeba 100644 --- a/src/services/models/Field.ts +++ b/src/services/models/Field.ts @@ -1,15 +1,15 @@ import { action, observable, makeObservable } from 'mobx'; -import { +import type { OpenAPIParameter, OpenAPIParameterLocation, OpenAPIParameterStyle, Referenced, } from '../../types'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { extractExtensions } from '../../utils/openapi'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPIParser } from '../OpenAPIParser'; import { SchemaModel } from './Schema'; import { ExampleModel } from './Example'; import { isArray, mapValues } from '../../utils/helpers'; @@ -64,10 +64,11 @@ export class FieldModel { infoOrRef: Referenced & { name?: string; kind?: string }, pointer: string, options: RedocNormalizedOptions, + refsStack?: string[], ) { makeObservable(this); - const info = parser.deref(infoOrRef); + const { resolved: info } = parser.deref(infoOrRef); this.kind = infoOrRef.kind || 'field'; this.name = infoOrRef.name || info.name; this.in = info.in; @@ -80,7 +81,7 @@ export class FieldModel { fieldSchema = info.content[serializationMime] && info.content[serializationMime].schema; } - this.schema = new SchemaModel(parser, fieldSchema || {}, pointer, options); + this.schema = new SchemaModel(parser, fieldSchema || {}, pointer, options, false, refsStack); this.description = info.description === undefined ? this.schema.description || '' : info.description; this.example = info.example || this.schema.example; @@ -110,7 +111,6 @@ export class FieldModel { } this.deprecated = info.deprecated === undefined ? !!this.schema.deprecated : info.deprecated; - parser.exitRef(infoOrRef); if (options.showExtensions) { this.extensions = extractExtensions(info, options.showExtensions); diff --git a/src/services/models/Group.model.ts b/src/services/models/Group.model.ts index 8a9429b2e3..e68f1724d1 100644 --- a/src/services/models/Group.model.ts +++ b/src/services/models/Group.model.ts @@ -1,10 +1,9 @@ import { action, observable, makeObservable } from 'mobx'; -import { OpenAPIExternalDocumentation, OpenAPITag } from '../../types'; +import type { OpenAPIExternalDocumentation, OpenAPITag } from '../../types'; import { safeSlugify } from '../../utils'; -import { MarkdownHeading, MarkdownRenderer } from '../MarkdownRenderer'; -import { ContentItemModel } from '../MenuBuilder'; -import { IMenuItem, MenuItemGroupType } from '../MenuStore'; +import { MarkdownRenderer } from '../MarkdownRenderer'; +import type { ContentItemModel, IMenuItem, MarkdownHeading, MenuItemGroupType } from '../types'; /** * Operations Group model ready to be used by components diff --git a/src/services/models/MediaContent.ts b/src/services/models/MediaContent.ts index e993da91e5..dc30e71164 100644 --- a/src/services/models/MediaContent.ts +++ b/src/services/models/MediaContent.ts @@ -1,11 +1,11 @@ import { action, computed, observable, makeObservable } from 'mobx'; -import { OpenAPIMediaType } from '../../types'; +import type { OpenAPIMediaType } from '../../types'; import { MediaTypeModel } from './MediaType'; import { mergeSimilarMediaTypes } from '../../utils'; -import { OpenAPIParser } from '../OpenAPIParser'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { OpenAPIParser } from '../OpenAPIParser'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; /** * MediaContent model ready to be sued by React components @@ -34,7 +34,6 @@ export class MediaContentModel { this.mediaTypes = Object.keys(info).map(name => { const mime = info[name]; // reset deref cache just in case something is left there - parser.resetVisited(); return new MediaTypeModel(parser, name, isRequestType, mime, options); }); } diff --git a/src/services/models/MediaType.ts b/src/services/models/MediaType.ts index 25dae8073b..1b7263ae95 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -1,11 +1,11 @@ import * as Sampler from 'openapi-sampler'; -import { OpenAPIMediaType } from '../../types'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { OpenAPIMediaType } from '../../types'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { SchemaModel } from './Schema'; import { isJsonLike, mapValues } from '../../utils'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPIParser } from '../OpenAPIParser'; import { ExampleModel } from './Example'; export class MediaTypeModel { @@ -40,7 +40,7 @@ export class MediaTypeModel { this.examples = { default: new ExampleModel( parser, - { value: parser.shallowDeref(info.example) }, + { value: parser.deref(info.example).resolved }, name, info.encoding, ), diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts index 2b3f62134e..e26b4a4c19 100644 --- a/src/services/models/Operation.ts +++ b/src/services/models/Operation.ts @@ -1,11 +1,5 @@ import { action, observable, makeObservable } from 'mobx'; -import { IMenuItem } from '../MenuStore'; -import { GroupModel } from './Group.model'; -import { SecurityRequirementModel } from './SecurityRequirement'; - -import { OpenAPIExternalDocumentation, OpenAPIServer, OpenAPIXCodeSample } from '../../types'; - import { extractExtensions, getOperationSummary, @@ -17,15 +11,20 @@ import { sortByField, sortByRequired, } from '../../utils'; -import { ContentItemModel, ExtendedOpenAPIOperation } from '../MenuBuilder'; -import { OpenAPIParser } from '../OpenAPIParser'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; + +import { GroupModel } from './Group.model'; +import { SecurityRequirementModel } from './SecurityRequirement'; import { CallbackModel } from './Callback'; import { FieldModel } from './Field'; -import { MediaContentModel } from './MediaContent'; import { RequestBodyModel } from './RequestBody'; import { ResponseModel } from './Response'; -import { SideNavStyleEnum } from '../RedocNormalizedOptions'; +import { SideNavStyleEnum } from '../types'; + +import type { OpenAPIExternalDocumentation, OpenAPIServer, OpenAPIXCodeSample } from '../../types'; +import type { OpenAPIParser } from '../OpenAPIParser'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { MediaContentModel } from './MediaContent'; +import type { ContentItemModel, ExtendedOpenAPIOperation, IMenuItem } from '../types'; export interface XPayloadSample { lang: 'payload'; diff --git a/src/services/models/RequestBody.ts b/src/services/models/RequestBody.ts index 18780a5851..ddb68d4145 100644 --- a/src/services/models/RequestBody.ts +++ b/src/services/models/RequestBody.ts @@ -1,7 +1,7 @@ -import { OpenAPIRequestBody, Referenced } from '../../types'; +import type { OpenAPIRequestBody, Referenced } from '../../types'; -import { OpenAPIParser } from '../OpenAPIParser'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { OpenAPIParser } from '../OpenAPIParser'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { MediaContentModel } from './MediaContent'; import { getContentWithLegacyExamples } from '../../utils'; @@ -19,10 +19,9 @@ export class RequestBodyModel { constructor({ parser, infoOrRef, options, isEvent }: RequestBodyProps) { const isRequest = !isEvent; - const info = parser.deref(infoOrRef); + const { resolved: info } = parser.deref(infoOrRef); this.description = info.description || ''; this.required = !!info.required; - parser.exitRef(infoOrRef); const mediaContent = getContentWithLegacyExamples(info); if (mediaContent !== undefined) { diff --git a/src/services/models/Response.ts b/src/services/models/Response.ts index 77159a8a3b..b57eea24f1 100644 --- a/src/services/models/Response.ts +++ b/src/services/models/Response.ts @@ -1,10 +1,10 @@ import { action, observable, makeObservable } from 'mobx'; -import { OpenAPIResponse, Referenced } from '../../types'; +import type { OpenAPIResponse, Referenced } from '../../types'; import { getStatusCodeType, extractExtensions } from '../../utils'; -import { OpenAPIParser } from '../OpenAPIParser'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { OpenAPIParser } from '../OpenAPIParser'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { FieldModel } from './Field'; import { MediaContentModel } from './MediaContent'; @@ -41,8 +41,7 @@ export class ResponseModel { this.expanded = options.expandResponses === 'all' || options.expandResponses[code]; - const info = parser.deref(infoOrRef); - parser.exitRef(infoOrRef); + const { resolved: info } = parser.deref(infoOrRef); this.code = code; if (info.content !== undefined) { this.content = new MediaContentModel(parser, info.content, isRequest, options); diff --git a/src/services/models/Schema.ts b/src/services/models/Schema.ts index afc4576204..3b8dcf2d07 100644 --- a/src/services/models/Schema.ts +++ b/src/services/models/Schema.ts @@ -1,12 +1,13 @@ import { action, observable, makeObservable } from 'mobx'; -import { OpenAPIExternalDocumentation, OpenAPISchema, Referenced } from '../../types'; +import type { OpenAPIExternalDocumentation, OpenAPISchema, Referenced } from '../../types'; -import { OpenAPIParser } from '../OpenAPIParser'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { OpenAPIParser } from '../OpenAPIParser'; +import { pushRef } from '../OpenAPIParser'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { FieldModel } from './Field'; -import { MergedOpenAPISchema } from '../'; +import { MergedOpenAPISchema } from '../types'; import { detectType, extractExtensions, @@ -80,17 +81,18 @@ export class SchemaModel { pointer: string, private options: RedocNormalizedOptions, isChild: boolean = false, + private refsStack: string[] = [], ) { makeObservable(this); this.pointer = schemaOrRef.$ref || pointer || ''; - this.rawSchema = parser.deref(schemaOrRef, false, true); - this.schema = parser.mergeAllOf(this.rawSchema, this.pointer, isChild); - this.init(parser, isChild); + const { resolved, refsStack: newRefsStack } = parser.deref(schemaOrRef, refsStack, true); + this.refsStack = pushRef(newRefsStack, this.pointer); + this.rawSchema = resolved; - parser.exitRef(schemaOrRef); - parser.exitParents(this.schema); + this.schema = parser.mergeAllOf(this.rawSchema, this.pointer, this.refsStack); + this.init(parser, isChild); if (options.showExtensions) { this.extensions = extractExtensions(this.schema, options.showExtensions); @@ -112,7 +114,7 @@ export class SchemaModel { init(parser: OpenAPIParser, isChild: boolean) { const schema = this.schema; - this.isCircular = schema['x-circular-ref']; + this.isCircular = !!schema['x-circular-ref']; this.title = schema.title || (isNamedDefinition(this.pointer) && JsonPointer.baseName(this.pointer)) || ''; @@ -189,16 +191,18 @@ export class SchemaModel { } if (this.hasType('object')) { - this.fields = buildFields(parser, schema, this.pointer, this.options); + this.fields = buildFields(parser, schema, this.pointer, this.options, this.refsStack); } else if (this.hasType('array')) { if (isArray(schema.items) || isArray(schema.prefixItems)) { - this.fields = buildFields(parser, schema, this.pointer, this.options); + this.fields = buildFields(parser, schema, this.pointer, this.options, this.refsStack); } else if (isObject(schema.items)) { this.items = new SchemaModel( parser, schema.items as OpenAPISchema, this.pointer + '/items', this.options, + false, + this.refsStack, ); } @@ -231,9 +235,9 @@ export class SchemaModel { private initOneOf(oneOf: OpenAPISchema[], parser: OpenAPIParser) { this.oneOf = oneOf!.map((variant, idx) => { - const derefVariant = parser.deref(variant, false, true); + const { resolved: derefVariant, refsStack } = parser.deref(variant, this.refsStack, true); - const merged = parser.mergeAllOf(derefVariant, this.pointer + '/oneOf/' + idx); + const merged = parser.mergeAllOf(derefVariant, this.pointer + '/oneOf/' + idx, refsStack); // try to infer title const title = @@ -252,13 +256,10 @@ export class SchemaModel { } as OpenAPISchema, this.pointer + '/oneOf/' + idx, this.options, + false, + this.refsStack, ); - parser.exitRef(variant); - // each oneOf should be independent so exiting all the parent refs - // otherwise it will cause false-positive recursive detection - parser.exitParents(merged); - return schema; }); @@ -280,16 +281,11 @@ export class SchemaModel { } } - private initDiscriminator( - schema: OpenAPISchema & { - parentRefs?: string[]; - }, - parser: OpenAPIParser, - ) { + private initDiscriminator(schema: OpenAPISchema, parser: OpenAPIParser) { const discriminator = getDiscriminator(schema)!; this.discriminatorProp = discriminator.propertyName; const implicitInversedMapping = parser.findDerived([ - ...(schema.parentRefs || []), + ...(this.schema['x-parentRefs'] || []), this.pointer, ]); @@ -372,7 +368,14 @@ export class SchemaModel { } this.oneOf = refs.map(({ $ref, name }) => { - const innerSchema = new SchemaModel(parser, parser.byRef($ref)!, $ref, this.options, true); + const innerSchema = new SchemaModel( + parser, + { $ref }, + $ref, + this.options, + true, + this.refsStack.slice(0, -1), + ); innerSchema.title = name; return innerSchema; }); @@ -405,6 +408,8 @@ export class SchemaModel { } as OpenAPISchema, this.pointer + '/oneOf/' + idx, this.options, + false, + this.refsStack, ), ); this.oneOfType = 'One of'; @@ -416,6 +421,7 @@ function buildFields( schema: OpenAPISchema, $ref: string, options: RedocNormalizedOptions, + refsStack: string[], ): FieldModel[] { const props = schema.properties || schema.prefixItems || schema.items || {}; const patternProps = schema.patternProperties || {}; @@ -447,6 +453,7 @@ function buildFields( }, $ref + '/properties/' + fieldName, options, + refsStack, ); }); @@ -479,6 +486,7 @@ function buildFields( }, `${$ref}/patternProperties/${fieldName}`, options, + refsStack, ); }), ); @@ -498,6 +506,7 @@ function buildFields( }, $ref + '/additionalProperties', options, + refsStack, ), ); } @@ -509,6 +518,7 @@ function buildFields( fieldsCount: fields.length, $ref, options, + refsStack, }), ); @@ -521,12 +531,14 @@ function buildAdditionalItems({ fieldsCount, $ref, options, + refsStack, }: { parser: OpenAPIParser; schema?: OpenAPISchema | OpenAPISchema[] | boolean; fieldsCount: number; $ref: string; options: RedocNormalizedOptions; + refsStack: string[]; }) { if (isBoolean(schema)) { return schema @@ -539,6 +551,7 @@ function buildAdditionalItems({ }, `${$ref}/additionalItems`, options, + refsStack, ), ] : []; @@ -556,6 +569,7 @@ function buildAdditionalItems({ }, `${$ref}/additionalItems`, options, + refsStack, ), ), ]; @@ -571,6 +585,7 @@ function buildAdditionalItems({ }, `${$ref}/additionalItems`, options, + refsStack, ), ]; } diff --git a/src/services/models/SecurityRequirement.ts b/src/services/models/SecurityRequirement.ts index f991abf089..8f1fdff180 100644 --- a/src/services/models/SecurityRequirement.ts +++ b/src/services/models/SecurityRequirement.ts @@ -1,5 +1,5 @@ -import { OpenAPISecurityRequirement, OpenAPISecurityScheme } from '../../types'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPISecurityRequirement, OpenAPISecurityScheme } from '../../types'; +import type { OpenAPIParser } from '../OpenAPIParser'; export interface SecurityScheme extends OpenAPISecurityScheme { id: string; @@ -16,7 +16,7 @@ export class SecurityRequirementModel { this.schemes = Object.keys(requirement || {}) .map(id => { - const scheme = parser.deref(schemes[id]); + const { resolved: scheme } = parser.deref(schemes[id]); const scopes = requirement[id] || []; if (!scheme) { diff --git a/src/services/models/SecuritySchemes.ts b/src/services/models/SecuritySchemes.ts index 9b157d138d..c24a50ba38 100644 --- a/src/services/models/SecuritySchemes.ts +++ b/src/services/models/SecuritySchemes.ts @@ -1,6 +1,6 @@ -import { OpenAPISecurityScheme, Referenced } from '../../types'; +import type { OpenAPISecurityScheme, Referenced } from '../../types'; import { SECURITY_SCHEMES_SECTION_PREFIX } from '../../utils'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPIParser } from '../OpenAPIParser'; export class SecuritySchemeModel { id: string; @@ -24,7 +24,7 @@ export class SecuritySchemeModel { }; constructor(parser: OpenAPIParser, id: string, scheme: Referenced) { - const info = parser.deref(scheme); + const { resolved: info } = parser.deref(scheme); this.id = id; this.sectionId = SECURITY_SCHEMES_SECTION_PREFIX + id; this.type = info.type; diff --git a/src/services/models/Webhook.ts b/src/services/models/Webhook.ts index 5512293518..888c8a6657 100644 --- a/src/services/models/Webhook.ts +++ b/src/services/models/Webhook.ts @@ -1,7 +1,7 @@ -import { OpenAPIPath, Referenced } from '../../types'; -import { OpenAPIParser } from '../OpenAPIParser'; +import type { OpenAPIPath, Referenced } from '../../types'; +import type { OpenAPIParser } from '../OpenAPIParser'; import { OperationModel } from './Operation'; -import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; +import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { isOperationName } from '../..'; export class WebhookModel { @@ -12,8 +12,7 @@ export class WebhookModel { options: RedocNormalizedOptions, infoOrRef?: Referenced, ) { - const webhooks = parser.deref(infoOrRef || {}); - parser.exitRef(infoOrRef); + const { resolved: webhooks } = parser.deref(infoOrRef || {}); this.initWebhooks(parser, webhooks, options); } diff --git a/src/services/types.ts b/src/services/types.ts new file mode 100644 index 0000000000..5cba6cc907 --- /dev/null +++ b/src/services/types.ts @@ -0,0 +1,131 @@ +import { + OpenAPIOperation, + OpenAPIParameter, + OpenAPISchema, + OpenAPIServer, + OpenAPITag, + Referenced, +} from '../types'; +import { AppStore } from './AppStore'; +import { GroupModel } from './models'; +import { OperationModel } from './models/Operation'; +import { RedocRawOptions } from './RedocNormalizedOptions'; + +export interface StoreState { + menu: { + activeItemIdx: number; + }; + spec: { + url?: string; + data: any; + }; + searchIndex: any; + options: RedocRawOptions; +} + +export interface LabelsConfig { + enum: string; + enumSingleValue: string; + enumArray: string; + default: string; + deprecated: string; + example: string; + examples: string; + recursive: string; + arrayOf: string; + webhook: string; + const: string; + noResultsFound: string; + download: string; + downloadSpecification: string; + responses: string; + callbackResponses: string; + requestSamples: string; + responseSamples: string; +} + +export type LabelsConfigRaw = Partial; + +export interface MDXComponentMeta { + component: React.ComponentType; + propsSelector: (store?: AppStore) => any; + props?: object; +} + +export interface MarkdownHeading { + id: string; + name: string; + level: number; + items?: MarkdownHeading[]; + description?: string; +} + +export type ContentItemModel = GroupModel | OperationModel; + +export type TagInfo = OpenAPITag & { + operations: ExtendedOpenAPIOperation[]; + used?: boolean; +}; + +export type ExtendedOpenAPIOperation = { + pointer: string; + pathName: string; + httpVerb: string; + pathParameters: Array>; + pathServers: Array | undefined; + isWebhook: boolean; +} & OpenAPIOperation; + +export type TagsInfoMap = Record; + +export interface TagGroup { + name: string; + tags: string[]; +} + +export type MenuItemGroupType = 'group' | 'tag' | 'section'; +export type MenuItemType = MenuItemGroupType | 'operation'; + +export interface IMenuItem { + id: string; + absoluteIdx?: number; + name: string; + sidebarLabel: string; + description?: string; + depth: number; + active: boolean; + expanded: boolean; + items: IMenuItem[]; + parent?: IMenuItem; + deprecated?: boolean; + type: MenuItemType; + + deactivate(): void; + activate(): void; + + collapse(): void; + expand(): void; +} + +export interface SearchDocument { + title: string; + description: string; + id: string; +} + +export interface SearchResult { + meta: T; + score: number; +} + +export enum SideNavStyleEnum { + SummaryOnly = 'summary-only', + PathOnly = 'path-only', + IdOnly = 'id-only', +} + +export type MergedOpenAPISchema = OpenAPISchema & { + 'x-refsStack'?: string[]; + 'x-parentRefs'?: string[]; + 'x-circular-ref'?: boolean; +}; diff --git a/src/standalone.tsx b/src/standalone.tsx index 5a331d69e2..1cb32cbb58 100644 --- a/src/standalone.tsx +++ b/src/standalone.tsx @@ -3,9 +3,10 @@ import { hydrate as hydrateComponent, render } from 'react-dom'; import { configure } from 'mobx'; import { Redoc, RedocStandalone } from './components/'; -import { AppStore, StoreState } from './services/AppStore'; +import { AppStore } from './services/AppStore'; import { debugTime, debugTimeEnd } from './utils/debug'; import { querySelector } from './utils/dom'; +import type { StoreState } from './services'; configure({ useProxies: 'ifavailable', diff --git a/src/types/open-api.ts b/src/types/open-api.ts index afa9864271..75644062ec 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -40,7 +40,10 @@ export interface OpenAPIPaths { [path: string]: OpenAPIPath; } export interface OpenAPIRef { + 'x-refsStack'?: string[]; $ref: string; + summary?: string; + description?: string; } export type Referenced = OpenAPIRef | T; diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 93eeb3d914..e381cb7081 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -121,6 +121,10 @@ export function isPrimitiveType( schema: OpenAPISchema, type: string | string[] | undefined = schema.type, ) { + if (schema['x-circular-ref']) { + return true; + } + if (schema.oneOf !== undefined || schema.anyOf !== undefined) { return false; } @@ -541,13 +545,13 @@ export function mergeParams( ): Array> { const operationParamNames = {}; operationParams.forEach(param => { - param = parser.shallowDeref(param); + ({ resolved: param } = parser.deref(param)); operationParamNames[param.name + '_' + param.in] = true; }); // filter out path params overridden by operation ones with the same name pathParams = pathParams.filter(param => { - param = parser.shallowDeref(param); + ({ resolved: param } = parser.deref(param)); return !operationParamNames[param.name + '_' + param.in]; }); From 2a737ccc23be089aa45ba12e2be3b826dc8cb94c Mon Sep 17 00:00:00 2001 From: Alex Varchuk Date: Wed, 6 Jul 2022 15:43:38 +0300 Subject: [PATCH 2/3] chore: improve after review --- src/services/OpenAPIParser.ts | 1 - src/utils/openapi.ts | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index 5894b0f4c6..303ae17201 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -21,7 +21,6 @@ export class OpenAPIParser { specUrl?: string; spec: OpenAPISpec; - // private _refCounter: RefCounter = new RefCounter(); private readonly allowMergeRefs: boolean = false; constructor( diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index e381cb7081..7a48ad92ea 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -636,6 +636,8 @@ export const shortenHTTPVerb = verb => export function isRedocExtension(key: string): boolean { const redocExtensions = { 'x-circular-ref': true, + 'x-parentRefs': true, + 'x-refsStack': true, 'x-code-samples': true, // deprecated 'x-codeSamples': true, 'x-displayName': true, From f7be345c0696ed7b84f1c3ad4fed1ec62e8cd284 Mon Sep 17 00:00:00 2001 From: Alex Varchuk Date: Thu, 7 Jul 2022 16:30:53 +0300 Subject: [PATCH 3/3] chore: add more test --- .../__tests__/models/Schema.circular.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/services/__tests__/models/Schema.circular.test.ts b/src/services/__tests__/models/Schema.circular.test.ts index 11f4012733..043fb74b11 100644 --- a/src/services/__tests__/models/Schema.circular.test.ts +++ b/src/services/__tests__/models/Schema.circular.test.ts @@ -482,5 +482,78 @@ describe('Models', () => { ] `); }); + + test('should detect and recursion with discriminator and oneOf', () => { + const spec = parseYaml(outdent` + openapi: 3.0.0 + components: + schemas: + User: + type: object + properties: + pet: + oneOf: + - $ref: '#/components/schemas/Pet' + Pet: + type: object + required: [ petType ] + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' + properties: + category: { $ref: '#/components/schemas/Category' } + status: { type: string } + friend: + allOf: [{ $ref: '#/components/schemas/Pet' }] + petType: { type: string } + Cat: + description: A representation of a cat + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + huntingSkill: { type: string } + Dog: + description: A representation of a dog + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + packSize: { type: integer } + Category: + type: object + properties: + name: { type: string } + `) as any; + + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel( + parser, + spec.components.schemas.User, + '#/components/schemas/User', + opts, + ); + + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + pet: oneOf + Pet -> oneOf + cat -> + category: + name: + status: + friend: !circular + petType*: + huntingSkill: + dog -> + category: + name: + status: + friend: !circular + petType*: + packSize: + `); + }); }); });