Repository: RobinTail/express-zod-api
Pull request #637 (2022-10-09 20:04:32)

Feat: Endpoint's `summary` — short description
Following up [the comment](https://github.com/RobinTail/express-zod-api/pull/571#issuecomment-1272567389) by @pupca


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb788f64..e9165e39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,25 @@
 
 ## Version 8
 
+### v8.2.0
+
+- Feature #637: endpoint short description (summary).
+  - Added the ability to assign a `shortDescription` to endpoints.
+  - One sentence of no more than 50 characters is implied.
+  - This text is substituted into the `summary` property of the generated documentation.
+  - Visualizers of the generated OpenAPI documentation nicely display this text on the same line as the endpoint path.
+  - If a `shortDescription` is not specified, but a regular `description` is, then by default the `summary` will be
+    generated from the `description` by trimming.
+  - You can optionally disable this behavior with the new option `hasSummaryFromDescription` of the `OpenAPI` generator.
+
+```typescript
+const exampleEndpoint = yourEndpointsFactory.build({
+  // ...
+  description: "The detailed explanaition on what this endpoint does.",
+  shortDescription: "Retrieves the user.",
+});
+```
+
 ### v8.1.0
 
 - Feature #571: tagging the endpoints.
diff --git a/README.md b/README.md
index cdc180fe..a24bb01e 100644
--- a/README.md
+++ b/README.md
@@ -812,13 +812,15 @@ const yamlString = new OpenAPI({
 }).getSpecAsYaml();
 ```
 
-You can add descriptions and examples to any I/O schema or its properties, and they will be included into the generated
-documentation of your API. Consider the following example:
+You can add descriptions and examples to your endpoints, their I/O schemas and their properties. It will be included
+into the generated documentation of your API. Consider the following example:
 
 ```typescript
 import { defaultEndpointsFactory, withMeta } from "express-zod-api";
 
 const exampleEndpoint = defaultEndpointsFactory.build({
+  shortDescription: "Retrieves the user.", // <—— this becomes the summary line
+  description: "The detailed explanaition on what this endpoint does.",
   input: withMeta(
     z.object({
       id: z.number().describe("the ID of the user"),
@@ -826,7 +828,7 @@ const exampleEndpoint = defaultEndpointsFactory.build({
   ).example({
     id: 123,
   }),
-  // ..., // similarly for output and middlewares
+  // ..., similarly for output and middlewares
 });
 ```
 
diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts
index ec18c0e7..cef009da 100644
--- a/example/endpoints/retrieve-user.ts
+++ b/example/endpoints/retrieve-user.ts
@@ -7,7 +7,8 @@ export const retrieveUserEndpoint = taggedEndpointsFactory
   .build({
     method: "get",
     tag: "users",
-    description: "example user retrieval endpoint",
+    shortDescription: "Retrieves the user.",
+    description: "Example user retrieval endpoint.",
     input: z.object({
       id: z
         .string()
diff --git a/example/endpoints/send-avatar.ts b/example/endpoints/send-avatar.ts
index bd89de51..204404b7 100644
--- a/example/endpoints/send-avatar.ts
+++ b/example/endpoints/send-avatar.ts
@@ -4,6 +4,7 @@ import fs from "fs";
 
 export const sendAvatarEndpoint = fileSendingEndpointsFactory.build({
   method: "get",
+  shortDescription: "Sends a file content.",
   tags: ["files", "users"],
   input: z.object({
     userId: z
diff --git a/example/endpoints/stream-avatar.ts b/example/endpoints/stream-avatar.ts
index 3f1f2eea..21c82414 100644
--- a/example/endpoints/stream-avatar.ts
+++ b/example/endpoints/stream-avatar.ts
@@ -3,6 +3,7 @@ import { fileStreamingEndpointsFactory } from "../factories";
 
 export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({
   method: "get",
+  shortDescription: "Streams a file content.",
   tags: ["users", "files"],
   input: z.object({
     userId: z
diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts
index 2c49d942..96148e74 100644
--- a/example/endpoints/update-user.ts
+++ b/example/endpoints/update-user.ts
@@ -5,7 +5,7 @@ export const updateUserEndpoint =
   keyAndTokenAuthenticatedEndpointsFactory.build({
     method: "post",
     tag: "users",
-    description: "example user update endpoint",
+    description: "Changes the user record. Example user update endpoint.",
     input: withMeta(
       z.object({
         // id is the route path param of /v1/user/:id
diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts
index 9e74a6c0..3c3c083d 100644
--- a/example/endpoints/upload-avatar.ts
+++ b/example/endpoints/upload-avatar.ts
@@ -5,6 +5,7 @@ import { taggedEndpointsFactory } from "../factories";
 export const uploadAvatarEndpoint = taggedEndpointsFactory.build({
   method: "post",
   tag: "files",
+  description: "Handles a file upload.",
   input: z
     .object({
       avatar: z
diff --git a/example/example.swagger.yaml b/example/example.swagger.yaml
index 91aba793..4e5ae11f 100644
--- a/example/example.swagger.yaml
+++ b/example/example.swagger.yaml
@@ -62,7 +62,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
-      description: example user retrieval endpoint
+      description: Example user retrieval endpoint.
+      summary: Retrieves the user.
       tags:
         - users
       parameters:
@@ -141,7 +142,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
-      description: example user update endpoint
+      description: Changes the user record. Example user update endpoint.
+      summary: Changes the user record. Example user update endp…
       tags:
         - users
       parameters:
@@ -211,6 +213,7 @@ paths:
             text/plain:
               schema:
                 type: string
+      summary: Sends a file content.
       tags:
         - files
         - users
@@ -238,6 +241,7 @@ paths:
             text/plain:
               schema:
                 type: string
+      summary: Streams a file content.
       tags:
         - users
         - files
@@ -320,6 +324,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
+      description: Handles a file upload.
+      summary: Handles a file upload.
       tags:
         - files
       requestBody:
diff --git a/src/endpoint.ts b/src/endpoint.ts
index 1eb3f23e..1a4f7b46 100644
--- a/src/endpoint.ts
+++ b/src/endpoint.ts
@@ -30,7 +30,7 @@ export abstract class AbstractEndpoint {
     logger: Logger;
     config: CommonConfig;
   }): Promise<void>;
-  public abstract getDescription(): string | undefined;
+  public abstract getDescription(variant: "short" | "long"): string | undefined;
   public abstract getMethods(): Method[];
   public abstract getInputSchema(): IOSchema;
   public abstract getOutputSchema(): IOSchema;
@@ -61,6 +61,7 @@ type EndpointProps<
   handler: Handler<z.output<IN>, z.input<OUT>, OPT>;
   resultHandler: ResultHandlerDefinition<POS, NEG>;
   description?: string;
+  shortDescription?: string;
 } & ({ scopes?: SCO[] } | { scope?: SCO }) &
   ({ tags?: TAG[] } | { tag?: TAG }) &
   MethodsDefinition<M>;
@@ -75,7 +76,7 @@ export class Endpoint<
   SCO extends string,
   TAG extends string
 > extends AbstractEndpoint {
-  protected readonly description?: string;
+  protected readonly descriptions: Record<"short" | "long", string | undefined>;
   protected readonly methods: M[] = [];
   protected readonly middlewares: AnyMiddlewareDef[] = [];
   protected readonly inputSchema: IN;
@@ -93,6 +94,7 @@ export class Endpoint<
     handler,
     resultHandler,
     description,
+    shortDescription,
     mimeTypes,
     ...rest
   }: EndpointProps<IN, OUT, OPT, M, POS, NEG, SCO, TAG>) {
@@ -103,7 +105,7 @@ export class Endpoint<
     this.outputSchema = outputSchema;
     this.handler = handler;
     this.resultHandler = resultHandler;
-    this.description = description;
+    this.descriptions = { long: description, short: shortDescription };
     this.scopes = [];
     this.tags = [];
     if ("scopes" in rest && rest.scopes) {
@@ -125,8 +127,8 @@ export class Endpoint<
     }
   }
 
-  public override getDescription() {
-    return this.description;
+  public override getDescription(variant: "short" | "long") {
+    return this.descriptions[variant];
   }
 
   public override getMethods(): M[] {
diff --git a/src/endpoints-factory.ts b/src/endpoints-factory.ts
index 52182377..ef21bf52 100644
--- a/src/endpoints-factory.ts
+++ b/src/endpoints-factory.ts
@@ -37,6 +37,7 @@ type BuildProps<
   output: OUT;
   handler: Handler<z.output<ProbableIntersection<MIN, IN>>, z.input<OUT>, OPT>;
   description?: string;
+  shortDescription?: string;
 } & ({ scopes?: SCO[] } | { scope?: SCO }) &
   ({ tags?: TAG[] } | { tag?: TAG }) &
   MethodsDefinition<M>;
@@ -150,7 +151,6 @@ export class EndpointsFactory<
   public build<BIN extends IOSchema, BOUT extends IOSchema, M extends Method>({
     input,
     handler,
-    description,
     output: outputSchema,
     ...rest
   }: BuildProps<BIN, BOUT, IN, OUT, M, SCO, TAG>): Endpoint<
@@ -166,7 +166,6 @@ export class EndpointsFactory<
     const { middlewares, resultHandler } = this;
     return new Endpoint({
       handler,
-      description,
       middlewares,
       outputSchema,
       resultHandler,
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index 3e3e95c2..bd8e8ded 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -3,11 +3,11 @@ import {
   ExampleObject,
   ExamplesObject,
   MediaTypeObject,
-  ParameterObject,
-  SchemaObject,
   OAuthFlowsObject,
+  ParameterObject,
   RequestBodyObject,
   ResponseObject,
+  SchemaObject,
   SecurityRequirementObject,
   SecuritySchemeObject,
   TagObject,
@@ -62,6 +62,7 @@ interface ReqResDepictHelperCommonProps {
   endpoint: AbstractEndpoint;
 }
 
+const shortDescriptionLimit = 50;
 const isoDateDocumentationUrl =
   "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString";
 
@@ -873,3 +874,10 @@ export const depictTags = <TAG extends string>(
         : {}),
     };
   });
+
+export const ensureShortDescription = (description: string) => {
+  if (description.length <= shortDescriptionLimit) {
+    return description;
+  }
+  return description.slice(0, shortDescriptionLimit - 1) + "…";
+};
diff --git a/src/open-api.ts b/src/open-api.ts
index 647bdc58..fd0e4e89 100644
--- a/src/open-api.ts
+++ b/src/open-api.ts
@@ -16,6 +16,7 @@ import {
   depictSecurity,
   depictSecurityRefs,
   depictTags,
+  ensureShortDescription,
 } from "./open-api-helpers";
 import { Routing, routingCycle, RoutingCycleParams } from "./routing";
 
@@ -25,8 +26,12 @@ interface GeneratorParams {
   serverUrl: string;
   routing: Routing;
   config: CommonConfig;
+  /** @default Successful response */
   successfulResponseDescription?: string;
+  /** @default Error response */
   errorResponseDescription?: string;
+  /** @default true */
+  hasSummaryFromDescription?: boolean;
 }
 
 export class OpenAPI extends OpenApiBuilder {
@@ -57,6 +62,7 @@ export class OpenAPI extends OpenApiBuilder {
     serverUrl,
     successfulResponseDescription = "Successful response",
     errorResponseDescription = "Error response",
+    hasSummaryFromDescription = true,
   }: GeneratorParams) {
     super();
     this.addInfo({ title, version }).addServer({ url: serverUrl });
@@ -67,6 +73,9 @@ export class OpenAPI extends OpenApiBuilder {
     ) => {
       const method = _method as Method;
       const commonParams = { path, method, endpoint };
+      const [shortDesc, longDesc] = (["short", "long"] as const).map(
+        endpoint.getDescription.bind(endpoint)
+      );
       const inputSources =
         config.inputSources?.[method] || defaultInputSources[method];
       const depictedParams = depictRequestParams({
@@ -87,8 +96,14 @@ export class OpenAPI extends OpenApiBuilder {
           }),
         },
       };
-      if (endpoint.getDescription()) {
-        operation.description = endpoint.getDescription();
+      if (longDesc) {
+        operation.description = longDesc;
+        if (hasSummaryFromDescription && shortDesc === undefined) {
+          operation.summary = ensureShortDescription(longDesc);
+        }
+      }
+      if (shortDesc) {
+        operation.summary = ensureShortDescription(shortDesc);
       }
       if (endpoint.getTags().length > 0) {
         operation.tags = endpoint.getTags();


Test patch:
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 6d4e942e..dbc8c913 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -1747,7 +1747,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
-      description: example user retrieval endpoint
+      description: Example user retrieval endpoint.
+      summary: Retrieves the user.
       tags:
         - users
       parameters:
@@ -1826,7 +1827,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
-      description: example user update endpoint
+      description: Changes the user record. Example user update endpoint.
+      summary: Changes the user record. Example user update endp…
       tags:
         - users
       parameters:
@@ -1896,6 +1898,7 @@ paths:
             text/plain:
               schema:
                 type: string
+      summary: Sends a file content.
       tags:
         - files
         - users
@@ -1923,6 +1926,7 @@ paths:
             text/plain:
               schema:
                 type: string
+      summary: Streams a file content.
       tags:
         - users
         - files
@@ -2005,6 +2009,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
+      description: Handles a file upload.
+      summary: Handles a file upload.
       tags:
         - files
       requestBody:
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index 59842ae4..fd6a7f76 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -41,6 +41,7 @@ import {
   depictSecurityRefs,
   depictZodBranded,
   depictTags,
+  ensureShortDescription,
 } from "../../src/open-api-helpers";
 import { serializeSchemaForTest } from "../helpers";
 
@@ -1015,4 +1016,21 @@ describe("Open API helpers", () => {
       ).toMatchSnapshot();
     });
   });
+
+  describe("ensureShortDescription()", () => {
+    test("keeps the short text as it is", () => {
+      expect(ensureShortDescription("here is a short text")).toBe(
+        "here is a short text"
+      );
+      expect(ensureShortDescription(" ")).toBe(" ");
+      expect(ensureShortDescription("")).toBe("");
+    });
+    test("trims the long text", () => {
+      expect(
+        ensureShortDescription(
+          "this text is definitely too long for the short description"
+        )
+      ).toBe("this text is definitely too long for the short de…");
+    });
+  });
 });