redoc/src/services/MenuBuilder.ts

279 lines
8.0 KiB
TypeScript
Raw Normal View History

import {
OpenAPIOperation,
OpenAPIParameter,
OpenAPISpec,
OpenAPITag,
Referenced,
OpenAPIServer,
2020-08-14 16:33:25 +03:00
OpenAPIPaths,
} from '../types';
import {
isOperationName,
SECURITY_DEFINITIONS_COMPONENT_NAME,
setSecuritySchemePrefix,
JsonPointer,
} from '../utils';
2017-10-12 00:01:37 +03:00
import { MarkdownRenderer } from './MarkdownRenderer';
2018-01-22 21:30:53 +03:00
import { GroupModel, OperationModel } from './models';
import { OpenAPIParser } from './OpenAPIParser';
2017-11-21 14:00:33 +03:00
import { RedocNormalizedOptions } from './RedocNormalizedOptions';
2017-10-12 00:01:37 +03:00
export type TagInfo = OpenAPITag & {
operations: ExtendedOpenAPIOperation[];
used?: boolean;
};
export type ExtendedOpenAPIOperation = {
pointer: string;
2018-07-26 17:34:44 +03:00
pathName: string;
2017-10-12 00:01:37 +03:00
httpVerb: string;
2018-01-22 21:30:53 +03:00
pathParameters: Array<Referenced<OpenAPIParameter>>;
pathServers: Array<OpenAPIServer> | undefined;
2020-08-14 16:33:25 +03:00
isWebhook: boolean;
2017-10-12 00:01:37 +03:00
} & OpenAPIOperation;
export type TagsInfoMap = Record<string, TagInfo>;
2017-10-12 00:01:37 +03:00
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
*/
2017-11-21 14:00:33 +03:00
static buildStructure(
parser: OpenAPIParser,
options: RedocNormalizedOptions,
): ContentItemModel[] {
const spec = parser.spec;
2017-10-12 00:01:37 +03:00
const items: ContentItemModel[] = [];
const tagsMap = MenuBuilder.getTagsWithOperations(parser, spec);
2018-10-19 20:21:27 +03:00
items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '', undefined, 1, options));
if (spec['x-tagGroups'] && spec['x-tagGroups'].length > 0) {
2017-11-21 14:00:33 +03:00
items.push(
...MenuBuilder.getTagGroupsItems(parser, undefined, spec['x-tagGroups'], tagsMap, options),
);
2017-10-12 00:01:37 +03:00
} else {
2017-11-21 14:00:33 +03:00
items.push(...MenuBuilder.getTagsItems(parser, tagsMap, undefined, undefined, options));
2017-10-12 00:01:37 +03:00
}
return items;
}
/**
* extracts items from markdown description
* @param description - markdown source
*/
static addMarkdownItems(
description: string,
2019-07-29 13:12:12 +03:00
parent: GroupModel | undefined,
2018-10-19 20:21:27 +03:00
initialDepth: number,
options: RedocNormalizedOptions,
): ContentItemModel[] {
const renderer = new MarkdownRenderer(options);
2017-10-12 00:01:37 +03:00
const headings = renderer.extractHeadings(description || '');
if (headings.length && parent && parent.description) {
parent.description = MarkdownRenderer.getTextBeforeHading(
parent.description,
headings[0].name,
);
}
2019-07-29 13:12:12 +03:00
const mapHeadingsDeep = (_parent, items, depth = 1) =>
items.map(heading => {
2019-07-29 13:12:12 +03:00
const group = new GroupModel('section', heading, _parent);
group.depth = depth;
if (heading.items) {
group.items = mapHeadingsDeep(group, heading.items, depth + 1);
}
if (
MarkdownRenderer.containsComponent(
group.description || '',
SECURITY_DEFINITIONS_COMPONENT_NAME,
)
) {
setSecuritySchemePrefix(group.id + '/');
}
return group;
});
2019-07-29 13:12:12 +03:00
return mapHeadingsDeep(parent, headings, initialDepth);
2017-10-12 00:01:37 +03:00
}
/**
2019-12-10 09:13:37 +03:00
* Returns array of OperationsGroup items for the tag groups (x-tagGroups vendor extension)
2017-10-12 00:01:37 +03:00
* @param tags value of `x-tagGroups` vendor extension
*/
static getTagGroupsItems(
parser: OpenAPIParser,
parent: GroupModel | undefined,
groups: TagGroup[],
tags: TagsInfoMap,
2017-11-21 14:00:33 +03:00
options: RedocNormalizedOptions,
2017-10-12 00:01:37 +03:00
): GroupModel[] {
2018-01-22 21:30:53 +03:00
const res: GroupModel[] = [];
for (const group of groups) {
const item = new GroupModel('group', group, parent);
2017-10-12 00:01:37 +03:00
item.depth = GROUP_DEPTH;
2017-11-21 14:00:33 +03:00
item.items = MenuBuilder.getTagsItems(parser, tags, item, group, options);
2017-10-12 00:01:37 +03:00
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,
2017-11-21 14:00:33 +03:00
parent: GroupModel | undefined,
group: TagGroup | undefined,
options: RedocNormalizedOptions,
2017-10-12 00:01:37 +03:00
): 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];
});
2018-01-22 21:30:53 +03:00
const res: Array<GroupModel | OperationModel> = [];
for (const tag of tags) {
if (!tag) {
continue;
}
const item = new GroupModel('tag', tag, parent);
2017-10-12 00:01:37 +03:00
item.depth = GROUP_DEPTH + 1;
// don't put empty tag into content, instead put its operations
if (tag.name === '') {
const items = [
2018-10-19 20:21:27 +03:00
...MenuBuilder.addMarkdownItems(tag.description || '', item, item.depth + 1, options),
...this.getOperationsItems(parser, undefined, tag, item.depth + 1, options),
];
2017-10-12 00:01:37 +03:00
res.push(...items);
continue;
}
item.items = [
2018-10-19 20:21:27 +03:00
...MenuBuilder.addMarkdownItems(tag.description || '', item, item.depth + 1, options),
...this.getOperationsItems(parser, item, tag, item.depth + 1, options),
];
2017-10-12 00:01:37 +03:00
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,
2017-11-21 14:00:33 +03:00
options: RedocNormalizedOptions,
2017-10-12 00:01:37 +03:00
): OperationModel[] {
if (tag.operations.length === 0) {
return [];
}
2018-01-22 21:30:53 +03:00
const res: OperationModel[] = [];
for (const operationInfo of tag.operations) {
const operation = new OperationModel(parser, operationInfo, parent, options);
2017-10-12 00:01:37 +03:00
operation.depth = depth;
res.push(operation);
}
return res;
}
/**
* collects tags and maps each tag to list of operations belonging to this tag
*/
static getTagsWithOperations(parser: OpenAPIParser, spec: OpenAPISpec): TagsInfoMap {
2017-10-12 00:01:37 +03:00
const tags: TagsInfoMap = {};
const webhooks = spec['x-webhooks'] || spec.webhooks;
2018-01-22 21:30:53 +03:00
for (const tag of spec.tags || []) {
tags[tag.name] = { ...tag, operations: [] };
2017-10-12 00:01:37 +03:00
}
if (webhooks) {
getTags(parser, webhooks, true);
2020-08-14 16:33:25 +03:00
}
2017-10-12 00:01:37 +03:00
if (spec.paths){
getTags(parser, spec.paths);
}
function getTags(parser: OpenAPIParser, paths: OpenAPIPaths, isWebhook?: boolean) {
2020-08-14 16:33:25 +03:00
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];
if (path.$ref) {
const resolvedPaths = parser.deref<OpenAPIPaths>(path as OpenAPIPaths);
getTags(parser, { [pathName]: resolvedPaths }, isWebhook);
continue;
}
let operationTags = operationInfo?.tags;
2018-07-26 17:34:44 +03:00
2020-08-14 16:33:25 +03:00
if (!operationTags || !operationTags.length) {
// empty tag
operationTags = [''];
2017-10-12 00:01:37 +03:00
}
2020-08-14 16:33:25 +03:00
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,
pathName,
pointer: JsonPointer.compile(['paths', pathName, operationName]),
httpVerb: operationName,
pathParameters: path.parameters || [],
pathServers: path.servers,
isWebhook: !!isWebhook,
});
2018-01-22 21:30:53 +03:00
}
2017-10-12 00:01:37 +03:00
}
}
}
return tags;
}
}