Skip to content

Commit

Permalink
feat(oas3): adds webhook support for oas 3.1 (#258)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: http operation parsing changed to introduce webhooks
  • Loading branch information
Daniel A. White authored Dec 12, 2023
1 parent 9d0ed87 commit 1cc34ec
Show file tree
Hide file tree
Showing 32 changed files with 335 additions and 136 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"dependencies": {
"@stoplight/json": "^3.18.1",
"@stoplight/json-schema-generator": "1.0.2",
"@stoplight/types": "14.0.0",
"@stoplight/types": "14.1.0",
"@types/json-schema": "7.0.11",
"@types/swagger-schema-official": "~2.0.22",
"@types/type-is": "^1.6.3",
Expand Down
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export function createContext<T extends Record<string, unknown>>(
service: '',
path: '',
operation: '',
webhookName: '',
webhook: '',
},
references: {},
parentId: '',
Expand Down
8 changes: 8 additions & 0 deletions src/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ export const idGenerators = {
return join(['http_path', props.parentId, sanitizePath(props.path)]);
},

httpWebhookName: (props: Context & { name: string }) => {
return join(['http_webhook_name', props.parentId, sanitizePath(props.name)]);
},

httpOperation: (props: Context & { method: string; path: string }) => {
return join(['http_operation', props.parentId, props.method, sanitizePath(props.path)]);
},

httpWebhookOperation: (props: Context & { method: string; name: string }) => {
return join(['http_webhook_operation', props.parentId, props.method, sanitizePath(props.name)]);
},

httpCallbackOperation: (props: Context & { method: string; path: string }) => {
return join(['http_callback', props.parentId, props.method, props.path]);
},
Expand Down
2 changes: 1 addition & 1 deletion src/oas/__tests__/operation.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { bundleTarget } from '@stoplight/json';
import { OpenAPIObject } from 'openapi3-ts';
import { Spec } from 'swagger-schema-official';

import { setSkipHashing } from '../../hash';
import { transformOas2Operations } from '../../oas2/operation';
import { transformOas3Operations } from '../../oas3/operation';
import { OpenAPIObject } from '../../oas3/types';

setSkipHashing(true);

Expand Down
2 changes: 1 addition & 1 deletion src/oas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type {
Oas2HttpServiceTransformer,
Oas2TransformOperationOpts,
Oas2TransformServiceOpts,
Oas3HttpOperationTransformer,
Oas3HttpEndpointOperationTransformer,
Oas3HttpServiceBundle,
Oas3HttpServiceTransformer,
Oas3TransformOperationOpts,
Expand Down
101 changes: 63 additions & 38 deletions src/oas/operation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { isPlainObject } from '@stoplight/json';
import type { DeepPartial, IHttpOperation } from '@stoplight/types';
import type { DeepPartial, IHttpEndpointOperation } from '@stoplight/types';
import type { OpenAPIObject, OperationObject, PathsObject } from 'openapi3-ts';
import type { Spec } from 'swagger-schema-official';
import pickBy = require('lodash.pickby');

import { isBoolean, isString } from '../guards';
import type { Fragment, HttpOperationTransformer } from '../types';
import type { EndpointOperationConfig, Fragment, HttpEndpointOperationTransformer } from '../types';
import { TransformerContext, TranslateFunction } from '../types';
import { extractId } from '../utils';
import { getExtensions } from './accessors';
Expand All @@ -15,16 +15,32 @@ import { translateToSecurityDeclarationType } from './transformers';

const DEFAULT_METHODS = ['get', 'post', 'put', 'delete', 'options', 'head', 'patch', 'trace'];

export function transformOasOperations<T extends Fragment & DeepPartial<Spec | OpenAPIObject>>(
export const OPERATION_CONFIG: EndpointOperationConfig = {
type: 'operation',
documentProp: 'paths',
nameProp: 'path',
};

export const WEBHOOK_CONFIG: EndpointOperationConfig = {
type: 'webhook',
documentProp: 'webhooks',
nameProp: 'name',
};

export function transformOasEndpointOperations<
T extends Fragment & DeepPartial<Spec | OpenAPIObject>,
TEndpoint extends IHttpEndpointOperation,
>(
document: T,
transformer: HttpOperationTransformer<any>,
transformer: HttpEndpointOperationTransformer<any, TEndpoint>,
config: EndpointOperationConfig,
methods: string[] | null = DEFAULT_METHODS,

ctx?: TransformerContext<T>,
): IHttpOperation[] {
const paths = isPlainObject(document.paths) ? Object.keys(document.paths) : [];
): TEndpoint[] {
const entries = isPlainObject(document[config.documentProp]) ? Object.entries(document[config.documentProp]) : [];

return paths.flatMap(path => {
const value = document.paths![path];
return entries.flatMap(([name, value]) => {
if (!isPlainObject(value)) return [];

let operations = Object.keys(value);
Expand All @@ -35,75 +51,84 @@ export function transformOasOperations<T extends Fragment & DeepPartial<Spec | O
return operations.map(method =>
transformer({
document,
path,
name,
method,
config,
ctx,
}),
);
});
}

export const transformOasOperation: TranslateFunction<
export const transformOasEndpointOperation: TranslateFunction<
DeepPartial<OpenAPIObject> | DeepPartial<Spec>,
[path: string, method: string],
Omit<IHttpOperation, 'responses' | 'request' | 'servers' | 'security' | 'callbacks'>
> = function (path: string, method: string) {
const pathObj = this.maybeResolveLocalRef(this.document?.paths?.[path]) as PathsObject;
[config: EndpointOperationConfig, name: string, method: string],
Omit<IHttpEndpointOperation, 'responses' | 'request' | 'servers' | 'security' | 'callbacks'>
> = function ({ type, documentProp, nameProp }: EndpointOperationConfig, name: string, method: string) {
const pathObj = this.maybeResolveLocalRef(this.document?.[documentProp]?.[name]) as PathsObject;
if (typeof pathObj !== 'object' || pathObj === null) {
throw new Error(`Could not find ${['paths', path].join('/')} in the provided spec.`);
throw new Error(`Could not find ${[documentProp, name].join('/')} in the provided spec.`);
}

const operation = this.maybeResolveLocalRef(pathObj[method]) as OperationObject;
if (!operation) {
throw new Error(`Could not find ${['paths', path, method].join('/')} in the provided spec.`);
const obj = this.maybeResolveLocalRef(pathObj[method]) as OperationObject;
if (!obj) {
throw new Error(`Could not find ${[documentProp, name, method].join('/')} in the provided spec.`);
}

const serviceId = (this.ids.service = String(this.document['x-stoplight']?.id));
this.ids.path = this.generateId.httpPath({ parentId: serviceId, path });
let operationId: string;
if (type === 'operation') {
this.ids.path = this.generateId.httpPath({ parentId: serviceId, path: name });
} else {
this.ids.webhookName = this.generateId.httpWebhookName({ parentId: serviceId, name });
}
let id: string;
if (this.context === 'callback') {
operationId = this.ids.operation =
extractId(operation) ??
id = this.ids.operation =
extractId(obj) ??
this.generateId.httpCallbackOperation({
parentId: serviceId,
method,
path,
path: name,
});
} else if (type === 'operation') {
id = this.ids.operation =
extractId(obj) ?? this.generateId.httpOperation({ parentId: serviceId, method, path: name });
} else {
operationId = this.ids.operation =
extractId(operation) ?? this.generateId.httpOperation({ parentId: serviceId, method, path });
id = this.ids.webhook =
extractId(obj) ?? this.generateId.httpWebhookOperation({ parentId: serviceId, method, name });
}

this.parentId = operationId;
this.context = 'operation';
this.parentId = id;

this.context = type;

return {
id: operationId,
id,

method,
path,
[nameProp]: name,

tags: translateToTags.call(this, operation.tags),
extensions: getExtensions(operation),
tags: translateToTags.call(this, obj.tags),
extensions: getExtensions(obj),

...pickBy(
{
deprecated: operation.deprecated,
internal: operation['x-internal'],
deprecated: obj.deprecated,
internal: obj['x-internal'],
},
isBoolean,
),

...pickBy(
{
iid: operation.operationId,
description: operation.description,
summary: operation.summary,
iid: obj.operationId,
description: obj.description,
summary: obj.summary,
},
isString,
),

securityDeclarationType: translateToSecurityDeclarationType(operation),
...toExternalDocs(operation.externalDocs),
securityDeclarationType: translateToSecurityDeclarationType(obj),
...toExternalDocs(obj.externalDocs),
};
};
3 changes: 2 additions & 1 deletion src/oas/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { isPlainObject } from '@stoplight/json';
import { DeepPartial, IHttpService } from '@stoplight/types';
import { OpenAPIObject } from 'openapi3-ts';
import { Spec } from 'swagger-schema-official';

import { OpenAPIObject } from '../oas3/types';
import pickBy = require('lodash.pickby');

import { isBoolean, isNonNullable, isString } from '../guards';
Expand Down
15 changes: 8 additions & 7 deletions src/oas/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { DeepPartial } from '@stoplight/types';
import { DeepPartial, IHttpEndpointOperation, IHttpOperation } from '@stoplight/types';
import type * as OAS3 from 'openapi3-ts';
import type * as OAS2 from 'swagger-schema-official';

import type {
HttpOperationTransformer,
HttpEndpointOperationTransformer,
HttpServiceBundle,
HttpServiceTransformer,
ITransformOperationOpts,
ITransformEndpointOperationOpts,
ITransformServiceOpts,
} from '../types';

Expand All @@ -30,10 +30,11 @@ export type Oas3HttpServiceBundle = HttpServiceBundle<Oas3TransformServiceOpts>;
/**
* Operation
*/
export type Oas2TransformOperationOpts = ITransformOperationOpts<DeepPartial<OAS2.Spec>>;
export type Oas3TransformOperationOpts = ITransformOperationOpts<DeepPartial<OAS3.OpenAPIObject>>;
export type Oas2HttpOperationTransformer = HttpOperationTransformer<Oas2TransformOperationOpts>;
export type Oas3HttpOperationTransformer = HttpOperationTransformer<Oas3TransformOperationOpts>;
export type Oas2TransformOperationOpts = ITransformEndpointOperationOpts<DeepPartial<OAS2.Spec>>;
export type Oas3TransformOperationOpts = ITransformEndpointOperationOpts<DeepPartial<OAS3.OpenAPIObject>>;
export type Oas2HttpOperationTransformer = HttpEndpointOperationTransformer<Oas2TransformOperationOpts, IHttpOperation>;
export type Oas3HttpEndpointOperationTransformer<T extends IHttpEndpointOperation = IHttpEndpointOperation> =
HttpEndpointOperationTransformer<Oas3TransformOperationOpts, T>;

export type Oas3ParamBase = ParamBase & { in: OAS3.ParameterLocation };
export type Oas2ParamBase = ParamBase & { in: OAS2.BaseParameter['in'] };
Expand Down
1 change: 1 addition & 0 deletions src/oas2/__tests__/__fixtures__/id/bundled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,4 +426,5 @@ export default {
],
},
],
webhooks: [],
};
2 changes: 2 additions & 0 deletions src/oas2/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ describe('bundleOas2Service', () => {
tags: [],
},
],
webhooks: [],
components: {
callbacks: [],
cookie: [],
Expand Down Expand Up @@ -281,6 +282,7 @@ describe('bundleOas2Service', () => {
tags: [],
},
],
webhooks: [],
components: {
callbacks: [],
cookie: [],
Expand Down
18 changes: 11 additions & 7 deletions src/oas2/__tests__/operation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { DeepPartial } from '@stoplight/types';
import { OpenAPIObject } from 'openapi3-ts';
import { Spec } from 'swagger-schema-official';

import { setSkipHashing } from '../../hash';
import { OPERATION_CONFIG } from '../../oas/operation';
import { transformOas2Operation, transformOas2Operations } from '../operation';

setSkipHashing(true);
Expand Down Expand Up @@ -69,9 +69,10 @@ describe('transformOas2Operation', () => {

expect(
transformOas2Operation({
path: '/users/{userId}',
name: '/users/{userId}',
method: 'get',
document,
config: OPERATION_CONFIG,
}),
).toMatchSnapshot({
id: expect.any(String),
Expand Down Expand Up @@ -147,9 +148,10 @@ describe('transformOas2Operation', () => {
};

const result = transformOas2Operation({
path: '/users/{userId}',
name: '/users/{userId}',
method: 'delete',
document,
config: OPERATION_CONFIG,
});

expect(result.responses[0].contents).toHaveLength(0);
Expand All @@ -169,9 +171,10 @@ describe('transformOas2Operation', () => {

expect(
transformOas2Operation({
path: '/users/{userId}',
name: '/users/{userId}',
method: 'get',
document,
config: OPERATION_CONFIG,
}),
).toHaveProperty('deprecated', true);
});
Expand Down Expand Up @@ -226,7 +229,7 @@ describe('transformOas2Operation', () => {
});

it('given malformed parameters should translate operation with those parameters', () => {
const document: Partial<OpenAPIObject> = {
const document: DeepPartial<Spec> = {
swagger: '2.0',
paths: {
'/users/{userId}': {
Expand All @@ -236,7 +239,7 @@ describe('transformOas2Operation', () => {
in: 'header',
name: 'name',
},
null,
null as any,
],
},
},
Expand All @@ -245,9 +248,10 @@ describe('transformOas2Operation', () => {

expect(
transformOas2Operation({
path: '/users/{userId}',
name: '/users/{userId}',
method: 'get',
document,
config: OPERATION_CONFIG,
}),
).toStrictEqual({
id: expect.any(String),
Expand Down
Loading

0 comments on commit 1cc34ec

Please sign in to comment.