diff --git a/demo/examples/tests/paramSerialization.yaml b/demo/examples/tests/paramSerialization.yaml index cc6fe782c..527633651 100644 --- a/demo/examples/tests/paramSerialization.yaml +++ b/demo/examples/tests/paramSerialization.yaml @@ -122,6 +122,71 @@ paths: "200": description: Successful response + /api/resource:customVerb: + post: + tags: + - params + summary: Custom verb endpoint in path + description: | + Demonstrates a literal custom verb suffix in the path segment. + Example: + ``` + Result: /api/resource:customVerb + ``` + responses: + "200": + description: Successful response + + /files/{name}.{ext}: + get: + tags: + - params + summary: Path parameters in the same segment + description: | + Demonstrates multiple path parameters in a single path segment. + Example: + ``` + {name} = "report" + {ext} = "pdf" + Result: /files/report.pdf + ``` + parameters: + - name: name + in: path + required: true + schema: + type: string + - name: ext + in: path + required: true + schema: + type: string + responses: + "200": + description: Successful response + + /jobs/{id}:cancel: + post: + tags: + - params + summary: Path template combined with custom verb + description: | + Demonstrates a path parameter with a verb-like suffix in the same segment. + Example: + ``` + {id} = "123" + Result: /jobs/123:cancel + ``` + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Successful response + /search: get: tags: diff --git a/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.test.ts b/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.test.ts index a25734a22..39a54f350 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.test.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.test.ts @@ -95,4 +95,180 @@ describe("openapi", () => { expect(schemaItems[0].id).toBe("without-tags"); }); }); + + describe("path template and custom verb handling", () => { + it("binds postman requests for OpenAPI templates and path verbs", async () => { + const openapiData = { + openapi: "3.0.0", + info: { + title: "Path Template API", + version: "1.0.0", + }, + paths: { + "/api/resource:customVerb": { + post: { + summary: "Custom verb endpoint", + operationId: "customVerbOperation", + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + "/api/users/{id}": { + get: { + summary: "Get user by ID", + operationId: "getUserById", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { + type: "string", + }, + }, + ], + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + "/api/users/{userId}/posts/{postId}": { + get: { + summary: "Get user post", + operationId: "getUserPost", + parameters: [ + { + name: "userId", + in: "path", + required: true, + schema: { + type: "string", + }, + }, + { + name: "postId", + in: "path", + required: true, + schema: { + type: "string", + }, + }, + ], + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + "/files/{name}.{ext}": { + get: { + summary: "Get file by name and extension", + operationId: "getFileByNameAndExt", + parameters: [ + { + name: "name", + in: "path", + required: true, + schema: { + type: "string", + }, + }, + { + name: "ext", + in: "path", + required: true, + schema: { + type: "string", + }, + }, + ], + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + "/jobs/{id}:cancel": { + post: { + summary: "Cancel job", + operationId: "cancelJob", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { + type: "string", + }, + }, + ], + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + }, + }; + + const options: APIOptions = { + specPath: "dummy", + outputDir: "build", + }; + const sidebarOptions = {} as SidebarOptions; + const [items] = await processOpenapiFile( + openapiData as any, + options, + sidebarOptions + ); + + const apiItems = items.filter((item) => item.type === "api"); + expect(apiItems).toHaveLength(5); + + const customVerbItem = apiItems.find( + (item) => item.type === "api" && item.id === "custom-verb-operation" + ) as any; + expect(customVerbItem.api.path).toBe("/api/resource:customVerb"); + expect(customVerbItem.api.method).toBe("post"); + expect(customVerbItem.api.postman).toBeDefined(); + + const standardItem = apiItems.find( + (item) => item.type === "api" && item.id === "get-user-by-id" + ) as any; + expect(standardItem.api.path).toBe("/api/users/{id}"); + expect(standardItem.api.method).toBe("get"); + expect(standardItem.api.postman).toBeDefined(); + + const multiParamItem = apiItems.find( + (item) => item.type === "api" && item.id === "get-user-post" + ) as any; + expect(multiParamItem.api.path).toBe( + "/api/users/{userId}/posts/{postId}" + ); + expect(multiParamItem.api.method).toBe("get"); + expect(multiParamItem.api.postman).toBeDefined(); + + const sameSegmentItem = apiItems.find( + (item) => item.type === "api" && item.id === "get-file-by-name-and-ext" + ) as any; + expect(sameSegmentItem.api.path).toBe("/files/{name}.{ext}"); + expect(sameSegmentItem.api.method).toBe("get"); + expect(sameSegmentItem.api.postman).toBeDefined(); + + const templatedVerbItem = apiItems.find( + (item) => item.type === "api" && item.id === "cancel-job" + ) as any; + expect(templatedVerbItem.api.path).toBe("/jobs/{id}:cancel"); + expect(templatedVerbItem.api.method).toBe("post"); + expect(templatedVerbItem.api.postman).toBeDefined(); + }); + }); }); diff --git a/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts b/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts index c48b62bd4..5d4331806 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts @@ -561,28 +561,44 @@ function createItems( /** * Attach Postman Request objects to the corresponding ApiItems. */ +function pathTemplateToRegex(pathTemplate: string): RegExp { + const pathWithTemplateTokens = pathTemplate.replace( + /\{[^}]+\}/g, + "__OPENAPI_PATH_PARAM__" + ); + const escapedPathTemplate = pathWithTemplateTokens.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&" + ); + const templatePattern = escapedPathTemplate.replace( + /__OPENAPI_PATH_PARAM__/g, + "[^/]+" + ); + return new RegExp(`^${templatePattern}$`); +} + function bindCollectionToApiItems( items: ApiMetadata[], postmanCollection: sdk.Collection ) { + const apiMatchers = items + .filter((item): item is ApiPageMetadata => item.type === "api") + .map((item) => ({ + apiItem: item, + method: item.api.method.toLowerCase(), + pathMatcher: pathTemplateToRegex(item.api.path), + })); + postmanCollection.forEachItem((item: any) => { const method = item.request.method.toLowerCase(); - const path = item.request.url - .getPath({ unresolved: true }) // unresolved returns "/:variableName" instead of "/" - .replace(/(? { - if ( - item.type === "info" || - item.type === "tag" || - item.type === "schema" - ) { - return false; - } - return item.api.path === path && item.api.method === method; - }); + const postmanPath = item.request.url.getPath({ unresolved: true }); + const match = apiMatchers.find( + ({ method: itemMethod, pathMatcher }) => + itemMethod === method && pathMatcher.test(postmanPath) + ); - if (apiItem?.type === "api") { - apiItem.api.postman = item.request; + if (match) { + match.apiItem.api.postman = item.request; } }); }