Skip to content

Commit 6fb6802

Browse files
authored
fix(plugin): support template-aware OpenAPI path binding (#1311)
* fix(plugin): support template-aware OpenAPI path binding Replace Postman path string rewrites with OpenAPI template matching so same-segment path params and path-verb suffixes bind correctly without regressions. * add additional param serialization examples
1 parent 6cd891e commit 6fb6802

File tree

3 files changed

+272
-15
lines changed

3 files changed

+272
-15
lines changed

demo/examples/tests/paramSerialization.yaml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,71 @@ paths:
122122
"200":
123123
description: Successful response
124124

125+
/api/resource:customVerb:
126+
post:
127+
tags:
128+
- params
129+
summary: Custom verb endpoint in path
130+
description: |
131+
Demonstrates a literal custom verb suffix in the path segment.
132+
Example:
133+
```
134+
Result: /api/resource:customVerb
135+
```
136+
responses:
137+
"200":
138+
description: Successful response
139+
140+
/files/{name}.{ext}:
141+
get:
142+
tags:
143+
- params
144+
summary: Path parameters in the same segment
145+
description: |
146+
Demonstrates multiple path parameters in a single path segment.
147+
Example:
148+
```
149+
{name} = "report"
150+
{ext} = "pdf"
151+
Result: /files/report.pdf
152+
```
153+
parameters:
154+
- name: name
155+
in: path
156+
required: true
157+
schema:
158+
type: string
159+
- name: ext
160+
in: path
161+
required: true
162+
schema:
163+
type: string
164+
responses:
165+
"200":
166+
description: Successful response
167+
168+
/jobs/{id}:cancel:
169+
post:
170+
tags:
171+
- params
172+
summary: Path template combined with custom verb
173+
description: |
174+
Demonstrates a path parameter with a verb-like suffix in the same segment.
175+
Example:
176+
```
177+
{id} = "123"
178+
Result: /jobs/123:cancel
179+
```
180+
parameters:
181+
- name: id
182+
in: path
183+
required: true
184+
schema:
185+
type: string
186+
responses:
187+
"200":
188+
description: Successful response
189+
125190
/search:
126191
get:
127192
tags:

packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,180 @@ describe("openapi", () => {
9595
expect(schemaItems[0].id).toBe("without-tags");
9696
});
9797
});
98+
99+
describe("path template and custom verb handling", () => {
100+
it("binds postman requests for OpenAPI templates and path verbs", async () => {
101+
const openapiData = {
102+
openapi: "3.0.0",
103+
info: {
104+
title: "Path Template API",
105+
version: "1.0.0",
106+
},
107+
paths: {
108+
"/api/resource:customVerb": {
109+
post: {
110+
summary: "Custom verb endpoint",
111+
operationId: "customVerbOperation",
112+
responses: {
113+
"200": {
114+
description: "OK",
115+
},
116+
},
117+
},
118+
},
119+
"/api/users/{id}": {
120+
get: {
121+
summary: "Get user by ID",
122+
operationId: "getUserById",
123+
parameters: [
124+
{
125+
name: "id",
126+
in: "path",
127+
required: true,
128+
schema: {
129+
type: "string",
130+
},
131+
},
132+
],
133+
responses: {
134+
"200": {
135+
description: "OK",
136+
},
137+
},
138+
},
139+
},
140+
"/api/users/{userId}/posts/{postId}": {
141+
get: {
142+
summary: "Get user post",
143+
operationId: "getUserPost",
144+
parameters: [
145+
{
146+
name: "userId",
147+
in: "path",
148+
required: true,
149+
schema: {
150+
type: "string",
151+
},
152+
},
153+
{
154+
name: "postId",
155+
in: "path",
156+
required: true,
157+
schema: {
158+
type: "string",
159+
},
160+
},
161+
],
162+
responses: {
163+
"200": {
164+
description: "OK",
165+
},
166+
},
167+
},
168+
},
169+
"/files/{name}.{ext}": {
170+
get: {
171+
summary: "Get file by name and extension",
172+
operationId: "getFileByNameAndExt",
173+
parameters: [
174+
{
175+
name: "name",
176+
in: "path",
177+
required: true,
178+
schema: {
179+
type: "string",
180+
},
181+
},
182+
{
183+
name: "ext",
184+
in: "path",
185+
required: true,
186+
schema: {
187+
type: "string",
188+
},
189+
},
190+
],
191+
responses: {
192+
"200": {
193+
description: "OK",
194+
},
195+
},
196+
},
197+
},
198+
"/jobs/{id}:cancel": {
199+
post: {
200+
summary: "Cancel job",
201+
operationId: "cancelJob",
202+
parameters: [
203+
{
204+
name: "id",
205+
in: "path",
206+
required: true,
207+
schema: {
208+
type: "string",
209+
},
210+
},
211+
],
212+
responses: {
213+
"200": {
214+
description: "OK",
215+
},
216+
},
217+
},
218+
},
219+
},
220+
};
221+
222+
const options: APIOptions = {
223+
specPath: "dummy",
224+
outputDir: "build",
225+
};
226+
const sidebarOptions = {} as SidebarOptions;
227+
const [items] = await processOpenapiFile(
228+
openapiData as any,
229+
options,
230+
sidebarOptions
231+
);
232+
233+
const apiItems = items.filter((item) => item.type === "api");
234+
expect(apiItems).toHaveLength(5);
235+
236+
const customVerbItem = apiItems.find(
237+
(item) => item.type === "api" && item.id === "custom-verb-operation"
238+
) as any;
239+
expect(customVerbItem.api.path).toBe("/api/resource:customVerb");
240+
expect(customVerbItem.api.method).toBe("post");
241+
expect(customVerbItem.api.postman).toBeDefined();
242+
243+
const standardItem = apiItems.find(
244+
(item) => item.type === "api" && item.id === "get-user-by-id"
245+
) as any;
246+
expect(standardItem.api.path).toBe("/api/users/{id}");
247+
expect(standardItem.api.method).toBe("get");
248+
expect(standardItem.api.postman).toBeDefined();
249+
250+
const multiParamItem = apiItems.find(
251+
(item) => item.type === "api" && item.id === "get-user-post"
252+
) as any;
253+
expect(multiParamItem.api.path).toBe(
254+
"/api/users/{userId}/posts/{postId}"
255+
);
256+
expect(multiParamItem.api.method).toBe("get");
257+
expect(multiParamItem.api.postman).toBeDefined();
258+
259+
const sameSegmentItem = apiItems.find(
260+
(item) => item.type === "api" && item.id === "get-file-by-name-and-ext"
261+
) as any;
262+
expect(sameSegmentItem.api.path).toBe("/files/{name}.{ext}");
263+
expect(sameSegmentItem.api.method).toBe("get");
264+
expect(sameSegmentItem.api.postman).toBeDefined();
265+
266+
const templatedVerbItem = apiItems.find(
267+
(item) => item.type === "api" && item.id === "cancel-job"
268+
) as any;
269+
expect(templatedVerbItem.api.path).toBe("/jobs/{id}:cancel");
270+
expect(templatedVerbItem.api.method).toBe("post");
271+
expect(templatedVerbItem.api.postman).toBeDefined();
272+
});
273+
});
98274
});

packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -561,28 +561,44 @@ function createItems(
561561
/**
562562
* Attach Postman Request objects to the corresponding ApiItems.
563563
*/
564+
function pathTemplateToRegex(pathTemplate: string): RegExp {
565+
const pathWithTemplateTokens = pathTemplate.replace(
566+
/\{[^}]+\}/g,
567+
"__OPENAPI_PATH_PARAM__"
568+
);
569+
const escapedPathTemplate = pathWithTemplateTokens.replace(
570+
/[.*+?^${}()|[\]\\]/g,
571+
"\\$&"
572+
);
573+
const templatePattern = escapedPathTemplate.replace(
574+
/__OPENAPI_PATH_PARAM__/g,
575+
"[^/]+"
576+
);
577+
return new RegExp(`^${templatePattern}$`);
578+
}
579+
564580
function bindCollectionToApiItems(
565581
items: ApiMetadata[],
566582
postmanCollection: sdk.Collection
567583
) {
584+
const apiMatchers = items
585+
.filter((item): item is ApiPageMetadata => item.type === "api")
586+
.map((item) => ({
587+
apiItem: item,
588+
method: item.api.method.toLowerCase(),
589+
pathMatcher: pathTemplateToRegex(item.api.path),
590+
}));
591+
568592
postmanCollection.forEachItem((item: any) => {
569593
const method = item.request.method.toLowerCase();
570-
const path = item.request.url
571-
.getPath({ unresolved: true }) // unresolved returns "/:variableName" instead of "/<type>"
572-
.replace(/(?<![a-z0-9-_]+):([a-z0-9-_]+)/gi, "{$1}"); // replace "/:variableName" with "/{variableName}"
573-
const apiItem = items.find((item) => {
574-
if (
575-
item.type === "info" ||
576-
item.type === "tag" ||
577-
item.type === "schema"
578-
) {
579-
return false;
580-
}
581-
return item.api.path === path && item.api.method === method;
582-
});
594+
const postmanPath = item.request.url.getPath({ unresolved: true });
595+
const match = apiMatchers.find(
596+
({ method: itemMethod, pathMatcher }) =>
597+
itemMethod === method && pathMatcher.test(postmanPath)
598+
);
583599

584-
if (apiItem?.type === "api") {
585-
apiItem.api.postman = item.request;
600+
if (match) {
601+
match.apiItem.api.postman = item.request;
586602
}
587603
});
588604
}

0 commit comments

Comments
 (0)