From 41f81b4d96648fec6bf0c39799c0aa2dded48749 Mon Sep 17 00:00:00 2001 From: Anna Stasiuk Date: Fri, 14 Aug 2020 16:33:25 +0300 Subject: [PATCH] feat: add webhooks support (#1304) --- demo/openapi.yaml | 16 +++++ e2e/integration/menu.e2e.ts | 2 +- src/common-elements/shelfs.tsx | 11 +++- src/components/Operation/Operation.tsx | 11 ++-- src/components/SideMenu/MenuItem.tsx | 7 ++- src/components/SideMenu/styled.elements.ts | 4 ++ src/services/Labels.ts | 2 + src/services/MenuBuilder.ts | 68 ++++++++++++---------- src/services/SpecStore.ts | 3 + src/services/models/Operation.ts | 2 + src/services/models/Webhook.ts | 38 ++++++++++++ src/types/open-api.d.ts | 1 + 12 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 src/services/models/Webhook.ts diff --git a/demo/openapi.yaml b/demo/openapi.yaml index 46b62e6257..74c16b70f2 100644 --- a/demo/openapi.yaml +++ b/demo/openapi.yaml @@ -1187,3 +1187,19 @@ components: shipDate: '2018-10-19T16:46:45Z' status: placed complete: false +x-webhooks: + newPet: + post: + summary: New pet + description: Information about a new pet in the systems + operationId: newPet + tags: + - pet + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Return a 200 status to indicate that the data was received successfully \ No newline at end of file diff --git a/e2e/integration/menu.e2e.ts b/e2e/integration/menu.e2e.ts index f21aebc6f3..e1b053d125 100644 --- a/e2e/integration/menu.e2e.ts +++ b/e2e/integration/menu.e2e.ts @@ -6,7 +6,7 @@ describe('Menu', () => { it('should have valid items count', () => { cy.get('.menu-content') .find('li') - .should('have.length', 6 + (2 + 8 + 1 + 4 + 2) + (1 + 8) + 1); + .should('have.length', 34); }); it('should sync active menu items while scroll', () => { diff --git a/src/common-elements/shelfs.tsx b/src/common-elements/shelfs.tsx index 278a1c40c9..0cc7e76702 100644 --- a/src/common-elements/shelfs.tsx +++ b/src/common-elements/shelfs.tsx @@ -51,10 +51,17 @@ export const ShelfIcon = styled(IntShelfIcon)` export const Badge = styled.span<{ type: string }>` display: inline-block; - padding: 0 5px; + padding: 2px 8px; margin: 0; background-color: ${props => props.theme.colors[props.type].main}; color: ${props => props.theme.colors[props.type].contrastText}; font-size: ${props => props.theme.typography.code.fontSize}; - vertical-align: text-top; + vertical-align: middle; + line-height: 1.6; + border-radius: 4px; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + font-size: 12px; + + span[type] { + margin-left: 4px; + } `; diff --git a/src/components/Operation/Operation.tsx b/src/components/Operation/Operation.tsx index 6da94e1ca9..57776cf0d7 100644 --- a/src/components/Operation/Operation.tsx +++ b/src/components/Operation/Operation.tsx @@ -37,19 +37,22 @@ export class Operation extends React.Component { render() { const { operation } = this.props; - const { name: summary, description, deprecated, externalDocs } = operation; + const { name: summary, description, deprecated, externalDocs, isWebhook } = operation; const hasDescription = !!(description || externalDocs); return ( - {options => ( + {(options) => (

{summary} {deprecated && Deprecated } + {isWebhook && Webhook }

- {options.pathInMiddlePanel && } + {options.pathInMiddlePanel && !isWebhook && ( + + )} {hasDescription && ( {description !== undefined && } @@ -63,7 +66,7 @@ export class Operation extends React.Component {
- {!options.pathInMiddlePanel && } + {!options.pathInMiddlePanel && !isWebhook && } diff --git a/src/components/SideMenu/MenuItem.tsx b/src/components/SideMenu/MenuItem.tsx index 8b418aad90..9a19fa0780 100644 --- a/src/components/SideMenu/MenuItem.tsx +++ b/src/components/SideMenu/MenuItem.tsx @@ -7,6 +7,7 @@ import { IMenuItem, OperationModel } from '../../services'; import { shortenHTTPVerb } from '../../utils/openapi'; import { MenuItems } from './MenuItems'; import { MenuItemLabel, MenuItemLi, MenuItemTitle, OperationBadge } from './styled.elements'; +import { l } from '../../services/Labels'; export interface MenuItemProps { item: IMenuItem; @@ -90,7 +91,11 @@ export class OperationMenuItemContent extends React.Component - {shortenHTTPVerb(item.httpVerb)} + {item.isWebhook ? ( + {l('webhook')} + ) : ( + {shortenHTTPVerb(item.httpVerb)} + )} {item.name} {this.props.children} diff --git a/src/components/SideMenu/styled.elements.ts b/src/components/SideMenu/styled.elements.ts index f400b170dc..1b14fcb882 100644 --- a/src/components/SideMenu/styled.elements.ts +++ b/src/components/SideMenu/styled.elements.ts @@ -60,6 +60,10 @@ export const OperationBadge = styled.span.attrs((props: { type: string }) => ({ &.head { background-color: ${props => props.theme.colors.http.head}; } + + &.hook { + background-color: ${props => props.theme.colors.primary.main}; + } `; function menuItemActiveBg(depth, { theme }: { theme: ResolvedThemeInterface }): string { diff --git a/src/services/Labels.ts b/src/services/Labels.ts index 4536503622..c0bddfedfc 100644 --- a/src/services/Labels.ts +++ b/src/services/Labels.ts @@ -8,6 +8,7 @@ export interface LabelsConfig { nullable: string; recursive: string; arrayOf: string; + webhook: string; } export type LabelsConfigRaw = Partial; @@ -22,6 +23,7 @@ const labels: LabelsConfig = { nullable: 'Nullable', recursive: 'Recursive', arrayOf: 'Array of ', + webhook: 'Event', }; export function setRedocLabels(_labels?: LabelsConfigRaw) { diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 38f104e5f1..339ba6cf18 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -5,6 +5,7 @@ import { OpenAPITag, Referenced, OpenAPIServer, + OpenAPIPaths, } from '../types'; import { isOperationName, @@ -28,6 +29,7 @@ export type ExtendedOpenAPIOperation = { httpVerb: string; pathParameters: Array>; pathServers: Array | undefined; + isWebhook: boolean; } & OpenAPIOperation; export type TagsInfoMap = Record; @@ -219,43 +221,49 @@ export class MenuBuilder { 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; + getTags(spec.paths); + if (spec['x-webhooks']) { + getTags(spec['x-webhooks'], true); + } - if (!operationTags || !operationTags.length) { - // empty tag - operationTags = ['']; - } + function getTags(paths: OpenAPIPaths, isWebhook?: boolean) { + 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; - for (const tagName of operationTags) { - let tag = tags[tagName]; - if (tag === undefined) { - tag = { - name: tagName, - operations: [], - }; - tags[tagName] = tag; + if (!operationTags || !operationTags.length) { + // empty tag + operationTags = ['']; } - if (tag['x-traitTag']) { - continue; + + 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, + }); } - tag.operations.push({ - ...operationInfo, - pathName, - pointer: JsonPointer.compile(['paths', pathName, operationName]), - httpVerb: operationName, - pathParameters: path.parameters || [], - pathServers: path.servers, - }); } } } - return tags; } } diff --git a/src/services/SpecStore.ts b/src/services/SpecStore.ts index 5a6c835d5d..1f39f9033e 100644 --- a/src/services/SpecStore.ts +++ b/src/services/SpecStore.ts @@ -2,6 +2,7 @@ import { OpenAPIExternalDocumentation, OpenAPISpec } from '../types'; import { ContentItemModel, 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'; @@ -15,6 +16,7 @@ export class SpecStore { externalDocs?: OpenAPIExternalDocumentation; contentItems: ContentItemModel[]; securitySchemes: SecuritySchemesModel; + webhooks?: WebhookModel; constructor( spec: OpenAPISpec, @@ -26,5 +28,6 @@ export class SpecStore { this.externalDocs = this.parser.spec.externalDocs; this.contentItems = MenuBuilder.buildStructure(this.parser, this.options); this.securitySchemes = new SecuritySchemesModel(this.parser); + this.webhooks = new WebhookModel(this.parser, options, this.parser.spec['x-webhooks']); } } diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts index 12ba2f6d65..107865a0d9 100644 --- a/src/services/models/Operation.ts +++ b/src/services/models/Operation.ts @@ -75,6 +75,7 @@ export class OperationModel implements IMenuItem { security: SecurityRequirementModel[]; extensions: Record; isCallback: boolean; + isWebhook: boolean; constructor( private parser: OpenAPIParser, @@ -95,6 +96,7 @@ export class OperationModel implements IMenuItem { this.operationId = operationSpec.operationId; this.path = operationSpec.pathName; this.isCallback = isCallback; + this.isWebhook = !!operationSpec.isWebhook; this.name = getOperationSummary(operationSpec); diff --git a/src/services/models/Webhook.ts b/src/services/models/Webhook.ts new file mode 100644 index 0000000000..7349b8bd17 --- /dev/null +++ b/src/services/models/Webhook.ts @@ -0,0 +1,38 @@ +import { OpenAPIPath, Referenced } from '../../types'; +import { OpenAPIParser } from '../OpenAPIParser'; +import { OperationModel } from './Operation'; +import { isOperationName } from '../..'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; + +export class WebhookModel { + operations: OperationModel[] = []; + + constructor( + parser: OpenAPIParser, + options: RedocNormalizedOptions, + infoOrRef?: Referenced, + ) { + const webhooks = parser.deref(infoOrRef || {}); + parser.exitRef(infoOrRef); + + for (const webhookName of Object.keys(webhooks)) { + const webhook = webhooks[webhookName]; + const operations = Object.keys(webhook).filter(isOperationName); + for (const operationName of operations) { + const operationInfo = webhook[operationName]; + const operation = new OperationModel( + parser, + { + ...operationInfo, + httpVerb: operationName, + }, + undefined, + options, + false, + ); + + this.operations.push(operation); + } + } + } +} diff --git a/src/types/open-api.d.ts b/src/types/open-api.d.ts index bb050b6e53..aefc0b7322 100644 --- a/src/types/open-api.d.ts +++ b/src/types/open-api.d.ts @@ -9,6 +9,7 @@ export interface OpenAPISpec { security?: OpenAPISecurityRequirement[]; tags?: OpenAPITag[]; externalDocs?: OpenAPIExternalDocumentation; + 'x-webhooks'?: OpenAPIPaths; } export interface OpenAPIInfo {