Repository: nestjs/swagger
Pull request #3123 (2024-10-24 13:01:18)

feat: introduce enum schema attribute
## 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?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

Issue Number: #2898 #3101

## What is the new behavior?

Instead of auto-populating field attributes to the enum schema, allow field-specific attributes and introduce the `enumSchema` property to allow setting Enum's default, deprecated, description, and other attributes.

## Does this PR introduce a breaking change?
- [ ] Yes
- [ ] 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/decorators/api-param.decorator.ts b/lib/decorators/api-param.decorator.ts
index 9188538f..af7310cd 100644
--- a/lib/decorators/api-param.decorator.ts
+++ b/lib/decorators/api-param.decorator.ts
@@ -1,5 +1,6 @@
 import { Type } from '@nestjs/common';
 import { isNil, omit } from 'lodash';
+import { EnumSchemaAttributes } from '../interfaces/enum-schema-attributes.interface';
 import {
   ParameterObject,
   SchemaObject
@@ -10,13 +11,21 @@ import { createParamDecorator } from './helpers';
 
 type ParameterOptions = Omit<ParameterObject, 'in' | 'schema'>;
 
-interface ApiParamMetadata extends ParameterOptions {
+interface ApiParamCommonMetadata extends ParameterOptions {
   type?: Type<unknown> | Function | [Function] | string;
   format?: string;
   enum?: SwaggerEnumType;
   enumName?: string;
+  enumSchema?: EnumSchemaAttributes;
 }
 
+type ApiParamMetadata =
+  | ApiParamCommonMetadata
+  | (ApiParamCommonMetadata & {
+      enumName: string;
+      enumSchema?: EnumSchemaAttributes;
+    });
+
 interface ApiParamSchemaHost extends ParameterOptions {
   schema: SchemaObject;
 }
diff --git a/lib/decorators/api-property.decorator.ts b/lib/decorators/api-property.decorator.ts
index c60af8a7..3832d5b7 100644
--- a/lib/decorators/api-property.decorator.ts
+++ b/lib/decorators/api-property.decorator.ts
@@ -1,14 +1,14 @@
 import { Type } from '@nestjs/common';
 import { DECORATORS } from '../constants';
+import { EnumSchemaAttributes } from '../interfaces/enum-schema-attributes.interface';
 import { SchemaObjectMetadata } from '../interfaces/schema-object-metadata.interface';
 import { getEnumType, getEnumValues } from '../utils/enum.utils';
 import { createPropertyDecorator, getTypeIsArrayTuple } from './helpers';
 
-export interface ApiPropertyOptions
+export interface ApiPropertyCommonOptions
   extends Omit<SchemaObjectMetadata, 'name' | 'enum'> {
   name?: string;
   enum?: any[] | Record<string, any> | (() => any[] | Record<string, any>);
-  enumName?: string;
   'x-enumNames'?: string[];
   /**
    * Lazy function returning the type for which the decorated property
@@ -22,6 +22,13 @@ export interface ApiPropertyOptions
   link?: () => Type<unknown> | Function;
 }
 
+export type ApiPropertyOptions =
+  | ApiPropertyCommonOptions
+  | (ApiPropertyCommonOptions & {
+      enumName: string;
+      enumSchema?: EnumSchemaAttributes;
+    });
+
 const isEnumArray = (obj: ApiPropertyOptions): boolean =>
   obj.isArray && !!obj.enum;
 
diff --git a/lib/decorators/api-query.decorator.ts b/lib/decorators/api-query.decorator.ts
index 60c23585..86c1fa4a 100644
--- a/lib/decorators/api-query.decorator.ts
+++ b/lib/decorators/api-query.decorator.ts
@@ -1,5 +1,6 @@
 import { Type } from '@nestjs/common';
 import { isNil, omit } from 'lodash';
+import { EnumSchemaAttributes } from '../interfaces/enum-schema-attributes.interface';
 import {
   ParameterObject,
   ReferenceObject,
@@ -16,14 +17,20 @@ import { createParamDecorator, getTypeIsArrayTuple } from './helpers';
 
 type ParameterOptions = Omit<ParameterObject, 'in' | 'schema' | 'name'>;
 
-interface ApiQueryMetadata extends ParameterOptions {
+interface ApiQueryCommonMetadata extends ParameterOptions {
   name?: string;
   type?: Type<unknown> | Function | [Function] | string;
   isArray?: boolean;
   enum?: SwaggerEnumType;
-  enumName?: string;
 }
 
+export type ApiQueryMetadata =
+  | ApiQueryCommonMetadata
+  | ({
+      enumName: string;
+      enumSchema?: EnumSchemaAttributes;
+    } & ApiQueryCommonMetadata);
+
 interface ApiQuerySchemaHost extends ParameterOptions {
   name?: string;
   schema: SchemaObject | ReferenceObject;
diff --git a/lib/interfaces/enum-schema-attributes.interface.ts b/lib/interfaces/enum-schema-attributes.interface.ts
new file mode 100644
index 00000000..b7132ed4
--- /dev/null
+++ b/lib/interfaces/enum-schema-attributes.interface.ts
@@ -0,0 +1,11 @@
+import { SchemaObject } from './open-api-spec.interface';
+
+export type EnumSchemaAttributes = Pick<
+  SchemaObject,
+  | 'default'
+  | 'description'
+  | 'deprecated'
+  | 'readOnly'
+  | 'writeOnly'
+  | 'nullable'
+>;
diff --git a/lib/interfaces/schema-object-metadata.interface.ts b/lib/interfaces/schema-object-metadata.interface.ts
index f61f13ae..71cbb6a9 100644
--- a/lib/interfaces/schema-object-metadata.interface.ts
+++ b/lib/interfaces/schema-object-metadata.interface.ts
@@ -1,11 +1,18 @@
 import { Type } from '@nestjs/common';
+import { EnumSchemaAttributes } from './enum-schema-attributes.interface';
 import { SchemaObject } from './open-api-spec.interface';
 
-export interface SchemaObjectMetadata
+interface SchemaObjectCommonMetadata
   extends Omit<SchemaObject, 'type' | 'required'> {
   type?: Type<unknown> | Function | [Function] | string | Record<string, any>;
   isArray?: boolean;
   required?: boolean;
   name?: string;
-  enumName?: string;
 }
+
+export type SchemaObjectMetadata =
+  | SchemaObjectCommonMetadata
+  | ({
+      enumName: string;
+      enumSchema?: EnumSchemaAttributes;
+    } & SchemaObjectCommonMetadata);
diff --git a/lib/services/parameter-metadata-accessor.ts b/lib/services/parameter-metadata-accessor.ts
index fcbf8fca..ac778fc7 100644
--- a/lib/services/parameter-metadata-accessor.ts
+++ b/lib/services/parameter-metadata-accessor.ts
@@ -5,7 +5,11 @@ import {
 } from '@nestjs/common/constants';
 import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum';
 import { isEmpty, mapValues, omitBy } from 'lodash';
-import { ParameterLocation, SchemaObject } from '../interfaces/open-api-spec.interface';
+import { EnumSchemaAttributes } from '../interfaces/enum-schema-attributes.interface';
+import {
+  ParameterLocation,
+  SchemaObject
+} from '../interfaces/open-api-spec.interface';
 import { reverseObjectKeys } from '../utils/reverse-object-keys.util';
 
 interface ParamMetadata {
@@ -23,6 +27,7 @@ export interface ParamWithTypeMetadata {
   required: true;
   enum?: unknown[];
   enumName?: string;
+  enumSchema?: EnumSchemaAttributes;
 }
 export type ParamsWithType = Record<string, ParamWithTypeMetadata>;
 
diff --git a/lib/services/schema-object-factory.ts b/lib/services/schema-object-factory.ts
index b2088315..814418b7 100644
--- a/lib/services/schema-object-factory.ts
+++ b/lib/services/schema-object-factory.ts
@@ -208,7 +208,13 @@ export class SchemaObjectFactory {
     const typeDefinition: SchemaObject = {
       type: 'object',
       properties: mapValues(keyBy(propertiesWithType, 'name'), (property) =>
-        omit(property, ['name', 'isArray', 'required', 'enumName'])
+        omit(property, [
+          'name',
+          'isArray',
+          'required',
+          'enumName',
+          'enumSchema'
+        ])
       ) as Record<string, SchemaObject | ReferenceObject>,
       ...extensionProperties
     };
@@ -296,8 +302,18 @@ export class SchemaObjectFactory {
             ? param.schema?.['items']?.['type']
             : param.schema?.['type']) ?? 'string',
         enum: _enum,
+        ...param.enumSchema,
         ...(param['x-enumNames'] ? { 'x-enumNames': param['x-enumNames'] } : {})
       };
+    } else {
+      // Enum type is already defined so grab it and
+      // assign additional metadata if specified
+      if (param.enumSchema) {
+        schemas[enumName] = {
+          ...schemas[enumName],
+          ...param.enumSchema
+        };
+      }
     }
 
     param.schema =
@@ -305,7 +321,14 @@ export class SchemaObjectFactory {
         ? { type: 'array', items: { $ref } }
         : { $ref };
 
-    return omit(param, ['isArray', 'items', 'enumName', 'enum', 'x-enumNames']);
+    return omit(param, [
+      'isArray',
+      'items',
+      'enumName',
+      'enum',
+      'x-enumNames',
+      'enumSchema'
+    ]);
   }
 
   createEnumSchemaType(
@@ -313,36 +336,36 @@ export class SchemaObjectFactory {
     metadata: SchemaObjectMetadata,
     schemas: Record<string, SchemaObject>
   ) {
-    if (!metadata.enumName) {
+    if (!('enumName' in metadata) || !metadata.enumName) {
       return {
         ...metadata,
         name: metadata.name || key
       };
     }
+
     const enumName = metadata.enumName;
     const $ref = getSchemaPath(enumName);
 
-    // Allow given fields to be part of the referenced enum schema
-    const additionalParams = ['description', 'deprecated', 'default'];
-    const additionalFields = additionalParams.reduce(
-      (acc, param) => ({
-        ...acc,
-        ...(metadata[param] && { [param]: metadata[param] })
-      }),
-      {}
-    );
-
     const enumType: string =
       (metadata.isArray ? metadata.items['type'] : metadata.type) ?? 'string';
 
-    schemas[enumName] = {
-      type: enumType,
-      ...additionalFields,
-      enum:
-        metadata.isArray && metadata.items
-          ? metadata.items['enum']
-          : metadata.enum
-    };
+    if (!schemas[enumName]) {
+      schemas[enumName] = {
+        type: enumType,
+        ...metadata.enumSchema,
+        enum:
+          metadata.isArray && metadata.items
+            ? metadata.items['enum']
+            : metadata.enum
+      };
+    } else {
+      if (metadata.enumSchema) {
+        schemas[enumName] = {
+          ...schemas[enumName],
+          ...metadata.enumSchema
+        };
+      }
+    }
 
     const _schemaObject = {
       ...metadata,
@@ -350,9 +373,11 @@ export class SchemaObjectFactory {
       type: metadata.isArray ? 'array' : 'string'
     };
 
-    const refHost = metadata.isArray ? { items: { $ref } } : { $ref };
+    const refHost = metadata.isArray
+      ? { items: { $ref } }
+      : { allOf: [{ $ref }] };
     const paramObject = { ..._schemaObject, ...refHost };
-    const pathsToOmit = ['enum', 'enumName', ...additionalParams];
+    const pathsToOmit = ['enum', 'enumName', 'enumSchema'];
 
     if (!metadata.isArray) {
       pathsToOmit.push('type');
diff --git a/lib/services/swagger-types-mapper.ts b/lib/services/swagger-types-mapper.ts
index 0ccb3969..73d21d84 100644
--- a/lib/services/swagger-types-mapper.ts
+++ b/lib/services/swagger-types-mapper.ts
@@ -7,12 +7,19 @@ import {
 } from '../interfaces/open-api-spec.interface';
 import { ParamWithTypeMetadata } from './parameter-metadata-accessor';
 
+type KeysToRemove =
+  | keyof ApiPropertyOptions
+  | '$ref'
+  | 'enumName'
+  | 'enumSchema';
+
 export class SwaggerTypesMapper {
-  private readonly keysToRemove: Array<keyof ApiPropertyOptions | '$ref'> = [
+  private readonly keysToRemove: Array<KeysToRemove> = [
     'type',
     'isArray',
     'enum',
     'enumName',
+    'enumSchema',
     'items',
     '$ref',
     ...this.getSchemaOptionsKeys()
@@ -73,10 +80,7 @@ export class SwaggerTypesMapper {
     return (type as string).charAt(0).toLowerCase() + (type as string).slice(1);
   }
 
-  mapEnumArrayType(
-    param: Record<string, any>,
-    keysToRemove: Array<keyof ApiPropertyOptions | '$ref'>
-  ) {
+  mapEnumArrayType(param: Record<string, any>, keysToRemove: KeysToRemove[]) {
     return {
       ...omit(param, keysToRemove),
       schema: {
@@ -89,7 +93,7 @@ export class SwaggerTypesMapper {
 
   mapArrayType(
     param: (ParamWithTypeMetadata & SchemaObject) | BaseParameterObject,
-    keysToRemove: Array<keyof ApiPropertyOptions | '$ref'>
+    keysToRemove: KeysToRemove[]
   ) {
     const itemsModifierKeys = ['format', 'maximum', 'minimum', 'pattern'];
     const items =
diff --git a/lib/utils/enum.utils.ts b/lib/utils/enum.utils.ts
index d92e5308..635c7bb4 100644
--- a/lib/utils/enum.utils.ts
+++ b/lib/utils/enum.utils.ts
@@ -43,8 +43,10 @@ export function getEnumType(values: (string | number)[]): 'string' | 'number' {
 }
 
 export function addEnumArraySchema(
-  paramDefinition: Partial<Record<'schema' | 'isArray' | 'enumName', any>>,
-  decoratorOptions: Partial<Record<'enum' | 'enumName', any>>
+  paramDefinition: Partial<
+    Record<'schema' | 'isArray' | 'enumName' | 'enumSchema', any>
+  >,
+  decoratorOptions: Partial<Record<'enum' | 'enumName' | 'enumSchema', any>>
 ) {
   const paramSchema: SchemaObject = paramDefinition.schema || {};
   paramDefinition.schema = paramSchema;
@@ -60,6 +62,10 @@ export function addEnumArraySchema(
   if (decoratorOptions.enumName) {
     paramDefinition.enumName = decoratorOptions.enumName;
   }
+
+  if (decoratorOptions.enumSchema) {
+    paramDefinition.enumSchema = decoratorOptions.enumSchema;
+  }
 }
 
 export function addEnumSchema(
@@ -76,6 +82,10 @@ export function addEnumSchema(
   if (decoratorOptions.enumName) {
     paramDefinition.enumName = decoratorOptions.enumName;
   }
+
+  if (decoratorOptions.enumSchema) {
+    paramDefinition.enumSchema = decoratorOptions.enumSchema;
+  }
 }
 
 export const isEnumArray = <T extends Partial<Record<'isArray' | 'enum', any>>>(


Test patch:
diff --git a/e2e/api-spec.json b/e2e/api-spec.json
index e1575646..51f38f6d 100644
--- a/e2e/api-spec.json
+++ b/e2e/api-spec.json
@@ -947,6 +947,8 @@
             "name": "type",
             "required": true,
             "in": "path",
+            "description": "This is a description for 'type' query parameter",
+            "deprecated": false,
             "schema": {
               "$ref": "#/components/schemas/Letter"
             }
@@ -1103,14 +1105,13 @@
       },
       "LettersEnum": {
         "type": "string",
-        "description": "A small assortment of letters?",
-        "deprecated": true,
-        "default": "A",
         "enum": [
           "A",
           "B",
           "C"
-        ]
+        ],
+        "description": "This is a description for the LettersEnum schema",
+        "deprecated": true
       },
       "TagDto": {
         "type": "object",
@@ -1177,16 +1178,28 @@
             "description": "Enum with description"
           },
           "enum": {
-            "$ref": "#/components/schemas/LettersEnum"
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/LettersEnum"
+              }
+            ]
           },
           "enumArr": {
             "type": "array",
+            "description": "This is a description for the enumArr attribute",
             "items": {
               "$ref": "#/components/schemas/LettersEnum"
             }
           },
           "enumWithRef": {
-            "$ref": "#/components/schemas/LettersEnum"
+            "description": "A small assortment of letters (in DTO)?",
+            "default": "A",
+            "deprecated": true,
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/LettersEnum"
+              }
+            ]
           },
           "tag": {
             "description": "tag",
@@ -1286,7 +1299,14 @@
             }
           },
           "enumWithRef": {
-            "$ref": "#/components/schemas/LettersEnum"
+            "description": "A small assortment of letters?",
+            "default": "A",
+            "deprecated": true,
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/LettersEnum"
+              }
+            ]
           },
           "oneOfExample": {
             "oneOf": [
@@ -1339,7 +1359,9 @@
           "A",
           "B",
           "C"
-        ]
+        ],
+        "description": "This is a description for the Letters schema",
+        "deprecated": true
       },
       "PaginationQuery": {
         "type": "object",
@@ -1370,7 +1392,11 @@
             "type": "number"
           },
           "enum": {
-            "$ref": "#/components/schemas/LettersEnum"
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/LettersEnum"
+              }
+            ]
           },
           "enumArr": {
             "type": "array",
diff --git a/e2e/src/cats/cats.controller.ts b/e2e/src/cats/cats.controller.ts
index 78d0f855..c5ef36d0 100644
--- a/e2e/src/cats/cats.controller.ts
+++ b/e2e/src/cats/cats.controller.ts
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
 import {
   ApiBearerAuth,
   ApiBody,
+  ApiCallbacks,
   ApiConsumes,
   ApiDefaultGetter,
   ApiExtension,
@@ -11,9 +12,7 @@ import {
   ApiQuery,
   ApiResponse,
   ApiSecurity,
-  ApiTags,
-  ApiCallbacks,
-  getSchemaPath
+  ApiTags
 } from '../../../lib';
 import { CatsService } from './cats.service';
 import { Cat } from './classes/cat.class';
@@ -148,7 +147,13 @@ export class CatsController {
   @ApiParam({
     name: 'type',
     enum: LettersEnum,
-    enumName: 'Letter'
+    enumName: 'Letter',
+    enumSchema: {
+      description: 'This is a description for the Letters schema',
+      deprecated: true
+    },
+    description: "This is a description for 'type' query parameter",
+    deprecated: false
   })
   getWithEnumNamedParam(@Param('type') type: LettersEnum) {}
 
diff --git a/e2e/src/cats/classes/cat.class.ts b/e2e/src/cats/classes/cat.class.ts
index 6663a940..f4d416fb 100644
--- a/e2e/src/cats/classes/cat.class.ts
+++ b/e2e/src/cats/classes/cat.class.ts
@@ -52,7 +52,7 @@ export class Cat {
     enum: LettersEnum,
     isArray: true
   })
-  enumArr: LettersEnum;
+  enumArr: LettersEnum[];
 
   @ApiProperty({
     enum: LettersEnum,
diff --git a/e2e/src/cats/dto/create-cat.dto.ts b/e2e/src/cats/dto/create-cat.dto.ts
index fac5d7cf..d888a34d 100644
--- a/e2e/src/cats/dto/create-cat.dto.ts
+++ b/e2e/src/cats/dto/create-cat.dto.ts
@@ -57,16 +57,21 @@ export class CreateCatDto {
   @ApiProperty({
     enum: LettersEnum,
     enumName: 'LettersEnum',
-    isArray: true
+    isArray: true,
+    description: 'This is a description for the enumArr attribute'
   })
-  readonly enumArr: LettersEnum;
+  readonly enumArr: LettersEnum[];
 
   @ApiProperty({
     enum: LettersEnum,
     enumName: 'LettersEnum',
-    description: 'A small assortment of letters?',
+    description: 'A small assortment of letters (in DTO)?',
     default: 'A',
-    deprecated: true
+    deprecated: true,
+    enumSchema: {
+      description: 'This is a description for the LettersEnum schema',
+      deprecated: true
+    }
   })
   readonly enumWithRef: LettersEnum;
 
diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts
index 2b2ba664..fa4087d0 100644
--- a/test/explorer/swagger-explorer.spec.ts
+++ b/test/explorer/swagger-explorer.spec.ts
@@ -84,16 +84,26 @@ describe('SwaggerExplorer', () => {
 
       @ApiProperty({
         enum: LettersEnum,
-        enumName: 'LettersEnum'
+        enumName: 'LettersEnum',
+        enumSchema: {
+          description: 'This is a description for the LettersEnum schema',
+          deprecated: true
+        },
+        description: "This is a description for 'enum' property",
+        deprecated: false,
+        default: LettersEnum.B
       })
       enum: LettersEnum;
 
       @ApiProperty({
         enum: LettersEnum,
         enumName: 'LettersEnum',
+        description: "This is a description for 'enumArr' property",
+        deprecated: false,
+        default: [LettersEnum.A],
         isArray: true
       })
-      enumArr: LettersEnum;
+      enumArr: LettersEnum[];
 
       @ApiProperty({
         enum: () => LettersEnum,
@@ -342,6 +352,8 @@ describe('SwaggerExplorer', () => {
           in: 'query',
           name: 'enum',
           required: true,
+          deprecated: false,
+          description: "This is a description for 'enum' property",
           schema: {
             $ref: '#/components/schemas/LettersEnum'
           }
@@ -350,6 +362,8 @@ describe('SwaggerExplorer', () => {
           in: 'query',
           name: 'enumArr',
           required: true,
+          deprecated: false,
+          description: "This is a description for 'enumArr' property",
           schema: {
             items: {
               $ref: '#/components/schemas/LettersEnum'
diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts
index f23c9258..5e527610 100644
--- a/test/services/schema-object-factory.spec.ts
+++ b/test/services/schema-object-factory.spec.ts
@@ -107,7 +107,11 @@ describe('SchemaObjectFactory', () => {
         type: 'object',
         properties: {
           role: {
-            $ref: '#/components/schemas/Role'
+            allOf: [
+              {
+                $ref: '#/components/schemas/Role'
+              }
+            ]
           },
           roles: {
             type: 'array',
@@ -134,7 +138,11 @@ describe('SchemaObjectFactory', () => {
             type: 'array'
           },
           hairColour: {
-            $ref: '#/components/schemas/HairColour'
+            allOf: [
+              {
+                $ref: '#/components/schemas/HairColour'
+              }
+            ]
           }
         },
         required: [
@@ -157,7 +165,11 @@ describe('SchemaObjectFactory', () => {
             type: 'string'
           },
           role: {
-            $ref: '#/components/schemas/Role'
+            allOf: [
+              {
+                $ref: '#/components/schemas/Role'
+              }
+            ]
           }
         },
         required: ['name', 'role']