diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index aaf81a248..e33bfa11d 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -1,5 +1,5 @@ import type { OpenAPIPaths, OpenAPITag, OpenAPISchema } from '../types'; -import { isOperationName, JsonPointer, alphabeticallyByProp } from '../utils'; +import { getPathOperations, JsonPointer, alphabeticallyByProp } from '../utils'; import { MarkdownRenderer } from './MarkdownRenderer'; import { GroupModel, OperationModel } from './models'; import type { OpenAPIParser } from './OpenAPIParser'; @@ -224,14 +224,14 @@ export class MenuBuilder { function getTags(parser: OpenAPIParser, 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]; - if (path.$ref) { - const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths); - getTags(parser, { [pathName]: resolvedPaths }, isWebhook); - continue; - } + if (path.$ref) { + const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths); + getTags(parser, { [pathName]: resolvedPaths }, isWebhook); + continue; + } + for (const { operationName, operation: operationInfo, pointerPath } of getPathOperations( + path, + )) { let operationTags = operationInfo?.tags; if (!operationTags || !operationTags.length) { @@ -254,7 +254,7 @@ export class MenuBuilder { tag.operations.push({ ...operationInfo, pathName, - pointer: JsonPointer.compile(['paths', pathName, operationName]), + pointer: JsonPointer.compile(['paths', pathName, ...pointerPath]), httpVerb: operationName, pathParameters: path.parameters || [], pathServers: path.servers, diff --git a/src/services/__tests__/models/MenuBuilder.test.ts b/src/services/__tests__/models/MenuBuilder.test.ts index 7deb3ef32..955f252c1 100644 --- a/src/services/__tests__/models/MenuBuilder.test.ts +++ b/src/services/__tests__/models/MenuBuilder.test.ts @@ -2,6 +2,7 @@ import { MenuBuilder } from '../../MenuBuilder'; import { OpenAPIParser } from '../../OpenAPIParser'; +import { OperationModel } from '../../models/Operation'; import { RedocNormalizedOptions } from '../../RedocNormalizedOptions'; const opts = new RedocNormalizedOptions({}); @@ -20,5 +21,81 @@ describe('Models', () => { expect(contentItems[0].name).toEqual('pet'); expect(contentItems[0].type).toEqual('tag'); }); + + test('should include OpenAPI 3.2 additionalOperations with arbitrary custom methods', () => { + parser = new OpenAPIParser( + { + openapi: '3.2.0', + info: { + title: 'Additional operations', + version: '1.0.0', + }, + paths: { + '/participants': { + get: { + tags: ['participants'], + summary: 'List participants with query parameters', + responses: { + '200': { + description: 'OK', + }, + }, + }, + additionalOperations: { + LIST: { + tags: ['participants'], + summary: 'List participants with request body', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + }, + }, + }, + COPY: { + tags: ['participants'], + summary: 'Copy participants with request body', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + }, + }, + }, + }, + }, + }, + }, + undefined, + opts, + ); + + const contentItems = MenuBuilder.buildStructure(parser, opts); + const operations = contentItems[0].items as OperationModel[]; + expect(contentItems).toHaveLength(1); + expect(operations).toHaveLength(3); + expect(operations.map(item => item.httpVerb)).toEqual(['get', 'LIST', 'COPY']); + expect(operations.map(item => item.name)).toEqual([ + 'List participants with query parameters', + 'List participants with request body', + 'Copy participants with request body', + ]); + }); }); }); diff --git a/src/services/models/Callback.ts b/src/services/models/Callback.ts index 49583a290..44a1ec942 100644 --- a/src/services/models/Callback.ts +++ b/src/services/models/Callback.ts @@ -1,6 +1,6 @@ import { action, observable, makeObservable } from 'mobx'; -import { isOperationName, JsonPointer } from '../../utils'; +import { getPathOperations, JsonPointer } from '../../utils'; import { OperationModel } from './Operation'; import type { OpenAPIParser } from '../OpenAPIParser'; import type { OpenAPICallback, Referenced } from '../../types'; @@ -27,19 +27,19 @@ export class CallbackModel { 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]; - + for (const { operationName, operation: operationInfo, pointerPath } of getPathOperations( + path, + )) { const operation = new OperationModel( parser, { ...operationInfo, pathName, - pointer: JsonPointer.compile([pointer, name, pathName, operationName]), + pointer: JsonPointer.compile([pointer, name, pathName, ...pointerPath]), httpVerb: operationName, pathParameters: path.parameters || [], pathServers: path.servers, + isWebhook: false, }, undefined, options, diff --git a/src/services/models/Webhook.ts b/src/services/models/Webhook.ts index 888c8a665..f2ac06bea 100644 --- a/src/services/models/Webhook.ts +++ b/src/services/models/Webhook.ts @@ -2,7 +2,7 @@ import type { OpenAPIPath, Referenced } from '../../types'; import type { OpenAPIParser } from '../OpenAPIParser'; import { OperationModel } from './Operation'; import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; -import { isOperationName } from '../..'; +import { getPathOperations, JsonPointer } from '../..'; export class WebhookModel { operations: OperationModel[] = []; @@ -19,20 +19,25 @@ export class WebhookModel { initWebhooks(parser: OpenAPIParser, webhooks: OpenAPIPath, options: RedocNormalizedOptions) { 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]; - if (webhook.$ref) { - const resolvedWebhook = parser.deref(webhook || {}); - this.initWebhooks(parser, { [operationName]: resolvedWebhook }, options); - } + if (webhook.$ref) { + const resolvedWebhook = parser.deref(webhook || {}); + this.initWebhooks(parser, { [webhookName]: resolvedWebhook }, options); + continue; + } - if (!operationInfo) continue; + for (const { operationName, operation: operationInfo, pointerPath } of getPathOperations( + webhook, + )) { const operation = new OperationModel( parser, { ...operationInfo, + pathName: webhookName, + pointer: JsonPointer.compile(['webhooks', webhookName, ...pointerPath]), httpVerb: operationName, + pathParameters: webhook.parameters || [], + pathServers: webhook.servers, + isWebhook: true, }, undefined, options, diff --git a/src/types/open-api.ts b/src/types/open-api.ts index 648d98e42..42530b00b 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -59,6 +59,8 @@ export interface OpenAPIPath { head?: OpenAPIOperation; patch?: OpenAPIOperation; trace?: OpenAPIOperation; + query?: OpenAPIOperation; + additionalOperations?: Record; servers?: OpenAPIServer[]; parameters?: Array>; $ref?: string; diff --git a/src/utils/__tests__/openapi.test.ts b/src/utils/__tests__/openapi.test.ts index 5249403cf..708066395 100644 --- a/src/utils/__tests__/openapi.test.ts +++ b/src/utils/__tests__/openapi.test.ts @@ -63,12 +63,16 @@ describe('Utils', () => { expect(isOperationName('patch')).toEqual(true); expect(isOperationName('delete')).toEqual(true); expect(isOperationName('options')).toEqual(true); + expect(isOperationName('trace')).toEqual(true); + expect(isOperationName('query')).toEqual(true); }); it('Should return `false` for incorrect HTTP verbs', () => { expect(isOperationName('properties')).toEqual(false); expect(isOperationName('x-name')).toEqual(false); expect(isOperationName('fix')).toEqual(false); + expect(isOperationName('list')).toEqual(false); + expect(isOperationName('copy')).toEqual(false); }); }); diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 15e04a43d..b07d12719 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -7,7 +7,9 @@ import { OpenAPIParser } from '../services/OpenAPIParser'; import { OpenAPIEncoding, OpenAPIMediaType, + OpenAPIOperation, OpenAPIParameter, + OpenAPIPath, OpenAPIParameterStyle, OpenAPIRequestBody, OpenAPIResponse, @@ -58,13 +60,44 @@ const operationNames = { patch: true, delete: true, options: true, - $ref: true, + trace: true, + query: true, }; export function isOperationName(key: string): boolean { return key in operationNames; } +export interface PathOperation { + operationName: string; + operation: OpenAPIOperation; + pointerPath: string[]; +} + +export function getPathOperations(path: OpenAPIPath): PathOperation[] { + const operations = Object.keys(path) + .filter(isOperationName) + .map(operationName => ({ + operationName, + operation: path[operationName], + pointerPath: [operationName], + })); + + for (const operationName of Object.keys(path.additionalOperations || {})) { + if (isOperationName(operationName.toLowerCase())) { + continue; + } + + operations.push({ + operationName, + operation: path.additionalOperations![operationName], + pointerPath: ['additionalOperations', operationName], + }); + } + + return operations.filter(({ operation }) => !!operation); +} + export function getOperationSummary(operation: ExtendedOpenAPIOperation): string { return ( operation.summary ||