Repository: RobinTail/express-zod-api
Pull request #845 (2023-02-27 22:43:03)

Adding `operationId` to the documentation
Alternative solution to #839 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fefd5b6..2cd9714c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
 
 ## Version 8
 
+### v8.10.0
+
+- Feature #845, proposed by [@lazylace37](https://github.com/lazylace37).
+  - Equipping the generated documentation with automatically generated and unique `operationId`.
+  - The `operationId` consists of method, path and optional numeric suffix.
+
+```yaml
+before:
+  paths:
+    /v1/user/retrieve:
+      get:
+        responses:
+after:
+  paths:
+    /v1/user/retrieve:
+      get:
+        operationId: GetV1UserRetrieve
+        responses:
+```
+
 ### v8.9.4
 
 - `openapi3-ts` version is 3.2.0.
diff --git a/example/example.swagger.yaml b/example/example.swagger.yaml
index 20fc13f2..d06d0d4a 100644
--- a/example/example.swagger.yaml
+++ b/example/example.swagger.yaml
@@ -5,6 +5,7 @@ info:
 paths:
   /v1/user/retrieve:
     get:
+      operationId: GetV1UserRetrieve
       responses:
         "200":
           description: GET /v1/user/retrieve Successful response
@@ -76,6 +77,7 @@ paths:
             pattern: /\d+/
   "/v1/user/{id}":
     post:
+      operationId: PostV1UserId
       responses:
         "200":
           description: POST /v1/user/:id Successful response
@@ -200,6 +202,7 @@ paths:
           APIKEY_2: []
   /v1/avatar/send:
     get:
+      operationId: GetV1AvatarSend
       responses:
         "200":
           description: GET /v1/avatar/send Successful response
@@ -227,6 +230,7 @@ paths:
             pattern: /\d+/
   /v1/avatar/stream:
     get:
+      operationId: GetV1AvatarStream
       responses:
         "200":
           description: GET /v1/avatar/stream Successful response
@@ -255,6 +259,7 @@ paths:
             pattern: /\d+/
   /v1/avatar/upload:
     post:
+      operationId: PostV1AvatarUpload
       responses:
         "200":
           description: POST /v1/avatar/upload Successful response
diff --git a/src/client-helpers.ts b/src/client-helpers.ts
index b43ace67..df7c324a 100644
--- a/src/client-helpers.ts
+++ b/src/client-helpers.ts
@@ -195,14 +195,3 @@ export const makeObjectKeysReducer = (
       initial,
     ]
   );
-
-export const cleanId = (path: string, method: string, suffix: string) => {
-  return [method]
-    .concat(path.split("/"))
-    .concat(suffix)
-    .flatMap((entry) => entry.split(/[^A-Z0-9]/gi))
-    .map(
-      (entry) => entry.slice(0, 1).toUpperCase() + entry.slice(1).toLowerCase()
-    )
-    .join("");
-};
diff --git a/src/client.ts b/src/client.ts
index 21bb2939..38b38ac2 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,6 +1,5 @@
 import ts from "typescript";
 import {
-  cleanId,
   exportModifier,
   f,
   makeAnyPromise,
@@ -23,6 +22,7 @@ import {
   parametricIndexNode,
   protectedReadonlyModifier,
 } from "./client-helpers";
+import { makeCleanId } from "./common-helpers";
 import { methods } from "./method";
 import { mimeJson } from "./mime";
 import { Routing } from "./routing";
@@ -43,8 +43,8 @@ export class Client {
     walkRouting({
       routing,
       onEndpoint: (endpoint, path, method) => {
-        const inputId = cleanId(path, method, "input");
-        const responseId = cleanId(path, method, "response");
+        const inputId = makeCleanId(path, method, "input");
+        const responseId = makeCleanId(path, method, "response");
         const input = zodToTs({
           schema: endpoint.getInputSchema(),
           isResponse: false,
diff --git a/src/common-helpers.ts b/src/common-helpers.ts
index 0600d5f7..584f0d10 100644
--- a/src/common-helpers.ts
+++ b/src/common-helpers.ts
@@ -219,6 +219,17 @@ export const hasCoercion = (schema: z.ZodType): boolean =>
     ? schema._def.coerce
     : false;
 
+export const makeCleanId = (path: string, method: string, suffix?: string) => {
+  return [method]
+    .concat(path.split("/"))
+    .concat(suffix || [])
+    .flatMap((entry) => entry.split(/[^A-Z0-9]/gi))
+    .map(
+      (entry) => entry.slice(0, 1).toUpperCase() + entry.slice(1).toLowerCase()
+    )
+    .join("");
+};
+
 export const tryToTransform = ({
   effect,
   sample,
diff --git a/src/open-api.ts b/src/open-api.ts
index caf97274..53a88c09 100644
--- a/src/open-api.ts
+++ b/src/open-api.ts
@@ -4,7 +4,7 @@ import {
   SecuritySchemeObject,
   SecuritySchemeType,
 } from "openapi3-ts";
-import { defaultInputSources } from "./common-helpers";
+import { defaultInputSources, makeCleanId } from "./common-helpers";
 import { CommonConfig } from "./config-type";
 import { mapLogicalContainer } from "./logical-container";
 import { Method } from "./method";
@@ -38,6 +38,17 @@ interface GeneratorParams {
 export class OpenAPI extends OpenApiBuilder {
   protected lastSecuritySchemaIds: Partial<Record<SecuritySchemeType, number>> =
     {};
+  protected lastOperationIdSuffixes: Record<string, number> = {};
+
+  protected ensureUniqOperationId(path: string, method: Method) {
+    const operationId = makeCleanId(path, method);
+    if (operationId in this.lastOperationIdSuffixes) {
+      this.lastOperationIdSuffixes[operationId]++;
+      return `${operationId}${this.lastOperationIdSuffixes[operationId]}`;
+    }
+    this.lastOperationIdSuffixes[operationId] = 1;
+    return operationId;
+  }
 
   protected ensureUniqSecuritySchemaName(subject: SecuritySchemeObject) {
     for (const name in this.rootDoc.components?.securitySchemes || {}) {
@@ -84,6 +95,7 @@ export class OpenAPI extends OpenApiBuilder {
         inputSources,
       });
       const operation: OperationObject = {
+        operationId: this.ensureUniqOperationId(path, method),
         responses: {
           "200": depictResponse({
             ...commonParams,


Test patch:
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index c70afc58..8f32163e 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -8,6 +8,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -71,6 +72,7 @@ paths:
               type: string
             minItems: 1
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -161,6 +163,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -263,6 +266,128 @@ servers:
 "
 `;
 
+exports[`Open API generator Metadata should ensure the uniq operation ids 1`] = `
+"openapi: 3.0.0
+info:
+  title: Testing Operation IDs
+  version: 3.4.5
+paths:
+  /v1/getSome/thing:
+    get:
+      operationId: GetV1GetsomeThing
+      responses:
+        "200":
+          description: GET /v1/getSome/thing Successful response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - success
+                  data:
+                    type: object
+                    properties: {}
+                required:
+                  - status
+                  - data
+        "400":
+          description: GET /v1/getSome/thing Error response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - error
+                  error:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+                    required:
+                      - message
+                required:
+                  - status
+                  - error
+              examples:
+                example1:
+                  value:
+                    status: error
+                    error:
+                      message: Sample error message
+      description: thing is the path segment
+      summary: thing is the path segment
+  "/v1/getSome/{thing}":
+    get:
+      operationId: GetV1GetsomeThing2
+      responses:
+        "200":
+          description: GET /v1/getSome/:thing Successful response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - success
+                  data:
+                    type: object
+                    properties: {}
+                required:
+                  - status
+                  - data
+        "400":
+          description: GET /v1/getSome/:thing Error response
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - error
+                  error:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+                    required:
+                      - message
+                required:
+                  - status
+                  - error
+              examples:
+                example1:
+                  value:
+                    status: error
+                    error:
+                      message: Sample error message
+      description: thing is the path parameter
+      summary: thing is the path parameter
+components:
+  schemas: {}
+  responses: {}
+  parameters: {}
+  examples: {}
+  requestBodies: {}
+  headers: {}
+  securitySchemes: {}
+  links: {}
+  callbacks: {}
+tags: []
+servers:
+  - url: http://example.com
+"
+`;
+
 exports[`Open API generator Metadata should ensure uniq security schema names 1`] = `
 "openapi: 3.0.0
 info:
@@ -271,6 +396,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -343,6 +469,7 @@ paths:
         - HTTP_1: []
   /v1/setSomething:
     post:
+      operationId: PostV1Setsomething
       responses:
         "200":
           description: POST /v1/setSomething Successful response
@@ -404,6 +531,7 @@ paths:
             - write
   /v1/updateSomething:
     put:
+      operationId: PutV1Updatesomething
       responses:
         "200":
           description: PUT /v1/updateSomething Successful response
@@ -504,6 +632,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -617,6 +746,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -708,6 +838,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -804,6 +935,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -886,6 +1018,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -972,6 +1105,7 @@ info:
 paths:
   "/v1/{name}":
     get:
+      operationId: GetV1Name
       responses:
         "200":
           description: GET /v1/:name Successful response
@@ -1060,6 +1194,7 @@ info:
 paths:
   "/v1/{name}":
     post:
+      operationId: PostV1Name
       responses:
         "200":
           description: POST /v1/:name Successful response
@@ -1153,6 +1288,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -1266,6 +1402,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -1393,6 +1530,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -1506,6 +1644,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -1614,6 +1753,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -1733,6 +1873,7 @@ info:
 paths:
   /v1/user/retrieve:
     get:
+      operationId: GetV1UserRetrieve
       responses:
         "200":
           description: GET /v1/user/retrieve Successful response
@@ -1804,6 +1945,7 @@ paths:
             pattern: /\\d+/
   "/v1/user/{id}":
     post:
+      operationId: PostV1UserId
       responses:
         "200":
           description: POST /v1/user/:id Successful response
@@ -1928,6 +2070,7 @@ paths:
           APIKEY_2: []
   /v1/avatar/send:
     get:
+      operationId: GetV1AvatarSend
       responses:
         "200":
           description: GET /v1/avatar/send Successful response
@@ -1955,6 +2098,7 @@ paths:
             pattern: /\\d+/
   /v1/avatar/stream:
     get:
+      operationId: GetV1AvatarStream
       responses:
         "200":
           description: GET /v1/avatar/stream Successful response
@@ -1983,6 +2127,7 @@ paths:
             pattern: /\\d+/
   /v1/avatar/upload:
     post:
+      operationId: PostV1AvatarUpload
       responses:
         "200":
           description: POST /v1/avatar/upload Successful response
@@ -2104,6 +2249,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2209,6 +2355,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2362,6 +2509,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2487,6 +2635,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2577,6 +2726,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2704,6 +2854,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2796,6 +2947,7 @@ info:
 paths:
   /v1/getSomething:
     post:
+      operationId: PostV1Getsomething
       responses:
         "200":
           description: POST /v1/getSomething Successful response
@@ -2921,6 +3073,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
@@ -3001,6 +3154,7 @@ info:
 paths:
   /v1/getSomething:
     get:
+      operationId: GetV1Getsomething
       responses:
         "200":
           description: GET /v1/getSomething Successful response
diff --git a/tests/unit/open-api.spec.ts b/tests/unit/open-api.spec.ts
index 5344d536..31d5852b 100644
--- a/tests/unit/open-api.spec.ts
+++ b/tests/unit/open-api.spec.ts
@@ -863,5 +863,35 @@ describe("Open API generator", () => {
       }).getSpecAsYaml();
       expect(spec).toMatchSnapshot();
     });
+
+    test("should ensure the uniq operation ids", () => {
+      const spec = new OpenAPI({
+        config: sampleConfig,
+        routing: {
+          v1: {
+            getSome: {
+              thing: defaultEndpointsFactory.build({
+                description: "thing is the path segment",
+                method: "get",
+                input: z.object({}),
+                output: z.object({}),
+                handler: async () => ({}),
+              }),
+              ":thing": defaultEndpointsFactory.build({
+                description: "thing is the path parameter",
+                method: "get",
+                input: z.object({}),
+                output: z.object({}),
+                handler: async () => ({}),
+              }),
+            },
+          },
+        },
+        version: "3.4.5",
+        title: "Testing Operation IDs",
+        serverUrl: "http://example.com",
+      }).getSpecAsYaml();
+      expect(spec).toMatchSnapshot();
+    });
   });
 });