Repository: nestjs/swagger
Pull request #2273 (2023-02-06 12:06:07)

feat(swagger): JSON and YAML document endpoints customizable
Same PR as https://github.com/nestjs/swagger/pull/2046
## 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)
- [ ] 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". -->
- [ ] Bugfix
- [X] 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?

Right now the swagger json and yam are deployed in `${finalPath}-json` and  `${finalPath}-yaml`, without possibility of customization:

```
http://localhost:3000/api-json
http://localhost:3000/api-yaml
```

Issue Number: [#1968](https://github.com/nestjs/swagger/issues/1968)


## What is the new behavior?

Added the options `jsonDocumentUrl` and `yamlDocumentUrl` in the interface `SwaggerCustomOptions`
If the option value is present, json and/or yaml documents are served in the specified url.
Furthermore, if `globalPrefix` is specified in the nest options, it's used as prefix for the custom urls.

## Does this PR introduce a breaking change?
- [ ] Yes
- [X] No

## Other information



Patch:
diff --git a/lib/extra/swagger-shim.ts b/lib/extra/swagger-shim.ts
index d0941a73..b0f8b367 100644
--- a/lib/extra/swagger-shim.ts
+++ b/lib/extra/swagger-shim.ts
@@ -165,5 +165,5 @@ export function PickType() {
   return () => {};
 }
 export function getSchemaPath() {
-  return () => ''
+  return () => '';
 }
diff --git a/lib/interfaces/swagger-custom-options.interface.ts b/lib/interfaces/swagger-custom-options.interface.ts
index ba24764d..f9fc3228 100644
--- a/lib/interfaces/swagger-custom-options.interface.ts
+++ b/lib/interfaces/swagger-custom-options.interface.ts
@@ -14,4 +14,6 @@ export interface SwaggerCustomOptions {
   validatorUrl?: string;
   url?: string;
   urls?: Record<'url' | 'name', string>[];
+  jsonDocumentUrl?: string;
+  yamlDocumentUrl?: string;
 }
diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts
index 68e86376..54cc7126 100644
--- a/lib/swagger-module.ts
+++ b/lib/swagger-module.ts
@@ -18,6 +18,7 @@ import { assignTwoLevelsDeep } from './utils/assign-two-levels-deep';
 import { getGlobalPrefix } from './utils/get-global-prefix';
 import { validatePath } from './utils/validate-path.util';
 import { normalizeRelPath } from './utils/normalize-rel-path';
+import { validateGlobalPrefix } from './utils/validate-global-prefix.util';
 
 export class SwaggerModule {
   public static createDocument(
@@ -65,9 +66,13 @@ export class SwaggerModule {
     urlLastSubdirectory: string,
     httpAdapter: HttpServer,
     swaggerInitJS: string,
-    yamlDocument: string,
-    jsonDocument: string,
-    html: string
+    options: {
+      html: string;
+      yamlDocument: string;
+      jsonDocument: string;
+      jsonDocumentUrl: string;
+      yamlDocumentUrl: string;
+    }
   ) {
     httpAdapter.get(
       normalizeRelPath(`${finalPath}/swagger-ui-init.js`),
@@ -100,14 +105,14 @@ export class SwaggerModule {
 
     httpAdapter.get(finalPath, (req, res) => {
       res.type('text/html');
-      res.send(html);
+      res.send(options.html);
     });
 
     // fastify doesn't resolve 'routePath/' -> 'routePath', that's why we handle it manually
     try {
       httpAdapter.get(normalizeRelPath(`${finalPath}/`), (req, res) => {
         res.type('text/html');
-        res.send(html);
+        res.send(options.html);
       });
     } catch (err) {
       /**
@@ -118,14 +123,14 @@ export class SwaggerModule {
        */
     }
 
-    httpAdapter.get(normalizeRelPath(`${finalPath}-json`), (req, res) => {
+    httpAdapter.get(normalizeRelPath(options.jsonDocumentUrl), (req, res) => {
       res.type('application/json');
-      res.send(jsonDocument);
+      res.send(options.jsonDocument);
     });
 
-    httpAdapter.get(normalizeRelPath(`${finalPath}-yaml`), (req, res) => {
+    httpAdapter.get(normalizeRelPath(options.yamlDocumentUrl), (req, res) => {
       res.type('text/yaml');
-      res.send(yamlDocument);
+      res.send(options.yamlDocument);
     });
   }
 
@@ -137,7 +142,7 @@ export class SwaggerModule {
   ) {
     const globalPrefix = getGlobalPrefix(app);
     const finalPath = validatePath(
-      options?.useGlobalPrefix && globalPrefix && !globalPrefix.match(/^(\/?)$/)
+      options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix)
         ? `${globalPrefix}${validatePath(path)}`
         : path
     );
@@ -146,6 +151,19 @@ export class SwaggerModule {
     const yamlDocument = jsyaml.dump(document, { skipInvalid: true });
     const jsonDocument = JSON.stringify(document);
 
+    const validatedGlobalPrefix =
+      options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix)
+        ? validatePath(globalPrefix)
+        : '';
+
+    const finalJSONDocumentPath = options?.jsonDocumentUrl
+      ? `${validatedGlobalPrefix}${validatePath(options.jsonDocumentUrl)}`
+      : `${finalPath}-json`;
+
+    const finalYAMLDocumentPath = options?.yamlDocumentUrl
+      ? `${validatedGlobalPrefix}${validatePath(options.yamlDocumentUrl)}`
+      : `${finalPath}-yaml`;
+
     const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`);
 
     const html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options);
@@ -157,9 +175,13 @@ export class SwaggerModule {
       urlLastSubdirectory,
       httpAdapter,
       swaggerInitJS,
-      yamlDocument,
-      jsonDocument,
-      html
+      {
+        html,
+        yamlDocument,
+        jsonDocument,
+        jsonDocumentUrl: finalJSONDocumentPath,
+        yamlDocumentUrl: finalYAMLDocumentPath
+      }
     );
 
     SwaggerModule.serveStatic(finalPath, app);
diff --git a/lib/type-helpers/intersection-type.helper.ts b/lib/type-helpers/intersection-type.helper.ts
index 734bf39a..2b3ead13 100644
--- a/lib/type-helpers/intersection-type.helper.ts
+++ b/lib/type-helpers/intersection-type.helper.ts
@@ -13,7 +13,7 @@ import { clonePluginMetadataFactory } from './mapped-types.utils';
 const modelPropertiesAccessor = new ModelPropertiesAccessor();
 
 type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
-  k: infer I,
+  k: infer I
 ) => void
   ? I
   : never;
@@ -39,7 +39,7 @@ export function IntersectionType<T extends Type[]>(...classRefs: T) {
     const fields = modelPropertiesAccessor.getModelProperties(
       classRef.prototype
     );
-  
+
     inheritValidationMetadata(classRef, IntersectionClassType);
     inheritTransformationMetadata(classRef, IntersectionClassType);
 
@@ -57,12 +57,11 @@ export function IntersectionType<T extends Type[]>(...classRefs: T) {
       const decoratorFactory = ApiProperty(metadata);
       decoratorFactory(IntersectionClassType.prototype, propertyKey);
     });
-  
   });
 
   const intersectedNames = classRefs.reduce((prev, ref) => prev + ref.name, '');
   Object.defineProperty(IntersectionClassType, 'name', {
-    value: `Intersection${intersectedNames}`,
+    value: `Intersection${intersectedNames}`
   });
   return IntersectionClassType as Intersection<T>;
 }
diff --git a/lib/utils/validate-global-prefix.util.ts b/lib/utils/validate-global-prefix.util.ts
new file mode 100644
index 00000000..9ef1f887
--- /dev/null
+++ b/lib/utils/validate-global-prefix.util.ts
@@ -0,0 +1,2 @@
+export const validateGlobalPrefix = (globalPrefix: string): boolean =>
+  globalPrefix && !globalPrefix.match(/^(\/?)$/);


Test patch:
diff --git a/e2e/express.e2e-spec.ts b/e2e/express.e2e-spec.ts
index 380fc3bd..a0b2498a 100644
--- a/e2e/express.e2e-spec.ts
+++ b/e2e/express.e2e-spec.ts
@@ -96,4 +96,91 @@ describe('Express Swagger', () => {
       expect(Object.keys(response.body).length).toBeGreaterThan(0);
     });
   });
+
+  describe('custom documents endpoints', () => {
+    const JSON_CUSTOM_URL = '/apidoc-json';
+    const YAML_CUSTOM_URL = '/apidoc-yaml';
+
+    beforeEach(async () => {
+      const swaggerDocument = SwaggerModule.createDocument(
+        app,
+        builder.build()
+      );
+      SwaggerModule.setup('api', app, swaggerDocument, {
+        jsonDocumentUrl: JSON_CUSTOM_URL,
+        yamlDocumentUrl: YAML_CUSTOM_URL
+      });
+
+      await app.init();
+    });
+
+    afterEach(async () => {
+      await app.close();
+    });
+
+    it('json document should be server in the custom url', async () => {
+      const response = await request(app.getHttpServer()).get(JSON_CUSTOM_URL);
+
+      expect(response.status).toEqual(200);
+      expect(Object.keys(response.body).length).toBeGreaterThan(0);
+    });
+
+    it('yaml document should be server in the custom url', async () => {
+      const response = await request(app.getHttpServer()).get(YAML_CUSTOM_URL);
+
+      expect(response.status).toEqual(200);
+      expect(response.text.length).toBeGreaterThan(0);
+    });
+  });
+
+  describe('custom documents endpoints with global prefix', () => {
+    let appGlobalPrefix: NestExpressApplication;
+
+    const GLOBAL_PREFIX = '/v1';
+    const JSON_CUSTOM_URL = '/apidoc-json';
+    const YAML_CUSTOM_URL = '/apidoc-yaml';
+
+    beforeEach(async () => {
+      appGlobalPrefix = await NestFactory.create<NestExpressApplication>(
+        ApplicationModule,
+        new ExpressAdapter(),
+        { logger: false }
+      );
+      appGlobalPrefix.setGlobalPrefix(GLOBAL_PREFIX);
+
+      const swaggerDocument = SwaggerModule.createDocument(
+        appGlobalPrefix,
+        builder.build()
+      );
+      SwaggerModule.setup('api', appGlobalPrefix, swaggerDocument, {
+        useGlobalPrefix: true,
+        jsonDocumentUrl: JSON_CUSTOM_URL,
+        yamlDocumentUrl: YAML_CUSTOM_URL
+      });
+
+      await appGlobalPrefix.init();
+    });
+
+    afterEach(async () => {
+      await appGlobalPrefix.close();
+    });
+
+    it('json document should be server in the custom url', async () => {
+      const response = await request(appGlobalPrefix.getHttpServer()).get(
+        `${GLOBAL_PREFIX}${JSON_CUSTOM_URL}`
+      );
+
+      expect(response.status).toEqual(200);
+      expect(Object.keys(response.body).length).toBeGreaterThan(0);
+    });
+
+    it('yaml document should be server in the custom url', async () => {
+      const response = await request(appGlobalPrefix.getHttpServer()).get(
+        `${GLOBAL_PREFIX}${YAML_CUSTOM_URL}`
+      );
+
+      expect(response.status).toEqual(200);
+      expect(response.text.length).toBeGreaterThan(0);
+    });
+  });
 });
diff --git a/e2e/fastify.e2e-spec.ts b/e2e/fastify.e2e-spec.ts
index 4c7380bc..53aed39b 100644
--- a/e2e/fastify.e2e-spec.ts
+++ b/e2e/fastify.e2e-spec.ts
@@ -99,4 +99,93 @@ describe('Fastify Swagger', () => {
       expect(Object.keys(response.body).length).toBeGreaterThan(0);
     });
   });
+
+  describe('custom documents endpoints', () => {
+    const JSON_CUSTOM_URL = '/apidoc-json';
+    const YAML_CUSTOM_URL = '/apidoc-yaml';
+
+    beforeEach(async () => {
+      const swaggerDocument = SwaggerModule.createDocument(
+        app,
+        builder.build()
+      );
+      SwaggerModule.setup('api', app, swaggerDocument, {
+        jsonDocumentUrl: JSON_CUSTOM_URL,
+        yamlDocumentUrl: YAML_CUSTOM_URL
+      });
+
+      await app.init();
+      await app.getHttpAdapter().getInstance().ready();
+    });
+
+    afterEach(async () => {
+      await app.close();
+    });
+
+    it('json document should be server in the custom url', async () => {
+      const response = await request(app.getHttpServer()).get(JSON_CUSTOM_URL);
+
+      expect(response.status).toEqual(200);
+      expect(Object.keys(response.body).length).toBeGreaterThan(0);
+    });
+
+    it('yaml document should be server in the custom url', async () => {
+      const response = await request(app.getHttpServer()).get(YAML_CUSTOM_URL);
+
+      expect(response.status).toEqual(200);
+      expect(response.text.length).toBeGreaterThan(0);
+    });
+  });
+
+  describe('custom documents endpoints with global prefix', () => {
+    let appGlobalPrefix: NestFastifyApplication;
+
+    const GLOBAL_PREFIX = '/v1';
+    const JSON_CUSTOM_URL = '/apidoc-json';
+    const YAML_CUSTOM_URL = '/apidoc-yaml';
+
+    beforeEach(async () => {
+      appGlobalPrefix = await NestFactory.create<NestFastifyApplication>(
+        ApplicationModule,
+        new FastifyAdapter(),
+        { logger: false }
+      );
+      appGlobalPrefix.setGlobalPrefix(GLOBAL_PREFIX);
+
+      const swaggerDocument = SwaggerModule.createDocument(
+        appGlobalPrefix,
+        builder.build()
+      );
+      SwaggerModule.setup('api', appGlobalPrefix, swaggerDocument, {
+        useGlobalPrefix: true,
+        jsonDocumentUrl: JSON_CUSTOM_URL,
+        yamlDocumentUrl: YAML_CUSTOM_URL
+      });
+
+      await appGlobalPrefix.init();
+      await appGlobalPrefix.getHttpAdapter().getInstance().ready();
+    });
+
+    afterEach(async () => {
+      await appGlobalPrefix.close();
+    });
+
+    it('json document should be server in the custom url', async () => {
+      const response = await request(appGlobalPrefix.getHttpServer()).get(
+        `${GLOBAL_PREFIX}${JSON_CUSTOM_URL}`
+      );
+
+      expect(response.status).toEqual(200);
+      expect(Object.keys(response.body).length).toBeGreaterThan(0);
+    });
+
+    it('yaml document should be server in the custom url', async () => {
+      const response = await request(appGlobalPrefix.getHttpServer()).get(
+        `${GLOBAL_PREFIX}${YAML_CUSTOM_URL}`
+      );
+
+      expect(response.status).toEqual(200);
+      expect(response.text.length).toBeGreaterThan(0);
+    });
+  });
 });
diff --git a/test/type-helpers/intersection-type.helper.spec.ts b/test/type-helpers/intersection-type.helper.spec.ts
index ce94d030..e8f18df5 100644
--- a/test/type-helpers/intersection-type.helper.spec.ts
+++ b/test/type-helpers/intersection-type.helper.spec.ts
@@ -42,7 +42,11 @@ describe('IntersectionType', () => {
     }
   }
 
-  class UpdateUserDto extends IntersectionType(UserDto, CreateUserDto, AuthorityDto) {}
+  class UpdateUserDto extends IntersectionType(
+    UserDto,
+    CreateUserDto,
+    AuthorityDto
+  ) {}
 
   let modelPropertiesAccessor: ModelPropertiesAccessor;
 
@@ -52,7 +56,7 @@ describe('IntersectionType', () => {
 
   describe('OpenAPI metadata', () => {
     it('should return combined class', () => {
-      const prototype = (UpdateUserDto.prototype as any) as Type<unknown>;
+      const prototype = UpdateUserDto.prototype as any as Type<unknown>;
 
       modelPropertiesAccessor.applyMetadataFactory(prototype);
       expect(modelPropertiesAccessor.getModelProperties(prototype)).toEqual([