import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types'; import { isOperationName, JsonPointer } 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 = { _$ref: string; httpVerb: string; pathParameters: Array>; } & OpenAPIOperation; export type TagsInfoMap = Dict; export interface TagGroup { name: string; tags: string[]; } export const GROUP_DEPTH = 0; export type ContentItemModel = GroupModel | OperationModel; export class MenuBuilder { /** * Builds page content structure based on tags */ static buildStructure( parser: OpenAPIParser, options: RedocNormalizedOptions, ): ContentItemModel[] { const spec = parser.spec; const items: ContentItemModel[] = []; const tagsMap = MenuBuilder.getTagsWithOperations(spec); items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '')); if (spec['x-tagGroups']) { items.push( ...MenuBuilder.getTagGroupsItems(parser, undefined, spec['x-tagGroups'], tagsMap, options), ); } else { items.push(...MenuBuilder.getTagsItems(parser, tagsMap, undefined, undefined, options)); } return items; } /** * extracts items from markdown description * @param description - markdown source */ static addMarkdownItems(description: string): ContentItemModel[] { const renderer = new MarkdownRenderer(); const headings = renderer.extractHeadings(description || ''); const mapHeadingsDeep = (parent, items, depth = 1) => items.map(heading => { const group = new GroupModel('section', heading, parent); group.depth = depth; if (heading.items) { group.items = mapHeadingsDeep(group, group.items, depth + 1); } return group; }); return mapHeadingsDeep(undefined, headings); } /** * Returns array of OperationsGroup items for the tag groups (x-tagGroups vendor extenstion) * @param tags value of `x-tagGroups` vendor extension */ static getTagGroupsItems( parser: OpenAPIParser, parent: GroupModel | undefined, groups: TagGroup[], tags: TagsInfoMap, options: RedocNormalizedOptions, ): GroupModel[] { const res: GroupModel[] = []; for (const group of groups) { const item = new GroupModel('group', group, parent); item.depth = GROUP_DEPTH; item.items = MenuBuilder.getTagsItems(parser, tags, item, group, options); res.push(item); } // TODO checkAllTagsUsedInGroups return res; } /** * Returns array of OperationsGroup items for the tags of the group or for all tags * @param tagsMap tags info returned from `getTagsWithOperations` * @param parent parent item * @param group group which this tag belongs to. if not provided gets all tags */ static getTagsItems( parser: OpenAPIParser, tagsMap: TagsInfoMap, parent: GroupModel | undefined, group: TagGroup | undefined, options: RedocNormalizedOptions, ): ContentItemModel[] { let tagNames; if (group === undefined) { tagNames = Object.keys(tagsMap); // all tags } else { tagNames = group.tags; } const tags = tagNames.map(tagName => { if (!tagsMap[tagName]) { console.warn(`Non-existing tag "${tagName}" is added to the group "${group!.name}"`); return null; } tagsMap[tagName].used = true; return tagsMap[tagName]; }); const res: Array = []; for (const tag of tags) { if (!tag) { continue; } const item = new GroupModel('tag', tag, parent); item.depth = GROUP_DEPTH + 1; item.items = this.getOperationsItems(parser, item, tag, item.depth + 1, options); // don't put empty tag into content, instead put its operations if (tag.name === '') { const items = this.getOperationsItems(parser, undefined, tag, item.depth + 1, options); res.push(...items); continue; } res.push(item); } return res; } /** * Returns array of Operation items for the tag * @param parent parent OperationsGroup * @param tag tag info returned from `getTagsWithOperations` * @param depth items depth */ static getOperationsItems( parser: OpenAPIParser, parent: GroupModel | undefined, tag: TagInfo, depth: number, options: RedocNormalizedOptions, ): OperationModel[] { if (tag.operations.length === 0) { return []; } const res: OperationModel[] = []; for (const operationInfo of tag.operations) { const operation = new OperationModel(parser, operationInfo, parent, options); operation.depth = depth; res.push(operation); } return res; } /** * collects tags and maps each tag to list of operations belonging to this tag */ static getTagsWithOperations(spec: OpenAPISpec): TagsInfoMap { const tags: TagsInfoMap = {}; for (const tag of spec.tags || []) { tags[tag.name] = { ...tag, operations: [] }; } const paths = spec.paths; for (const pathName of Object.keys(paths)) { const path = paths[pathName]; const operations = Object.keys(path).filter(isOperationName); for (const operationName of operations) { const operationInfo = path[operationName]; let operationTags = operationInfo.tags; if (!operationTags || !operationTags.length) { // empty tag operationTags = ['']; } const operationPointer = JsonPointer.compile(['paths', pathName, operationName]); for (const tagName of operationTags) { let tag = tags[tagName]; if (tag === undefined) { tag = { name: tagName, operations: [], }; tags[tagName] = tag; } if (tag['x-traitTag']) { continue; } tag.operations.push({ ...operationInfo, _$ref: operationPointer, httpVerb: operationName, pathParameters: path.parameters || [], }); } } } return tags; } }