Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add webhooks support #1304

Merged
merged 5 commits into from
Aug 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions demo/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion e2e/integration/menu.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 9 additions & 2 deletions src/common-elements/shelfs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,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;
}
`;
11 changes: 7 additions & 4 deletions src/components/Operation/Operation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,22 @@ export class Operation extends React.Component<OperationProps> {
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 (
<OptionsContext.Consumer>
{options => (
{(options) => (
<OperationRow>
<MiddlePanel>
<H2>
<ShareLink to={operation.id} />
{summary} {deprecated && <Badge type="warning"> Deprecated </Badge>}
{isWebhook && <Badge type="primary"> Webhook </Badge>}
</H2>
{options.pathInMiddlePanel && <Endpoint operation={operation} inverted={true} />}
{options.pathInMiddlePanel && !isWebhook && (
<Endpoint operation={operation} inverted={true} />
)}
{hasDescription && (
<Description>
{description !== undefined && <Markdown source={description} />}
Expand All @@ -63,7 +66,7 @@ export class Operation extends React.Component<OperationProps> {
<CallbacksList callbacks={operation.callbacks} />
</MiddlePanel>
<DarkRightPanel>
{!options.pathInMiddlePanel && <Endpoint operation={operation} />}
{!options.pathInMiddlePanel && !isWebhook && <Endpoint operation={operation} />}
<RequestSamples operation={operation} />
<ResponseSamples operation={operation} />
<CallbackSamples callbacks={operation.callbacks} />
Expand Down
7 changes: 6 additions & 1 deletion src/components/SideMenu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,7 +91,11 @@ export class OperationMenuItemContent extends React.Component<OperationMenuItemC
deprecated={item.deprecated}
ref={this.ref}
>
<OperationBadge type={item.httpVerb}>{shortenHTTPVerb(item.httpVerb)}</OperationBadge>
{item.isWebhook ? (
<OperationBadge type="hook">{l('webhook')}</OperationBadge>
) : (
<OperationBadge type={item.httpVerb}>{shortenHTTPVerb(item.httpVerb)}</OperationBadge>
)}
<MenuItemTitle width="calc(100% - 38px)">
{item.name}
{this.props.children}
Expand Down
4 changes: 4 additions & 0 deletions src/components/SideMenu/styled.elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/services/Labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface LabelsConfig {
nullable: string;
recursive: string;
arrayOf: string;
webhook: string;
}

export type LabelsConfigRaw = Partial<LabelsConfig>;
Expand All @@ -22,6 +23,7 @@ const labels: LabelsConfig = {
nullable: 'Nullable',
recursive: 'Recursive',
arrayOf: 'Array of ',
webhook: 'Event',
};

export function setRedocLabels(_labels?: LabelsConfigRaw) {
Expand Down
68 changes: 38 additions & 30 deletions src/services/MenuBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
OpenAPITag,
Referenced,
OpenAPIServer,
OpenAPIPaths,
} from '../types';
import {
isOperationName,
Expand All @@ -28,6 +29,7 @@ export type ExtendedOpenAPIOperation = {
httpVerb: string;
pathParameters: Array<Referenced<OpenAPIParameter>>;
pathServers: Array<OpenAPIServer> | undefined;
isWebhook: boolean;
} & OpenAPIOperation;

export type TagsInfoMap = Record<string, TagInfo>;
Expand Down Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions src/services/SpecStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +16,7 @@ export class SpecStore {
externalDocs?: OpenAPIExternalDocumentation;
contentItems: ContentItemModel[];
securitySchemes: SecuritySchemesModel;
webhooks?: WebhookModel;

constructor(
spec: OpenAPISpec,
Expand All @@ -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']);
}
}
2 changes: 2 additions & 0 deletions src/services/models/Operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export class OperationModel implements IMenuItem {
security: SecurityRequirementModel[];
extensions: Record<string, any>;
isCallback: boolean;
isWebhook: boolean;

constructor(
private parser: OpenAPIParser,
Expand All @@ -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);

Expand Down
38 changes: 38 additions & 0 deletions src/services/models/Webhook.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
1 change: 1 addition & 0 deletions src/types/open-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface OpenAPISpec {
security?: OpenAPISecurityRequirement[];
tags?: OpenAPITag[];
externalDocs?: OpenAPIExternalDocumentation;
'x-webhooks'?: OpenAPIPaths;
}

export interface OpenAPIInfo {
Expand Down