Repository: RobinTail/express-zod-api
Pull request #571 (2022-08-28 19:06:58)

Feat: Tagging endpoints
See #564 

![image](https://user-images.githubusercontent.com/13189514/194766714-bf718ae9-0dbc-44f6-ba88-6eeb3b5481c0.png)


pupca: @RobinTail any progress on this experiment? What is missing? Any help needed?
RobinTail: @pupca , what's missing is an approach for enforcing constraints on tags used across the API, especially when using different EndpointFactories.
I believe this is required in order to avoid the manual input mistakes when tagging the endpoints.
Perhaps there should be a method of the Factory for setting all possible tags from an array, but it have to be applied to all Factories used in the API.
In case you have better ideas, please let me know or create a PR.
RobinTail: @pupca , check out the updated PR and let me know what you think.
Thank you
RobinTail: It would be great to have a list of the tags along with their descriptions in the configuration.
However, currently, EndpointsFactory does not interact with Config.
Moreover, the library provides `defaultEndpointsFactory`, a ready-to-go instance that does not need any configuration.
The workaround could be some method that applies the configuration to the factory... but I'm sure I like this.
@pupca 
RobinTail: @pupca , you know what... I have an idea :)
Tagging will be a feature on demand. So, we'll keep the `defaultEndpointsFactory` as it is, without tags.
However, we'll make the `EndpointsFactory` constructor to accept the configuration (maybe even optionally), along with the ResultHandler.
This way, we could keep the tags in a one single place — the configuration, but you'll have to opt-in for using tags by creating your own instance of `EndpointsFactory`.
pupca: Hi @RobinTail, first of all thanks for taking your time implement this feature! 

Hm, I see what you are doing there and yes that can be an option. But perhaps it could be done in very simplistic way, that in the `OpenAPI` class you will define the tags and its descriptions and in the `EndpointsFactory` you will just be using the string representations (ids) of that tags. Yes, sure, refactoring of tags or missing/extra tags will be 'hard' to spot, but I think that is quite acceptable.

As well as I think we should add the `summary` tag into the `defaultEndpointsFactory` ([see usage here](https://swagger.io/docs/specification/paths-and-operations/)) as this is quite a important part of the endpoint description in OpenApi, what do you think?
RobinTail: > As well as I think we should add the summary tag into the defaultEndpointsFactory ([see usage here](https://swagger.io/docs/specification/paths-and-operations/)) as this is quite a important part of the endpoint description in OpenApi, what do you think?

Yes, this is exactly what I'd like to achieve by keeping tags in the configuration object.
At the same I'd like to prevent manual tagging mistakes when creating endpoints, by enforcing constraints if tags are present in the configuration.
I think I'm getting closer to the solution that would fulfill all these needs by using `EndpointsFactory` constructor overloads.
That would preserve the backward compatibility as well.
Thank you for your support and comments @pupca 
RobinTail: Nice!

![image](https://user-images.githubusercontent.com/13189514/194766728-e9043f81-2c99-4800-a1ba-b4c89f7ffef4.png)

pupca: Looks good @RobinTail , would you be able to squeeze in the the `summary` tag, perhaps in the same fashion as the `description` tag ?
RobinTail: > Looks good @RobinTail , would you be able to squeeze in the the `summary` tag, perhaps in the same fashion as the `description` tag ?

Oh, I see what you mean.
You'd like to have `summary` prop along with the `description` one when creating endpoint.
Yeah, sure, it's possible, but I'm not sure it's related to the tagging feature, but I'm going think on it for sure. @pupca 
pupca: Right, that (tags and summary) just make the OpenApi feature way more usable, therefore i kinda put it together :D 
RobinTail: Regarding this `summary` prop, my concern is that I'd like to avoid an uncertainty on which property to use: `description` or `summary`. Maybe it's obvious for advanced OpenAPI users, but definitely not for beginners. Perhaps, I'm going to make a prop with a name like `shortDescription` that would be assigned to the `summary` in the OpenAPI spec.
What do you think, @pupca ?
pupca: @RobinTail, yes that certainly work, or make the prop as like OpenApi object, so instead of 'description' (which can stay for backwards compatibility), we can have `specification` object that would have props `{ description: string; summary: string }` and perhaps many more from the OpenApi definition if anyone would want.
pupca: > Upd.
And maybe I'd copy description to the summary if it's short enough. But how exactly short — I don't know yet.

Just watch out, those are two different things in the OpenApi definition, `summary` which is one sentence description of the endpoint like `Get collection of users` and `description` which might be a longer text talking about the endpoint more in depth.
pupca: 👍 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90425741..cb788f64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,49 @@
 
 ## Version 8
 
+### v8.1.0
+
+- Feature #571: tagging the endpoints.
+  - Good news dear community! You can now tag your endpoints using the new properties of the `.build()` method
+    of the `EndpointsFactory`.
+  - For your convenience and for the sake of Semantics, there are singular and plural properties: `tag` and `tags`.
+  - By default, these properties allow any string, so in order to enforce restrictions and achieve the consistency
+    across all endpoints, the possible tags should be declared in the configuration first and also a brand
+    new `EndpointsFactory` instantiation approach is required.
+  - The configuration has got a new `tags` property for declaring possible tags and their descriptions.
+  - Tags are an important part of the generated documentation for the OpenAPI standard.
+- The property `scopes` (introduced in v7.9.0) has got its singular variation `scope`.
+
+```typescript
+import {
+  createConfig,
+  EndpointsFactory,
+  defaultResultHandler,
+} from "express-zod-api";
+
+const config = createConfig({
+  // ..., use the simple or the advanced syntax:
+  tags: {
+    users: "Everything about the users",
+    files: {
+      description: "Everything about the files processing",
+      url: "https://example.com",
+    },
+  },
+});
+
+// instead of defaultEndpointsFactory use the following approach:
+const taggedEndpointsFactory = new EndpointsFactory({
+  resultHandler: defaultResultHandler, // or use your custom one
+  config,
+});
+
+const exampleEndpoint = taggedEndpointsFactory.build({
+  // ...
+  tag: "users", // or tags: ["users", "files"]
+});
+```
+
 ### v8.0.2
 
 - `express` version is 4.18.2.
diff --git a/README.md b/README.md
index 73631a4d..cdc180fe 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Start your API server with I/O schema validation and custom middlewares in minut
    5. [Set up routing](#set-up-routing)
    6. [Start your server](#start-your-server)
    7. [Try it](#try-it)
-4. [Fascinating features](#fascinating-features)
+4. [Charming features](#charming-features)
    1. [Cross-Origin Resource Sharing](#cross-origin-resource-sharing) (CORS)
    2. [Middlewares](#middlewares)
    3. [Options](#options)
@@ -46,6 +46,7 @@ Start your API server with I/O schema validation and custom middlewares in minut
    18. [Enabling HTTPS](#enabling-https)
    19. [Generating a Frontend Client](#generating-a-frontend-client)
    20. [Creating a documentation](#creating-a-documentation)
+   21. [Tagging the endpoints](#tagging-the-endpoints)
 5. [Additional hints](#additional-hints)
    1. [How to test endpoints](#how-to-test-endpoints)
    2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output)
@@ -208,7 +209,7 @@ You should receive the following response:
 { "status": "success", "data": { "greetings": "Hello, Rick. Happy coding!" } }
 ```
 
-# Fascinating features
+# Charming features
 
 ## Cross-Origin Resource Sharing
 
@@ -832,6 +833,43 @@ const exampleEndpoint = defaultEndpointsFactory.build({
 _See the example of the generated documentation
 [here](https://github.com/RobinTail/express-zod-api/blob/master/example/example.swagger.yaml)_
 
+## Tagging the endpoints
+
+When generating documentation, you may find it necessary to classify endpoints into groups. For this, the
+possibility of tagging endpoints is provided. In order to achieve the consistency of tags across all endpoints, the
+possible tags should be declared in the configuration first and another instantiation approach of the
+`EndpointsFactory` is required. Consider the following example:
+
+```typescript
+import {
+  createConfig,
+  EndpointsFactory,
+  defaultResultHandler,
+} from "express-zod-api";
+
+const config = createConfig({
+  // ..., use the simple or the advanced syntax:
+  tags: {
+    users: "Everything about the users",
+    files: {
+      description: "Everything about the files processing",
+      url: "https://example.com",
+    },
+  },
+});
+
+// instead of defaultEndpointsFactory use the following approach:
+const taggedEndpointsFactory = new EndpointsFactory({
+  resultHandler: defaultResultHandler, // or use your custom one
+  config, // <—— supply your config here
+});
+
+const exampleEndpoint = taggedEndpointsFactory.build({
+  // ...
+  tag: "users", // or tags: ["users", "files"]
+});
+```
+
 # Additional hints
 
 ## How to test endpoints
diff --git a/example/config.ts b/example/config.ts
index 2f46b835..5bdcce30 100644
--- a/example/config.ts
+++ b/example/config.ts
@@ -11,4 +11,8 @@ export const config = createConfig({
     level: "debug",
     color: true,
   },
+  tags: {
+    users: "Everything about the users",
+    files: "Everything about the files processing",
+  },
 });
diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts
index 6d6e61f1..ec18c0e7 100644
--- a/example/endpoints/retrieve-user.ts
+++ b/example/endpoints/retrieve-user.ts
@@ -1,10 +1,12 @@
-import { z, createHttpError, defaultEndpointsFactory } from "../../src";
+import { z, createHttpError } from "../../src";
+import { taggedEndpointsFactory } from "../factories";
 import { methodProviderMiddleware } from "../middlewares";
 
-export const retrieveUserEndpoint = defaultEndpointsFactory
+export const retrieveUserEndpoint = taggedEndpointsFactory
   .addMiddleware(methodProviderMiddleware)
   .build({
     method: "get",
+    tag: "users",
     description: "example user retrieval endpoint",
     input: z.object({
       id: z
diff --git a/example/endpoints/send-avatar.ts b/example/endpoints/send-avatar.ts
index 95b0f2e4..bd89de51 100644
--- a/example/endpoints/send-avatar.ts
+++ b/example/endpoints/send-avatar.ts
@@ -3,7 +3,8 @@ import { fileSendingEndpointsFactory } from "../factories";
 import fs from "fs";
 
 export const sendAvatarEndpoint = fileSendingEndpointsFactory.build({
-  methods: ["get"],
+  method: "get",
+  tags: ["files", "users"],
   input: z.object({
     userId: z
       .string()
diff --git a/example/endpoints/stream-avatar.ts b/example/endpoints/stream-avatar.ts
index e43520f1..3f1f2eea 100644
--- a/example/endpoints/stream-avatar.ts
+++ b/example/endpoints/stream-avatar.ts
@@ -2,7 +2,8 @@ import { z } from "../../src";
 import { fileStreamingEndpointsFactory } from "../factories";
 
 export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({
-  methods: ["get"],
+  method: "get",
+  tags: ["users", "files"],
   input: z.object({
     userId: z
       .string()
diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts
index d524b001..2c49d942 100644
--- a/example/endpoints/update-user.ts
+++ b/example/endpoints/update-user.ts
@@ -4,6 +4,7 @@ import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories";
 export const updateUserEndpoint =
   keyAndTokenAuthenticatedEndpointsFactory.build({
     method: "post",
+    tag: "users",
     description: "example user update endpoint",
     input: withMeta(
       z.object({
diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts
index 1aea585f..9e74a6c0 100644
--- a/example/endpoints/upload-avatar.ts
+++ b/example/endpoints/upload-avatar.ts
@@ -1,8 +1,10 @@
-import { z, defaultEndpointsFactory } from "../../src";
+import { z } from "../../src";
 import crypto from "crypto";
+import { taggedEndpointsFactory } from "../factories";
 
-export const uploadAvatarEndpoint = defaultEndpointsFactory.build({
+export const uploadAvatarEndpoint = taggedEndpointsFactory.build({
   method: "post",
+  tag: "files",
   input: z
     .object({
       avatar: z
diff --git a/example/example.swagger.yaml b/example/example.swagger.yaml
index b3f88cb7..26425b14 100644
--- a/example/example.swagger.yaml
+++ b/example/example.swagger.yaml
@@ -63,6 +63,8 @@ paths:
                     error:
                       message: Sample error message
       description: example user retrieval endpoint
+      tags:
+        - users
       parameters:
         - name: id
           in: query
@@ -140,6 +142,8 @@ paths:
                     error:
                       message: Sample error message
       description: example user update endpoint
+      tags:
+        - users
       parameters:
         - name: id
           in: path
@@ -207,6 +211,9 @@ paths:
             text/plain:
               schema:
                 type: string
+      tags:
+        - files
+        - users
       parameters:
         - name: userId
           in: query
@@ -231,6 +238,9 @@ paths:
             text/plain:
               schema:
                 type: string
+      tags:
+        - users
+        - files
       parameters:
         - name: userId
           in: query
@@ -310,6 +320,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
+      tags:
+        - files
       requestBody:
         content:
           multipart/form-data:
@@ -340,7 +352,11 @@ components:
       name: token
   links: {}
   callbacks: {}
-tags: []
+tags:
+  - name: users
+    description: Everything about the users
+  - name: files
+    description: Everything about the files processing
 servers:
   - url: http://example.com
 
diff --git a/example/factories.ts b/example/factories.ts
index e557411f..884af44b 100644
--- a/example/factories.ts
+++ b/example/factories.ts
@@ -2,18 +2,25 @@ import mime from "mime";
 import {
   createApiResponse,
   createResultHandler,
-  defaultEndpointsFactory,
+  defaultResultHandler,
   EndpointsFactory,
   z,
 } from "../src";
+import { config } from "./config";
 import { authMiddleware } from "./middlewares";
 import fs from "fs";
 
+export const taggedEndpointsFactory = new EndpointsFactory({
+  resultHandler: defaultResultHandler,
+  config,
+});
+
 export const keyAndTokenAuthenticatedEndpointsFactory =
-  defaultEndpointsFactory.addMiddleware(authMiddleware);
+  taggedEndpointsFactory.addMiddleware(authMiddleware);
 
-export const fileSendingEndpointsFactory = new EndpointsFactory(
-  createResultHandler({
+export const fileSendingEndpointsFactory = new EndpointsFactory({
+  config,
+  resultHandler: createResultHandler({
     getPositiveResponse: () =>
       createApiResponse(z.string(), mime.getType("svg") || "image/svg+xml"),
     getNegativeResponse: () =>
@@ -29,11 +36,12 @@ export const fileSendingEndpointsFactory = new EndpointsFactory(
         response.status(400).send("Data is missing");
       }
     },
-  })
-);
+  }),
+});
 
-export const fileStreamingEndpointsFactory = new EndpointsFactory(
-  createResultHandler({
+export const fileStreamingEndpointsFactory = new EndpointsFactory({
+  config,
+  resultHandler: createResultHandler({
     getPositiveResponse: () => createApiResponse(z.file().binary(), "image/*"),
     getNegativeResponse: () =>
       createApiResponse(z.string(), mime.getType("txt") || "text/plain"),
@@ -50,5 +58,5 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory(
         response.status(400).send("Filename is missing");
       }
     },
-  })
-);
+  }),
+});
diff --git a/src/config-type.ts b/src/config-type.ts
index 7a1db6a7..97b72dc9 100644
--- a/src/config-type.ts
+++ b/src/config-type.ts
@@ -66,7 +66,12 @@ type HeadersProvider = (params: {
   logger: Logger;
 }) => Headers | Promise<Headers>;
 
-export interface CommonConfig {
+export type TagsConfig<TAG extends string> = Record<
+  TAG,
+  string | { description: string; url?: string }
+>;
+
+export interface CommonConfig<TAG extends string = string> {
   // enable cross-origin resource sharing
   // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
   // you can override the default CORS headers by setting up a provider function here
@@ -83,10 +88,13 @@ export interface CommonConfig {
   // default: { get: [query, params], post: [body, params, files],
   // put: [body, params], patch: [body, params], delete: [body, query, params] }
   inputSources?: Partial<InputSources>;
+  // optional endpoints tagging configuration, example: { users: "Everything about the users" }
+  tags?: TagsConfig<TAG>;
 }
 
 export const createConfig = <
-  T extends (ServerConfig | AppConfig) & CommonConfig
+  TAG extends string,
+  T extends (ServerConfig | AppConfig) & CommonConfig<TAG>
 >(
   config: T
 ): T => config;
diff --git a/src/depends-on-method.ts b/src/depends-on-method.ts
index 9e14921e..e31031dd 100644
--- a/src/depends-on-method.ts
+++ b/src/depends-on-method.ts
@@ -6,8 +6,8 @@ export class DependsOnMethod {
   constructor(
     public readonly methods: {
       [K in Method]?:
-        | Endpoint<any, any, any, K, any, any, any>
-        | Endpoint<any, any, any, Method, any, any, any>;
+        | Endpoint<any, any, any, K, any, any, any, any>
+        | Endpoint<any, any, any, Method, any, any, any, any>;
     }
   ) {
     (Object.keys(methods) as (keyof typeof methods)[]).forEach((key) => {
diff --git a/src/endpoint.ts b/src/endpoint.ts
index 6616675c..1eb3f23e 100644
--- a/src/endpoint.ts
+++ b/src/endpoint.ts
@@ -41,6 +41,7 @@ export abstract class AbstractEndpoint {
   public abstract getNegativeMimeTypes(): string[];
   public abstract getSecurity(): LogicalContainer<Security>;
   public abstract getScopes(): string[];
+  public abstract getTags(): string[];
 }
 
 type EndpointProps<
@@ -50,7 +51,8 @@ type EndpointProps<
   M extends Method,
   POS extends ApiResponse,
   NEG extends ApiResponse,
-  SCO extends string
+  SCO extends string,
+  TAG extends string
 > = {
   middlewares: AnyMiddlewareDef[];
   inputSchema: IN;
@@ -59,8 +61,9 @@ type EndpointProps<
   handler: Handler<z.output<IN>, z.input<OUT>, OPT>;
   resultHandler: ResultHandlerDefinition<POS, NEG>;
   description?: string;
-  scopes?: SCO[];
-} & MethodsDefinition<M>;
+} & ({ scopes?: SCO[] } | { scope?: SCO }) &
+  ({ tags?: TAG[] } | { tag?: TAG }) &
+  MethodsDefinition<M>;
 
 export class Endpoint<
   IN extends IOSchema,
@@ -69,7 +72,8 @@ export class Endpoint<
   M extends Method,
   POS extends ApiResponse,
   NEG extends ApiResponse,
-  SCO extends string
+  SCO extends string,
+  TAG extends string
 > extends AbstractEndpoint {
   protected readonly description?: string;
   protected readonly methods: M[] = [];
@@ -79,7 +83,8 @@ export class Endpoint<
   protected readonly outputSchema: OUT;
   protected readonly handler: Handler<z.output<IN>, z.input<OUT>, OPT>;
   protected readonly resultHandler: ResultHandlerDefinition<POS, NEG>;
-  protected readonly scopes?: SCO[];
+  protected readonly scopes: SCO[];
+  protected readonly tags: TAG[];
 
   constructor({
     middlewares,
@@ -89,9 +94,8 @@ export class Endpoint<
     resultHandler,
     description,
     mimeTypes,
-    scopes,
     ...rest
-  }: EndpointProps<IN, OUT, OPT, M, POS, NEG, SCO>) {
+  }: EndpointProps<IN, OUT, OPT, M, POS, NEG, SCO, TAG>) {
     super();
     this.middlewares = middlewares;
     this.inputSchema = inputSchema;
@@ -100,7 +104,20 @@ export class Endpoint<
     this.handler = handler;
     this.resultHandler = resultHandler;
     this.description = description;
-    this.scopes = scopes;
+    this.scopes = [];
+    this.tags = [];
+    if ("scopes" in rest && rest.scopes) {
+      this.scopes.push(...rest.scopes);
+    }
+    if ("scope" in rest && rest.scope) {
+      this.scopes.push(rest.scope);
+    }
+    if ("tags" in rest && rest.tags) {
+      this.tags.push(...rest.tags);
+    }
+    if ("tag" in rest && rest.tag) {
+      this.tags.push(rest.tag);
+    }
     if ("methods" in rest) {
       this.methods = rest.methods;
     } else {
@@ -153,7 +170,11 @@ export class Endpoint<
   }
 
   public override getScopes(): SCO[] {
-    return this.scopes || [];
+    return this.scopes;
+  }
+
+  public override getTags(): TAG[] {
+    return this.tags;
   }
 
   #getDefaultCorsHeaders(): Record<string, string> {
diff --git a/src/endpoints-factory.ts b/src/endpoints-factory.ts
index b2d842c1..52182377 100644
--- a/src/endpoints-factory.ts
+++ b/src/endpoints-factory.ts
@@ -8,6 +8,7 @@ import {
   IOSchema,
   ProbableIntersection,
 } from "./common-helpers";
+import { CommonConfig } from "./config-type";
 import { Endpoint, Handler } from "./endpoint";
 import { Method, MethodsDefinition } from "./method";
 import {
@@ -29,37 +30,58 @@ type BuildProps<
   MIN extends IOSchema<"strip"> | null,
   OPT extends FlatObject,
   M extends Method,
-  SCO extends string
+  SCO extends string,
+  TAG extends string
 > = {
   input: IN;
   output: OUT;
   handler: Handler<z.output<ProbableIntersection<MIN, IN>>, z.input<OUT>, OPT>;
   description?: string;
-  scopes?: SCO[];
-} & MethodsDefinition<M>;
+} & ({ scopes?: SCO[] } | { scope?: SCO }) &
+  ({ tags?: TAG[] } | { tag?: TAG }) &
+  MethodsDefinition<M>;
 
 export class EndpointsFactory<
   POS extends ApiResponse,
   NEG extends ApiResponse,
   IN extends IOSchema<"strip"> | null = null,
   OUT extends FlatObject = {},
-  SCO extends string = string
+  SCO extends string = string,
+  TAG extends string = string
 > {
+  protected resultHandler: ResultHandlerDefinition<POS, NEG>;
   protected middlewares: AnyMiddlewareDef[] = [];
 
-  constructor(protected resultHandler: ResultHandlerDefinition<POS, NEG>) {}
+  constructor(resultHandler: ResultHandlerDefinition<POS, NEG>);
+  /** @desc Consider using the "config" prop with the "tags" option to enforce constraints on tagging the endpoints */
+  constructor(params: {
+    resultHandler: ResultHandlerDefinition<POS, NEG>;
+    config?: CommonConfig<TAG>;
+  });
+  constructor(
+    subject:
+      | ResultHandlerDefinition<POS, NEG>
+      | {
+          resultHandler: ResultHandlerDefinition<POS, NEG>;
+          config?: CommonConfig<TAG>;
+        }
+  ) {
+    this.resultHandler =
+      "resultHandler" in subject ? subject.resultHandler : subject;
+  }
 
   static #create<
     CPOS extends ApiResponse,
     CNEG extends ApiResponse,
     CIN extends IOSchema<"strip"> | null,
     COUT extends FlatObject,
-    CSCO extends string
+    CSCO extends string,
+    CTAG extends string
   >(
     middlewares: AnyMiddlewareDef[],
     resultHandler: ResultHandlerDefinition<CPOS, CNEG>
   ) {
-    const factory = new EndpointsFactory<CPOS, CNEG, CIN, COUT, CSCO>(
+    const factory = new EndpointsFactory<CPOS, CNEG, CIN, COUT, CSCO, CTAG>(
       resultHandler
     );
     factory.middlewares = middlewares;
@@ -76,7 +98,8 @@ export class EndpointsFactory<
       NEG,
       ProbableIntersection<IN, AIN>,
       OUT & AOUT,
-      SCO & ASCO
+      SCO & ASCO,
+      TAG
     >(this.middlewares.concat(subject), this.resultHandler);
   }
 
@@ -106,14 +129,14 @@ export class EndpointsFactory<
           middleware(request as R, response as S, next);
         }),
     };
-    return EndpointsFactory.#create<POS, NEG, IN, OUT & AOUT, SCO>(
+    return EndpointsFactory.#create<POS, NEG, IN, OUT & AOUT, SCO, TAG>(
       this.middlewares.concat(definition),
       this.resultHandler
     );
   }
 
   public addOptions<AOUT extends FlatObject>(options: AOUT) {
-    return EndpointsFactory.#create<POS, NEG, IN, OUT & AOUT, SCO>(
+    return EndpointsFactory.#create<POS, NEG, IN, OUT & AOUT, SCO, TAG>(
       this.middlewares.concat(
         createMiddleware({
           input: z.object({}),
@@ -130,14 +153,15 @@ export class EndpointsFactory<
     description,
     output: outputSchema,
     ...rest
-  }: BuildProps<BIN, BOUT, IN, OUT, M, SCO>): Endpoint<
+  }: BuildProps<BIN, BOUT, IN, OUT, M, SCO, TAG>): Endpoint<
     ProbableIntersection<IN, BIN>,
     BOUT,
     OUT,
     M,
     POS,
     NEG,
-    SCO
+    SCO,
+    TAG
   > {
     const { middlewares, resultHandler } = this;
     return new Endpoint({
diff --git a/src/open-api-helpers.ts b/src/open-api-helpers.ts
index 81b01439..3e3e95c2 100644
--- a/src/open-api-helpers.ts
+++ b/src/open-api-helpers.ts
@@ -10,6 +10,7 @@ import {
   ResponseObject,
   SecurityRequirementObject,
   SecuritySchemeObject,
+  TagObject,
 } from "openapi3-ts";
 import { omit } from "ramda";
 import { z } from "zod";
@@ -20,7 +21,7 @@ import {
   IOSchema,
   routePathParamsRegex,
 } from "./common-helpers";
-import { InputSources } from "./config-type";
+import { InputSources, TagsConfig } from "./config-type";
 import { isoDateRegex, ZodDateIn, ZodDateInDef } from "./date-in-schema";
 import { ZodDateOut, ZodDateOutDef } from "./date-out-schema";
 import { AbstractEndpoint } from "./endpoint";
@@ -858,3 +859,17 @@ export const depictRequest = ({
     ),
   };
 };
+
+export const depictTags = <TAG extends string>(
+  tags: TagsConfig<TAG>
+): TagObject[] =>
+  (Object.keys(tags) as TAG[]).map((tag) => {
+    const def = tags[tag];
+    return {
+      name: tag,
+      description: typeof def === "string" ? def : def.description,
+      ...(typeof def === "object" && def.url
+        ? { externalDocs: { url: def.url } }
+        : {}),
+    };
+  });
diff --git a/src/open-api.ts b/src/open-api.ts
index 60f9d71b..647bdc58 100644
--- a/src/open-api.ts
+++ b/src/open-api.ts
@@ -15,6 +15,7 @@ import {
   reformatParamsInPath,
   depictSecurity,
   depictSecurityRefs,
+  depictTags,
 } from "./open-api-helpers";
 import { Routing, routingCycle, RoutingCycleParams } from "./routing";
 
@@ -58,8 +59,6 @@ export class OpenAPI extends OpenApiBuilder {
     errorResponseDescription = "Error response",
   }: GeneratorParams) {
     super();
-    // @todo make this conditional when going to support OpenAPI 3.1.0
-    delete this.rootDoc.webhooks;
     this.addInfo({ title, version }).addServer({ url: serverUrl });
     const endpointCb: RoutingCycleParams["endpointCb"] = (
       endpoint,
@@ -91,6 +90,9 @@ export class OpenAPI extends OpenApiBuilder {
       if (endpoint.getDescription()) {
         operation.description = endpoint.getDescription();
       }
+      if (endpoint.getTags().length > 0) {
+        operation.tags = endpoint.getTags();
+      }
       if (depictedParams.length > 0) {
         operation.parameters = depictedParams;
       }
@@ -122,5 +124,8 @@ export class OpenAPI extends OpenApiBuilder {
       });
     };
     routingCycle({ routing, endpointCb });
+    // @todo make this conditional when going to support OpenAPI 3.1.0
+    delete this.rootDoc.webhooks;
+    this.rootDoc.tags = config.tags ? depictTags(config.tags) : [];
   }
 }


Test patch:
diff --git a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
index 4e1a7424..71dee44f 100644
--- a/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api-helpers.spec.ts.snap
@@ -690,6 +690,35 @@ exports[`Open API helpers depictString() should depict regular ZodString 1`] = `
 }
 `;
 
+exports[`Open API helpers depictTags() should accept objects with URLs 1`] = `
+[
+  {
+    "description": "Everything about users",
+    "name": "users",
+  },
+  {
+    "description": "Everything about files processing",
+    "externalDocs": {
+      "url": "https://example.com",
+    },
+    "name": "files",
+  },
+]
+`;
+
+exports[`Open API helpers depictTags() should accept plain descriptions 1`] = `
+[
+  {
+    "description": "Everything about users",
+    "name": "users",
+  },
+  {
+    "description": "Everything about files processing",
+    "name": "files",
+  },
+]
+`;
+
 exports[`Open API helpers depictTuple() should depict ZodTuple 1`] = `
 {
   "description": "test",
diff --git a/tests/unit/__snapshots__/open-api.spec.ts.snap b/tests/unit/__snapshots__/open-api.spec.ts.snap
index 399d0854..6d4e942e 100644
--- a/tests/unit/__snapshots__/open-api.spec.ts.snap
+++ b/tests/unit/__snapshots__/open-api.spec.ts.snap
@@ -1748,6 +1748,8 @@ paths:
                     error:
                       message: Sample error message
       description: example user retrieval endpoint
+      tags:
+        - users
       parameters:
         - name: id
           in: query
@@ -1825,6 +1827,8 @@ paths:
                     error:
                       message: Sample error message
       description: example user update endpoint
+      tags:
+        - users
       parameters:
         - name: id
           in: path
@@ -1892,6 +1896,9 @@ paths:
             text/plain:
               schema:
                 type: string
+      tags:
+        - files
+        - users
       parameters:
         - name: userId
           in: query
@@ -1916,6 +1923,9 @@ paths:
             text/plain:
               schema:
                 type: string
+      tags:
+        - users
+        - files
       parameters:
         - name: userId
           in: query
@@ -1995,6 +2005,8 @@ paths:
                     status: error
                     error:
                       message: Sample error message
+      tags:
+        - files
       requestBody:
         content:
           multipart/form-data:
@@ -2025,7 +2037,11 @@ components:
       name: token
   links: {}
   callbacks: {}
-tags: []
+tags:
+  - name: users
+    description: Everything about the users
+  - name: files
+    description: Everything about the files processing
 servers:
   - url: http://example.com
 "
diff --git a/tests/unit/open-api-helpers.spec.ts b/tests/unit/open-api-helpers.spec.ts
index ba06c22c..59842ae4 100644
--- a/tests/unit/open-api-helpers.spec.ts
+++ b/tests/unit/open-api-helpers.spec.ts
@@ -40,6 +40,7 @@ import {
   depictSecurity,
   depictSecurityRefs,
   depictZodBranded,
+  depictTags,
 } from "../../src/open-api-helpers";
 import { serializeSchemaForTest } from "../helpers";
 
@@ -991,4 +992,27 @@ describe("Open API helpers", () => {
       ).toMatchSnapshot();
     });
   });
+
+  describe("depictTags()", () => {
+    test("should accept plain descriptions", () => {
+      expect(
+        depictTags({
+          users: "Everything about users",
+          files: "Everything about files processing",
+        })
+      ).toMatchSnapshot();
+    });
+
+    test("should accept objects with URLs", () => {
+      expect(
+        depictTags({
+          users: { description: "Everything about users" },
+          files: {
+            description: "Everything about files processing",
+            url: "https://example.com",
+          },
+        })
+      ).toMatchSnapshot();
+    });
+  });
 });
diff --git a/tests/unit/open-api.spec.ts b/tests/unit/open-api.spec.ts
index a2a189d9..67afd83e 100644
--- a/tests/unit/open-api.spec.ts
+++ b/tests/unit/open-api.spec.ts
@@ -1,3 +1,4 @@
+import { config as exampleConfig } from "../../example/config";
 import { routing } from "../../example/routing";
 import {
   z,
@@ -10,7 +11,7 @@ import {
 import { expectType } from "tsd";
 
 describe("Open API generator", () => {
-  const config = createConfig({
+  const sampleConfig = createConfig({
     cors: true,
     logger: { level: "debug", color: true },
     server: {
@@ -22,7 +23,7 @@ describe("Open API generator", () => {
     test("should generate the correct schema of example routing", () => {
       const spec = new OpenAPI({
         routing,
-        config,
+        config: exampleConfig,
         version: "1.2.3",
         title: "Example API",
         serverUrl: "http://example.com",
@@ -33,7 +34,7 @@ describe("Open API generator", () => {
     test("should generate the correct schema for complex types", () => {
       const literalValue = "something" as const;
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -63,7 +64,7 @@ describe("Open API generator", () => {
 
     test("should generate the correct schema for nullable and optional types", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -92,7 +93,7 @@ describe("Open API generator", () => {
 
     test("should generate the correct schema for intersection type", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -136,7 +137,7 @@ describe("Open API generator", () => {
 
     test("should generate the correct schema for union type", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -171,7 +172,7 @@ describe("Open API generator", () => {
 
     test("should generate the correct schema for discriminated union type", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -203,7 +204,7 @@ describe("Open API generator", () => {
 
     test("should handle transformation schema in output", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -230,7 +231,7 @@ describe("Open API generator", () => {
 
     test("should handle bigint, boolean, date and null", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -260,7 +261,7 @@ describe("Open API generator", () => {
 
     test("should handle record", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -290,7 +291,7 @@ describe("Open API generator", () => {
 
     test("should handle type any", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -314,7 +315,7 @@ describe("Open API generator", () => {
 
     test("should handle different number types", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -346,7 +347,7 @@ describe("Open API generator", () => {
 
     test("should handle different string types", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -384,7 +385,7 @@ describe("Open API generator", () => {
 
     test("should handle tuples", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -414,7 +415,7 @@ describe("Open API generator", () => {
 
     test("should handle enum types", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -446,7 +447,7 @@ describe("Open API generator", () => {
       );
       const boolean = z.preprocess((arg) => !!arg, z.boolean());
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -485,7 +486,7 @@ describe("Open API generator", () => {
         expect(
           () =>
             new OpenAPI({
-              config,
+              config: sampleConfig,
               routing: {
                 v1: {
                   getSomething: defaultEndpointsFactory.build({
@@ -513,7 +514,7 @@ describe("Open API generator", () => {
       // It existed though in Zod v3.6.x:
       // @see https://github.com/colinhacks/zod/blob/v3.6.1/src/types.ts#L1204
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -548,7 +549,7 @@ describe("Open API generator", () => {
       expectType<TestingType>({ id: "string", field2: "string" });
 
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -581,7 +582,7 @@ describe("Open API generator", () => {
   describe("Route Path Params", () => {
     test("should handle route path params for POST request", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             ":name": defaultEndpointsFactory.build({
@@ -604,7 +605,7 @@ describe("Open API generator", () => {
 
     test("should handle route path params for GET request", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             ":name": defaultEndpointsFactory.build({
@@ -629,7 +630,7 @@ describe("Open API generator", () => {
   describe("Metadata", () => {
     test("should pass over the schema description", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -657,7 +658,7 @@ describe("Open API generator", () => {
 
     test("should pass over the example of an individual parameter", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -685,7 +686,7 @@ describe("Open API generator", () => {
 
     test("should pass over examples of each param from the whole IO schema examples (GET)", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -717,7 +718,7 @@ describe("Open API generator", () => {
 
     test("should pass over examples of the whole IO schema (POST)", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.build({
@@ -749,7 +750,7 @@ describe("Open API generator", () => {
 
     test("should merge endpoint handler examples with its middleware examples", () => {
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory
@@ -821,7 +822,7 @@ describe("Open API generator", () => {
         middleware: jest.fn(),
       });
       const spec = new OpenAPI({
-        config,
+        config: sampleConfig,
         routing: {
           v1: {
             getSomething: defaultEndpointsFactory.addMiddleware(mw1).build({
@@ -836,7 +837,7 @@ describe("Open API generator", () => {
               handler: async () => ({ num: 123 }),
             }),
             setSomething: defaultEndpointsFactory.addMiddleware(mw2).build({
-              scopes: ["write"],
+              scope: "write",
               method: "post",
               input: z.object({}),
               output: z.object({}),