Repository: nestjs/swagger (ts)

Issue:
Colon escaping is not reverted
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current behavior

Using colon in endpoint path like `users:find` needs to be escaped:
* `users[:]find` for express
* `users::find` for fastify

But SwaggerModule.createDocument does not revert this escape sequences and emits literal strings like `users[:]find`.

![image](https://user-images.githubusercontent.com/17786588/137479093-14b024f1-d1ba-42cd-a733-306a7f868c26.png)


Currently we are forced to use this workaround (express only):

```ts
function fixColonInEndpointPath(document: OpenAPIObject): OpenAPIObject {
  const paths = Object.fromEntries(
    Object.entries(document.paths).map(([path, endpoints]) => [
      path.replace(/\[:\]/, ':'),
      endpoints,
    ]),
  );
  return { ...document, paths };
}
```

```ts
const document = fixColonInEndpointPath(
    SwaggerModule.createDocument(
      app,
      new DocumentBuilder() ...
```

### Minimum reproduction code

https://github.com/glebbash/nestjs-swagger-colon-bug

### Steps to reproduce

1. npm install
2. npm test

### Expected behavior

SwaggerModule should detect platform (express/fastify) and revert colon escape sequences.

### Package version

5.1.0

### NestJS version

8.1.1

### Node.js version

14

### In which operating systems have you tested?

- [ ] macOS
- [ ] Windows
- [X] Linux

### Other

_No response_

Pull request:
fix(): unescape colons in paths
## PR Checklist
Please check if your PR fulfills the following requirements:

- [X] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [X] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
```
[X] Bugfix
[ ] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Other... Please describe:
```

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

Issue Number: #1618


## What is the new behavior?

Fixes #1618

## Does this PR introduce a breaking change?
```
[ ] Yes
[X] No
```

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->


## Other information

Patch:
diff --git a/lib/interfaces/module-route.interface.ts b/lib/interfaces/module-route.interface.ts
new file mode 100644
index 00000000..f2644a06
--- /dev/null
+++ b/lib/interfaces/module-route.interface.ts
@@ -0,0 +1,4 @@
+import { OpenAPIObject } from '.';
+
+export type ModuleRoute = Omit<OpenAPIObject, 'openapi' | 'info'> &
+  Record<'root', any>;
diff --git a/lib/swagger-scanner.ts b/lib/swagger-scanner.ts
index 104a3c02..eca108c4 100644
--- a/lib/swagger-scanner.ts
+++ b/lib/swagger-scanner.ts
@@ -6,6 +6,7 @@ import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
 import { InstanceToken, Module } from '@nestjs/core/injector/module';
 import { flatten, isEmpty } from 'lodash';
 import { OpenAPIObject, SwaggerDocumentOptions } from './interfaces';
+import { ModuleRoute } from './interfaces/module-route.interface';
 import {
   ReferenceObject,
   SchemaObject
@@ -19,7 +20,7 @@ import { getGlobalPrefix } from './utils/get-global-prefix';
 import { stripLastSlash } from './utils/strip-last-slash.util';
 
 export class SwaggerScanner {
-  private readonly transfomer = new SwaggerTransformer();
+  private readonly transformer = new SwaggerTransformer();
   private readonly schemaObjectFactory = new SchemaObjectFactory(
     new ModelPropertiesAccessor(),
     new SwaggerTypesMapper()
@@ -51,7 +52,7 @@ export class SwaggerScanner {
 
     const denormalizedPaths = modules.map(
       ({ routes, metatype, relatedModules }) => {
-        let result = [];
+        let result: ModuleRoute[] = [];
 
         if (deepScanRoutes) {
           // only load submodules routes if asked
@@ -77,7 +78,7 @@ export class SwaggerScanner {
             });
         }
         const modulePath = this.getModulePathMetadata(container, metatype);
-        return result.concat(
+        result = result.concat(
           this.scanModuleRoutes(
             routes,
             modulePath,
@@ -86,6 +87,7 @@ export class SwaggerScanner {
             operationIdFactory
           )
         );
+        return this.transformer.unescapeColonsInPath(app, result);
       }
     );
 
@@ -93,7 +95,7 @@ export class SwaggerScanner {
     this.addExtraModels(schemas, extraModels);
 
     return {
-      ...this.transfomer.normalizePaths(flatten(denormalizedPaths)),
+      ...this.transformer.normalizePaths(flatten(denormalizedPaths)),
       components: {
         schemas: schemas as Record<string, SchemaObject | ReferenceObject>
       }
@@ -106,7 +108,7 @@ export class SwaggerScanner {
     globalPrefix: string | undefined,
     applicationConfig: ApplicationConfig,
     operationIdFactory?: (controllerKey: string, methodKey: string) => string
-  ): Array<Omit<OpenAPIObject, 'openapi' | 'info'> & Record<'root', any>> {
+  ): ModuleRoute[] {
     const denormalizedArray = [...routes.values()].map((ctrl) =>
       this.explorer.exploreController(
         ctrl,
diff --git a/lib/swagger-transformer.ts b/lib/swagger-transformer.ts
index fa885582..ea8017e6 100644
--- a/lib/swagger-transformer.ts
+++ b/lib/swagger-transformer.ts
@@ -1,5 +1,7 @@
+import { INestApplication } from '@nestjs/common';
 import { filter, groupBy, keyBy, mapValues, omit } from 'lodash';
 import { OpenAPIObject } from './interfaces';
+import { ModuleRoute } from './interfaces/module-route.interface';
 
 export class SwaggerTransformer {
   public normalizePaths(
@@ -26,4 +28,23 @@ export class SwaggerTransformer {
       paths
     };
   }
+
+  public unescapeColonsInPath(
+    app: INestApplication,
+    moduleRoutes: ModuleRoute[]
+  ): ModuleRoute[] {
+    const httpAdapter = app.getHttpAdapter();
+    const usingFastify = httpAdapter && httpAdapter.getType() === 'fastify';
+    const unescapeColon = usingFastify
+      ? (path: string) => path.replace(/:\{([^}]+)\}/g, ':$1')
+      : (path: string) => path.replace(/\[:\]/g, ':');
+
+    return moduleRoutes.map((moduleRoute) => ({
+      ...moduleRoute,
+      root: {
+        ...moduleRoute.root,
+        path: unescapeColon(moduleRoute.root.path)
+      }
+    }));
+  }
 }
diff --git a/package-lock.json b/package-lock.json
index 2cf90949..529369f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2353,9 +2353,9 @@
       "dev": true
     },
     "@types/node": {
-      "version": "14.17.28",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.28.tgz",
-      "integrity": "sha512-1Nh2wEWN8y9hyC4yTDPccUnRunqNeBEFmmTU+K5FpGZOEixqAzMhUXzrBwajNYCZq5uoGaztaMN9Yy1PDMMB6Q==",
+      "version": "14.17.27",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.27.tgz",
+      "integrity": "sha512-94+Ahf9IcaDuJTle/2b+wzvjmutxXAEXU6O81JHblYXUg2BDG+dnBy7VxIPHKAyEEDHzCMQydTJuWvrE+Aanzw==",
       "dev": true
     },
     "@types/normalize-package-data": {
diff --git a/package.json b/package.json
index 9eb67b9e..2c8da70e 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
     "@nestjs/platform-fastify": "8.1.1",
     "@types/jest": "27.0.2",
     "@types/lodash": "4.14.176",
-    "@types/node": "14.17.28",
+    "@types/node": "14.17.27",
     "@typescript-eslint/eslint-plugin": "5.1.0",
     "@typescript-eslint/parser": "5.1.0",
     "class-transformer": "0.4.0",


Test patch:
diff --git a/e2e/api-spec.json b/e2e/api-spec.json
index 307b206c..bc575dfd 100644
--- a/e2e/api-spec.json
+++ b/e2e/api-spec.json
@@ -67,6 +67,50 @@
         }
       }
     },
+    "/api/v1/express:colon:another/{prop}": {
+      "get": {
+        "operationId": "AppController_withColonExpress",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        }
+      }
+    },
+    "/api/v2/express:colon:another/{prop}": {
+      "get": {
+        "operationId": "AppController_withColonExpress",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        }
+      }
+    },
+    "/api/v1/fastify:{colon}:{another}/{prop}": {
+      "get": {
+        "operationId": "AppController_withColonFastify",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        }
+      }
+    },
+    "/api/v2/fastify:{colon}:{another}/{prop}": {
+      "get": {
+        "operationId": "AppController_withColonFastify",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        }
+      }
+    },
     "/api/cats": {
       "post": {
         "operationId": "CatsController_create",
diff --git a/e2e/fastify.e2e-spec.ts b/e2e/fastify.e2e-spec.ts
index 38146746..c7da7695 100644
--- a/e2e/fastify.e2e-spec.ts
+++ b/e2e/fastify.e2e-spec.ts
@@ -52,6 +52,11 @@ describe('Fastify Swagger', () => {
     }
   });
 
+  it('should fix colons in url', async () => {
+    const document = SwaggerModule.createDocument(app, builder.build());
+    expect(document.paths['/fastify:colon:another/{prop}']).toBeDefined();
+  });
+
   it('should pass uiConfig options to fastify-swagger', async () => {
     const document1 = SwaggerModule.createDocument(app, builder.build());
     const uiConfig = {
diff --git a/e2e/src/app.controller.ts b/e2e/src/app.controller.ts
index 779ea058..d4ee3a8e 100644
--- a/e2e/src/app.controller.ts
+++ b/e2e/src/app.controller.ts
@@ -13,4 +13,14 @@ export class AppController {
   withAliases(): string {
     return 'Hello world!';
   }
+
+  @Get('express[:]colon[:]another/:prop')
+  withColonExpress(): string {
+    return 'Hello world!';
+  }
+
+  @Get('fastify::colon::another/:prop')
+  withColonFastify(): string {
+    return 'Hello world!';
+  }
 }
diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts
index fbdc4b1b..c05e3df3 100644
--- a/e2e/validate-schema.e2e-spec.ts
+++ b/e2e/validate-schema.e2e-spec.ts
@@ -66,6 +66,11 @@ describe('Validate OpenAPI schema', () => {
     }
   });
 
+  it('should fix colons in url', async () => {
+    const document = SwaggerModule.createDocument(app, options);
+    expect(document.paths['/api/v1/express:colon:another/{prop}']).toBeDefined();
+  });
+
   it('should merge custom components passed via config', async () => {
     const components = {
       schemas: {