feat: add webhooks support (#1304)

This commit is contained in:
Anna Stasiuk 2020-08-14 16:33:25 +03:00 committed by GitHub
parent 171711f79c
commit 41f81b4d96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 127 additions and 38 deletions

View File

@ -1187,3 +1187,19 @@ components:
shipDate: '2018-10-19T16:46:45Z' shipDate: '2018-10-19T16:46:45Z'
status: placed status: placed
complete: false 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

View File

@ -6,7 +6,7 @@ describe('Menu', () => {
it('should have valid items count', () => { it('should have valid items count', () => {
cy.get('.menu-content') cy.get('.menu-content')
.find('li') .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', () => { it('should sync active menu items while scroll', () => {

View File

@ -51,10 +51,17 @@ export const ShelfIcon = styled(IntShelfIcon)`
export const Badge = styled.span<{ type: string }>` export const Badge = styled.span<{ type: string }>`
display: inline-block; display: inline-block;
padding: 0 5px; padding: 2px 8px;
margin: 0; margin: 0;
background-color: ${props => props.theme.colors[props.type].main}; background-color: ${props => props.theme.colors[props.type].main};
color: ${props => props.theme.colors[props.type].contrastText}; color: ${props => props.theme.colors[props.type].contrastText};
font-size: ${props => props.theme.typography.code.fontSize}; 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;
}
`; `;

View File

@ -37,19 +37,22 @@ export class Operation extends React.Component<OperationProps> {
render() { render() {
const { operation } = this.props; const { operation } = this.props;
const { name: summary, description, deprecated, externalDocs } = operation; const { name: summary, description, deprecated, externalDocs, isWebhook } = operation;
const hasDescription = !!(description || externalDocs); const hasDescription = !!(description || externalDocs);
return ( return (
<OptionsContext.Consumer> <OptionsContext.Consumer>
{options => ( {(options) => (
<OperationRow> <OperationRow>
<MiddlePanel> <MiddlePanel>
<H2> <H2>
<ShareLink to={operation.id} /> <ShareLink to={operation.id} />
{summary} {deprecated && <Badge type="warning"> Deprecated </Badge>} {summary} {deprecated && <Badge type="warning"> Deprecated </Badge>}
{isWebhook && <Badge type="primary"> Webhook </Badge>}
</H2> </H2>
{options.pathInMiddlePanel && <Endpoint operation={operation} inverted={true} />} {options.pathInMiddlePanel && !isWebhook && (
<Endpoint operation={operation} inverted={true} />
)}
{hasDescription && ( {hasDescription && (
<Description> <Description>
{description !== undefined && <Markdown source={description} />} {description !== undefined && <Markdown source={description} />}
@ -63,7 +66,7 @@ export class Operation extends React.Component<OperationProps> {
<CallbacksList callbacks={operation.callbacks} /> <CallbacksList callbacks={operation.callbacks} />
</MiddlePanel> </MiddlePanel>
<DarkRightPanel> <DarkRightPanel>
{!options.pathInMiddlePanel && <Endpoint operation={operation} />} {!options.pathInMiddlePanel && !isWebhook && <Endpoint operation={operation} />}
<RequestSamples operation={operation} /> <RequestSamples operation={operation} />
<ResponseSamples operation={operation} /> <ResponseSamples operation={operation} />
<CallbackSamples callbacks={operation.callbacks} /> <CallbackSamples callbacks={operation.callbacks} />

View File

@ -7,6 +7,7 @@ import { IMenuItem, OperationModel } from '../../services';
import { shortenHTTPVerb } from '../../utils/openapi'; import { shortenHTTPVerb } from '../../utils/openapi';
import { MenuItems } from './MenuItems'; import { MenuItems } from './MenuItems';
import { MenuItemLabel, MenuItemLi, MenuItemTitle, OperationBadge } from './styled.elements'; import { MenuItemLabel, MenuItemLi, MenuItemTitle, OperationBadge } from './styled.elements';
import { l } from '../../services/Labels';
export interface MenuItemProps { export interface MenuItemProps {
item: IMenuItem; item: IMenuItem;
@ -90,7 +91,11 @@ export class OperationMenuItemContent extends React.Component<OperationMenuItemC
deprecated={item.deprecated} deprecated={item.deprecated}
ref={this.ref} ref={this.ref}
> >
{item.isWebhook ? (
<OperationBadge type="hook">{l('webhook')}</OperationBadge>
) : (
<OperationBadge type={item.httpVerb}>{shortenHTTPVerb(item.httpVerb)}</OperationBadge> <OperationBadge type={item.httpVerb}>{shortenHTTPVerb(item.httpVerb)}</OperationBadge>
)}
<MenuItemTitle width="calc(100% - 38px)"> <MenuItemTitle width="calc(100% - 38px)">
{item.name} {item.name}
{this.props.children} {this.props.children}

View File

@ -60,6 +60,10 @@ export const OperationBadge = styled.span.attrs((props: { type: string }) => ({
&.head { &.head {
background-color: ${props => props.theme.colors.http.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 { function menuItemActiveBg(depth, { theme }: { theme: ResolvedThemeInterface }): string {

View File

@ -8,6 +8,7 @@ export interface LabelsConfig {
nullable: string; nullable: string;
recursive: string; recursive: string;
arrayOf: string; arrayOf: string;
webhook: string;
} }
export type LabelsConfigRaw = Partial<LabelsConfig>; export type LabelsConfigRaw = Partial<LabelsConfig>;
@ -22,6 +23,7 @@ const labels: LabelsConfig = {
nullable: 'Nullable', nullable: 'Nullable',
recursive: 'Recursive', recursive: 'Recursive',
arrayOf: 'Array of ', arrayOf: 'Array of ',
webhook: 'Event',
}; };
export function setRedocLabels(_labels?: LabelsConfigRaw) { export function setRedocLabels(_labels?: LabelsConfigRaw) {

View File

@ -5,6 +5,7 @@ import {
OpenAPITag, OpenAPITag,
Referenced, Referenced,
OpenAPIServer, OpenAPIServer,
OpenAPIPaths,
} from '../types'; } from '../types';
import { import {
isOperationName, isOperationName,
@ -28,6 +29,7 @@ export type ExtendedOpenAPIOperation = {
httpVerb: string; httpVerb: string;
pathParameters: Array<Referenced<OpenAPIParameter>>; pathParameters: Array<Referenced<OpenAPIParameter>>;
pathServers: Array<OpenAPIServer> | undefined; pathServers: Array<OpenAPIServer> | undefined;
isWebhook: boolean;
} & OpenAPIOperation; } & OpenAPIOperation;
export type TagsInfoMap = Record<string, TagInfo>; export type TagsInfoMap = Record<string, TagInfo>;
@ -219,7 +221,12 @@ export class MenuBuilder {
tags[tag.name] = { ...tag, operations: [] }; tags[tag.name] = { ...tag, operations: [] };
} }
const paths = spec.paths; getTags(spec.paths);
if (spec['x-webhooks']) {
getTags(spec['x-webhooks'], true);
}
function getTags(paths: OpenAPIPaths, isWebhook?: boolean) {
for (const pathName of Object.keys(paths)) { for (const pathName of Object.keys(paths)) {
const path = paths[pathName]; const path = paths[pathName];
const operations = Object.keys(path).filter(isOperationName); const operations = Object.keys(path).filter(isOperationName);
@ -251,11 +258,12 @@ export class MenuBuilder {
httpVerb: operationName, httpVerb: operationName,
pathParameters: path.parameters || [], pathParameters: path.parameters || [],
pathServers: path.servers, pathServers: path.servers,
isWebhook: !!isWebhook,
}); });
} }
} }
} }
}
return tags; return tags;
} }
} }

View File

@ -2,6 +2,7 @@ import { OpenAPIExternalDocumentation, OpenAPISpec } from '../types';
import { ContentItemModel, MenuBuilder } from './MenuBuilder'; import { ContentItemModel, MenuBuilder } from './MenuBuilder';
import { ApiInfoModel } from './models/ApiInfo'; import { ApiInfoModel } from './models/ApiInfo';
import { WebhookModel } from './models/Webhook';
import { SecuritySchemesModel } from './models/SecuritySchemes'; import { SecuritySchemesModel } from './models/SecuritySchemes';
import { OpenAPIParser } from './OpenAPIParser'; import { OpenAPIParser } from './OpenAPIParser';
import { RedocNormalizedOptions } from './RedocNormalizedOptions'; import { RedocNormalizedOptions } from './RedocNormalizedOptions';
@ -15,6 +16,7 @@ export class SpecStore {
externalDocs?: OpenAPIExternalDocumentation; externalDocs?: OpenAPIExternalDocumentation;
contentItems: ContentItemModel[]; contentItems: ContentItemModel[];
securitySchemes: SecuritySchemesModel; securitySchemes: SecuritySchemesModel;
webhooks?: WebhookModel;
constructor( constructor(
spec: OpenAPISpec, spec: OpenAPISpec,
@ -26,5 +28,6 @@ export class SpecStore {
this.externalDocs = this.parser.spec.externalDocs; this.externalDocs = this.parser.spec.externalDocs;
this.contentItems = MenuBuilder.buildStructure(this.parser, this.options); this.contentItems = MenuBuilder.buildStructure(this.parser, this.options);
this.securitySchemes = new SecuritySchemesModel(this.parser); this.securitySchemes = new SecuritySchemesModel(this.parser);
this.webhooks = new WebhookModel(this.parser, options, this.parser.spec['x-webhooks']);
} }
} }

View File

@ -75,6 +75,7 @@ export class OperationModel implements IMenuItem {
security: SecurityRequirementModel[]; security: SecurityRequirementModel[];
extensions: Record<string, any>; extensions: Record<string, any>;
isCallback: boolean; isCallback: boolean;
isWebhook: boolean;
constructor( constructor(
private parser: OpenAPIParser, private parser: OpenAPIParser,
@ -95,6 +96,7 @@ export class OperationModel implements IMenuItem {
this.operationId = operationSpec.operationId; this.operationId = operationSpec.operationId;
this.path = operationSpec.pathName; this.path = operationSpec.pathName;
this.isCallback = isCallback; this.isCallback = isCallback;
this.isWebhook = !!operationSpec.isWebhook;
this.name = getOperationSummary(operationSpec); this.name = getOperationSummary(operationSpec);

View File

@ -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<OpenAPIPath>,
) {
const webhooks = parser.deref<OpenAPIPath>(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);
}
}
}
}

View File

@ -9,6 +9,7 @@ export interface OpenAPISpec {
security?: OpenAPISecurityRequirement[]; security?: OpenAPISecurityRequirement[];
tags?: OpenAPITag[]; tags?: OpenAPITag[];
externalDocs?: OpenAPIExternalDocumentation; externalDocs?: OpenAPIExternalDocumentation;
'x-webhooks'?: OpenAPIPaths;
} }
export interface OpenAPIInfo { export interface OpenAPIInfo {