Repository: nestjs/swagger
Pull request #2427 (2023-05-12 22:47:47)

Rebases #983 (Add decorator to allow specification of the schema type name #983)
See #983 and #1596 

TL;DR; cherry-picked the commits onto the latest master branch to resolve conflicts in the PR. Original author info should be maintained on the commits.


Patch:
diff --git a/lib/constants.ts b/lib/constants.ts
index 158de362..166abc7f 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -13,5 +13,6 @@ export const DECORATORS = {
   API_EXCLUDE_ENDPOINT: `${DECORATORS_PREFIX}/apiExcludeEndpoint`,
   API_EXCLUDE_CONTROLLER: `${DECORATORS_PREFIX}/apiExcludeController`,
   API_EXTRA_MODELS: `${DECORATORS_PREFIX}/apiExtraModels`,
-  API_EXTENSION: `${DECORATORS_PREFIX}/apiExtension`
+  API_EXTENSION: `${DECORATORS_PREFIX}/apiExtension`,
+  API_SCHEMA: `${DECORATORS_PREFIX}/apiSchema`
 };
diff --git a/lib/decorators/api-schema.decorator.ts b/lib/decorators/api-schema.decorator.ts
new file mode 100644
index 00000000..98ea6a2b
--- /dev/null
+++ b/lib/decorators/api-schema.decorator.ts
@@ -0,0 +1,14 @@
+import { DECORATORS } from '../constants';
+import { SchemaObjectMetadata } from '../interfaces/schema-object-metadata.interface';
+import { createClassDecorator } from './helpers';
+
+export interface ApiSchemaOptions extends Pick<SchemaObjectMetadata, 'name'> {
+  /**
+   * Name of the schema.
+   */
+  name: string;
+}
+
+export function ApiSchema(options: ApiSchemaOptions): ClassDecorator {
+  return createClassDecorator(DECORATORS.API_SCHEMA, [options]);
+}
diff --git a/lib/decorators/index.ts b/lib/decorators/index.ts
index f507d072..7a2fe69e 100644
--- a/lib/decorators/index.ts
+++ b/lib/decorators/index.ts
@@ -23,3 +23,4 @@ export * from './api-response.decorator';
 export * from './api-security.decorator';
 export * from './api-use-tags.decorator';
 export * from './api-extension.decorator';
+export * from './api-schema.decorator';
diff --git a/lib/extra/swagger-shim.ts b/lib/extra/swagger-shim.ts
index b0f8b367..2d43b081 100644
--- a/lib/extra/swagger-shim.ts
+++ b/lib/extra/swagger-shim.ts
@@ -137,6 +137,9 @@ export function ApiUnsupportedMediaTypeResponse() {
 export function ApiDefaultResponse() {
   return () => {};
 }
+export function ApiSchema() {
+  return () => {};
+}
 export function ApiSecurity() {
   return () => {};
 }
diff --git a/lib/services/schema-object-factory.ts b/lib/services/schema-object-factory.ts
index 71ed88df..3d4e6d34 100644
--- a/lib/services/schema-object-factory.ts
+++ b/lib/services/schema-object-factory.ts
@@ -11,6 +11,7 @@ import {
   pick
 } from 'lodash';
 import { DECORATORS } from '../constants';
+import { ApiSchemaOptions } from '../decorators';
 import { getTypeIsArrayTuple } from '../decorators/helpers';
 import { exploreGlobalApiExtraModelsMetadata } from '../explorers/api-extra-models.explorer';
 import {
@@ -203,8 +204,16 @@ export class SchemaObjectFactory {
     if (typeDefinitionRequiredFields.length > 0) {
       typeDefinition['required'] = typeDefinitionRequiredFields;
     }
-    schemas[type.name] = typeDefinition;
-    return type.name;
+    const customSchema: ApiSchemaOptions[] = Reflect.getOwnMetadata(
+      DECORATORS.API_SCHEMA,
+      type
+    );
+    const schemaName =
+      customSchema && customSchema.length === 1
+        ? customSchema[0].name
+        : type.name;
+    schemas[schemaName] = typeDefinition;
+    return schemaName;
   }
 
   mergePropertyWithMetadata(


Test patch:
diff --git a/e2e/src/cats/dto/create-cat.dto.ts b/e2e/src/cats/dto/create-cat.dto.ts
index acc33a05..f00571d3 100644
--- a/e2e/src/cats/dto/create-cat.dto.ts
+++ b/e2e/src/cats/dto/create-cat.dto.ts
@@ -1,9 +1,9 @@
 import { ApiExtraModels, ApiProperty } from '../../../../lib';
-import { ExtraModel } from './extra-model.dto';
+import { ExtraModelDto } from './extra-model.dto';
 import { LettersEnum } from './pagination-query.dto';
 import { TagDto } from './tag.dto';
 
-@ApiExtraModels(ExtraModel)
+@ApiExtraModels(ExtraModelDto)
 export class CreateCatDto {
   @ApiProperty()
   readonly name: string;
diff --git a/e2e/src/cats/dto/extra-model.dto.ts b/e2e/src/cats/dto/extra-model.dto.ts
index 63edfa88..c57491ee 100644
--- a/e2e/src/cats/dto/extra-model.dto.ts
+++ b/e2e/src/cats/dto/extra-model.dto.ts
@@ -1,6 +1,9 @@
-import { ApiProperty } from '../../../../lib';
+import { ApiProperty, ApiSchema } from '../../../../lib';
 
-export class ExtraModel {
+@ApiSchema({
+  name: 'ExtraModel'
+})
+export class ExtraModelDto {
   @ApiProperty()
   readonly one: string;
 
diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts
index 84645560..dd5af7bc 100644
--- a/test/explorer/swagger-explorer.spec.ts
+++ b/test/explorer/swagger-explorer.spec.ts
@@ -26,7 +26,8 @@ import {
   ApiParam,
   ApiProduces,
   ApiProperty,
-  ApiQuery
+  ApiQuery,
+  ApiSchema
 } from '../../lib/decorators';
 import { DenormalizedDoc } from '../../lib/interfaces/denormalized-doc.interface';
 import { ResponseObject } from '../../lib/interfaces/open-api-spec.interface';
@@ -1466,12 +1467,10 @@ describe('SwaggerExplorer', () => {
       });
     });
   });
-  
-  describe('when @All(...) is used', () => {
 
+  describe('when @All(...) is used', () => {
     @Controller('')
     class AllController {
-
       @All('*')
       all(): Promise<void> {
         return Promise.resolve();
@@ -1491,13 +1490,161 @@ describe('SwaggerExplorer', () => {
       );
 
       expect(routes.length).toEqual(7);
-      expect(['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every((method) => routes.find((route) => route.root.method === method))).toBe(true);
-      expect(routes.find((route) => route.root.method === 'all')).toBe(undefined);
+      expect(
+        ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every(
+          (method) => routes.find((route) => route.root.method === method)
+        )
+      ).toBe(true);
+      expect(routes.find((route) => route.root.method === 'all')).toBe(
+        undefined
+      );
       // check if all routes are equal except for method
-      expect(routes.filter((v, i, a) => a.findIndex(v2 => ['path', 'parameter'].every(k => v2[k] === v[k])) === i).length).toEqual(1);
+      expect(
+        routes.filter(
+          (v, i, a) =>
+            a.findIndex((v2) =>
+              ['path', 'parameter'].every((k) => v2[k] === v[k])
+            ) === i
+        ).length
+      ).toEqual(1);
+    });
+  });
+
+  describe('when custom schema names are used', () => {
+    @ApiSchema({
+      name: 'Foo'
+    })
+    class FooDto {}
+
+    @ApiSchema({
+      name: 'CreateFoo'
+    })
+    class CreateFooDto {}
+
+    @Controller('')
+    class FooController {
+      @Post('foos')
+      @ApiBody({ type: CreateFooDto })
+      @ApiOperation({ summary: 'Create foo' })
+      @ApiCreatedResponse({
+        type: FooDto,
+        description: 'Newly created Foo object'
+      })
+      create(@Body() createFoo: CreateFooDto): Promise<FooDto> {
+        return Promise.resolve({});
+      }
+
+      @Get('foos/:objectId')
+      @ApiParam({ name: 'objectId', type: 'string' })
+      @ApiQuery({ name: 'page', type: 'string' })
+      @ApiOperation({ summary: 'List all Foos' })
+      @ApiOkResponse({ type: [FooDto] })
+      @ApiDefaultResponse({ type: [FooDto] })
+      find(
+        @Param('objectId') objectId: string,
+        @Query('page') q: string
+      ): Promise<FooDto[]> {
+        return Promise.resolve([]);
+      }
+    }
+
+    it('sees two controller operations and their responses', () => {
+      const explorer = new SwaggerExplorer(schemaObjectFactory);
+      const routes = explorer.exploreController(
+        {
+          instance: new FooController(),
+          metatype: FooController
+        } as InstanceWrapper<FooController>,
+        new ApplicationConfig(),
+        'path'
+      );
+
+      expect(routes.length).toEqual(2);
+
+      // POST
+      expect(routes[0].root.operationId).toEqual('FooController_create');
+      expect(routes[0].root.method).toEqual('post');
+      expect(routes[0].root.path).toEqual('/path/foos');
+      expect(routes[0].root.summary).toEqual('Create foo');
+      expect(routes[0].root.parameters.length).toEqual(0);
+      expect(routes[0].root.requestBody).toEqual({
+        required: true,
+        content: {
+          'application/json': {
+            schema: {
+              $ref: '#/components/schemas/CreateFoo'
+            }
+          }
+        }
+      });
+
+      expect(
+        (routes[0].responses['201'] as ResponseObject).description
+      ).toEqual('Newly created Foo object');
+      expect(
+        (routes[0].responses['201'] as ResponseObject).content[
+          'application/json'
+        ]
+      ).toEqual({
+        schema: {
+          $ref: '#/components/schemas/Foo'
+        }
+      });
+
+      // GET
+      expect(routes[1].root.operationId).toEqual('FooController_find');
+      expect(routes[1].root.method).toEqual('get');
+      expect(routes[1].root.path).toEqual('/path/foos/{objectId}');
+      expect(routes[1].root.summary).toEqual('List all Foos');
+      expect(routes[1].root.parameters.length).toEqual(2);
+      expect(routes[1].root.parameters).toEqual([
+        {
+          in: 'path',
+          name: 'objectId',
+          required: true,
+          schema: {
+            type: 'string'
+          }
+        },
+        {
+          in: 'query',
+          name: 'page',
+          required: true,
+          schema: {
+            type: 'string'
+          }
+        }
+      ]);
+      expect(
+        (routes[1].responses['200'] as ResponseObject).description
+      ).toEqual('');
+      expect(
+        (routes[1].responses['200'] as ResponseObject).content[
+          'application/json'
+        ]
+      ).toEqual({
+        schema: {
+          type: 'array',
+          items: {
+            $ref: '#/components/schemas/Foo'
+          }
+        }
+      });
+      expect(
+        (routes[1].responses.default as ResponseObject).content[
+          'application/json'
+        ]
+      ).toEqual({
+        schema: {
+          type: 'array',
+          items: {
+            $ref: '#/components/schemas/Foo'
+          }
+        }
+      });
     });
   });
-  
+
   describe('when global paramters are defined', () => {
     class Foo {}
 
diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts
index 091127d5..0f34dd34 100644
--- a/test/services/schema-object-factory.spec.ts
+++ b/test/services/schema-object-factory.spec.ts
@@ -1,4 +1,4 @@
-import { ApiProperty } from '../../lib/decorators';
+import { ApiProperty, ApiSchema } from '../../lib/decorators';
 import { SchemasObject } from '../../lib/interfaces/open-api-spec.interface';
 import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor';
 import { SchemaObjectFactory } from '../../lib/services/schema-object-factory';
@@ -265,5 +265,33 @@ describe('SchemaObjectFactory', () => {
         properties: { name: { type: 'string', minLength: 1 } }
       });
     });
+
+    it('should use schema name instead of class name', () => {
+      @ApiSchema({
+        name: 'CreateUser'
+      })
+      class CreateUserDto {}
+
+      const schemas: Record<string, SchemasObject> = {};
+
+      schemaObjectFactory.exploreModelSchema(CreateUserDto, schemas);
+
+      expect(Object.keys(schemas)).toContain('CreateUser');
+    });
+
+    it('should not use schema name of base class', () => {
+      @ApiSchema({
+        name: 'CreateUser'
+      })
+      class CreateUserDto {}
+
+      class UpdateUserDto extends CreateUserDto {}
+
+      const schemas: Record<string, SchemasObject> = {};
+
+      schemaObjectFactory.exploreModelSchema(UpdateUserDto, schemas);
+
+      expect(Object.keys(schemas)).toContain('UpdateUserDto');
+    });
   });
 });