Repository: RobinTail/express-zod-api (ts)

Issue:
Incorrect parameter notation is used in generated documentation
Route path params in OpenAPI / Swagger specification should use curly braces notation instead of colon notation used by Express.

https://swagger.io/specification/
see "Path Templating"
> Path templating refers to the usage of template expressions, delimited by curly braces ({}), to mark a section of a URL path as replaceable using path parameters.

Reported in PR #201 

Pull request:
Using curly braces notation for path params in generated documentation.
Closes #202 

Originally reported in #201 

Patch:
diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml
new file mode 100644
index 00000000..988fdafa
--- /dev/null
+++ b/.github/workflows/swagger.yml
@@ -0,0 +1,18 @@
+name: OpenAPI Validation
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+
+
+jobs:
+  validate:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Validate example specification
+        uses: char0n/swagger-editor-validate@v1.2.1
+        with:
+          definition-file: example/example.swagger.yaml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee6a3e3b..b74ff643 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
 
 ## Version 3
 
+### v3.1.2
+
+- Fixed issue #202, originally reported in PR #201.
+  - Using curly braces notation instead of colon for route path params in generated documentation according to
+    OpenAPI / Swagger specification.
+  - See "Path templating" at https://swagger.io/specification/.
+
+```yaml
+# before
+/v1/user/:id:
+# after
+"/v1/user/{id}":
+```
+
 ### v3.1.1
 
 - No changes. Releasing as 3.1.1 due to a typo in Readme I found after publishing 3.1.0.
diff --git a/example/example.swagger.yaml b/example/example.swagger.yaml
index 3c9ce438..ca270bd1 100644
--- a/example/example.swagger.yaml
+++ b/example/example.swagger.yaml
@@ -71,7 +71,7 @@ paths:
             description: a numeric string containing the id of the user
             type: string
             pattern: /\d+/
-  /v1/user/:id:
+  "/v1/user/{id}":
     post:
       responses:
         "200":
diff --git a/src/common-helpers.ts b/src/common-helpers.ts
index a9e3367c..bb6d3595 100644
--- a/src/common-helpers.ts
+++ b/src/common-helpers.ts
@@ -60,6 +60,9 @@ export type Merge<A extends IOSchema, B extends IOSchema | any> = z.ZodObject<
 export type OutputMarker = IOSchema & { _typeGuard: "OutputMarker" };
 export const markOutput = (output: IOSchema) => output as OutputMarker;
 
+/** @see https://expressjs.com/en/guide/routing.html */
+export const routePathParamsRegex = /:([A-Za-z0-9_]+)/g;
+
 export type ReplaceMarkerInShape<
   S extends z.ZodRawShape,
   OUT extends IOSchema
@@ -216,9 +219,8 @@ export const combinations = <T extends any>(
   return { type: "tuple", value: result };
 };
 
-/** @see https://expressjs.com/en/guide/routing.html */
 export function getRoutePathParams(path: string): string[] {
-  const match = path.match(/:([A-Za-z0-9_]+)/g);
+  const match = path.match(routePathParamsRegex);
   if (!match) {
     return [];
   }
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index a44b3b29..33e2aa83 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -17,6 +17,7 @@ import {
   getExamples,
   getRoutePathParams,
   IOSchema,
+  routePathParamsRegex,
 } from "./common-helpers";
 import { AbstractEndpoint } from "./endpoint";
 import { OpenAPIError } from "./errors";
@@ -49,6 +50,10 @@ interface ReqResDepictHelperCommonProps {
 
 /* eslint-disable @typescript-eslint/no-use-before-define */
 
+export function reformatParamsInPath(path: string): string {
+  return path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`);
+}
+
 const depictDefault: DepictHelper<z.ZodDefault<z.ZodTypeAny>> = ({
   schema: {
     _def: { innerType, defaultValue },
diff --git a/src/open-api.ts b/src/open-api.ts
index e06a2b8b..53f38e48 100644
--- a/src/open-api.ts
+++ b/src/open-api.ts
@@ -4,6 +4,7 @@ import {
   depictRequestParams,
   depictRequest,
   depictResponse,
+  reformatParamsInPath,
 } from "./open-api-helpers";
 import { Routing, routingCycle, RoutingCycleParams } from "./routing";
 
@@ -55,8 +56,9 @@ export class OpenAPI extends OpenApiBuilder {
         // @todo involve config/inputSources in v4
         operation.requestBody = depictRequest(commonParams);
       }
-      this.addPath(path, {
-        ...(this.rootDoc.paths?.[path] || {}),
+      const swaggerCompatiblePath = reformatParamsInPath(path);
+      this.addPath(swaggerCompatiblePath, {
+        ...(this.rootDoc.paths?.[swaggerCompatiblePath] || {}),
         [method]: operation,
       });
     };


Test patch:
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 81f19ad6..64bab8ef 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -734,7 +734,7 @@ info:
   title: Testing route path params
   version: 3.4.5
 paths:
-  /v1/:name:
+  \\"/v1/{name}\\":
     get:
       responses:
         \\"200\\":
@@ -823,7 +823,7 @@ info:
   title: Testing route path params
   version: 3.4.5
 paths:
-  /v1/:name:
+  \\"/v1/{name}\\":
     post:
       responses:
         \\"200\\":
@@ -1438,7 +1438,7 @@ paths:
             description: a numeric string containing the id of the user
             type: string
             pattern: /\\\\d+/
-  /v1/user/:id:
+  \\"/v1/user/{id}\\":
     post:
       responses:
         \\"200\\":
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index 40b08682..855f8fb4 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -2,6 +2,7 @@ import { z } from "../../src/index";
 import {
   depictSchema,
   excludeParamsFromDepiction,
+  reformatParamsInPath,
 } from "../../src/open-api-helpers";
 
 describe("Open API helpers", () => {
@@ -49,4 +50,17 @@ describe("Open API helpers", () => {
       expect(excludeParamsFromDepiction(depicted, ["a"])).toMatchSnapshot();
     });
   });
+
+  describe("reformatParamsInPath()", () => {
+    test("should replace route path params from colon to curly braces notation", () => {
+      expect(reformatParamsInPath("/v1/user")).toBe("/v1/user");
+      expect(reformatParamsInPath("/v1/user/:id")).toBe("/v1/user/{id}");
+      expect(reformatParamsInPath("/v1/flight/:from-:to")).toBe(
+        "/v1/flight/{from}-{to}"
+      );
+      expect(reformatParamsInPath("/v1/flight/:from-:to/updates")).toBe(
+        "/v1/flight/{from}-{to}/updates"
+      );
+    });
+  });
 });