Repository: nestjs/swagger
Pull request #2777 (2024-01-15 11:03:27)

feat(plugin) add support for @Expose() and @Exclude() decorators
## 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?
The Swagger plugin will automatically annotate all DTO properties with @ApiProperty unless @ApiHideProperty is used.

Issue Number: [#2603](https://github.com/nestjs/swagger/issues/2603)

## What is the new behavior?
This PR adds support to include or hide DTO properties from the generated OpenAPI definitions via the existing class-transformer decorators. 

The new `classTransformerShim` option can be set to one of the following: 
- **false**: No changes to the current behavior
- **true**: Any property decorated **with** `@Exclude()` will hidden from the the generated definition; same behavior as `@ApiHideProperty()`
- **'exclusive'**: Only properties that **are** decorated with `@Expose()` or `@ApiProperty` will be included in the generated definition

**NOTE:** default is  `classTransformerShim: false`

## 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
Small unrelated change to `test/plugin/readonly-visitor.spec.ts` to help with test failing due to end-of-line characters differences between host machine and PluginMetadataPrinter output.


Patch:
diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts
index b0238618..bf63ff26 100644
--- a/lib/plugin/merge-options.ts
+++ b/lib/plugin/merge-options.ts
@@ -4,6 +4,7 @@ export interface PluginOptions {
   dtoFileNameSuffix?: string | string[];
   controllerFileNameSuffix?: string | string[];
   classValidatorShim?: boolean;
+  classTransformerShim?: boolean | 'exclusive';
   dtoKeyOfComment?: string;
   controllerKeyOfComment?: string;
   introspectComments?: boolean;
@@ -17,6 +18,7 @@ const defaultOptions: PluginOptions = {
   dtoFileNameSuffix: ['.dto.ts', '.entity.ts'],
   controllerFileNameSuffix: ['.controller.ts'],
   classValidatorShim: true,
+  classTransformerShim: false,
   dtoKeyOfComment: 'description',
   controllerKeyOfComment: 'description',
   introspectComments: false,
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index d1399ea0..a422d788 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -2,7 +2,7 @@ import { compact, flatten, head } from 'lodash';
 import { posix } from 'path';
 import * as ts from 'typescript';
 import { factory, PropertyAssignment } from 'typescript';
-import { ApiHideProperty } from '../../decorators';
+import { ApiHideProperty, ApiProperty } from '../../decorators';
 import { PluginOptions } from '../merge-options';
 import { METADATA_FACTORY_NAME } from '../plugin-constants';
 import { pluginDebugLogger } from '../plugin-debug-logger';
@@ -155,23 +155,43 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     sourceFile: ts.SourceFile,
     metadata: ClassMetadata
   ) {
+    const isPropertyStatic = (node.modifiers || []).some(
+      (modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
+    );
+    if (isPropertyStatic) {
+      return node;
+    }
+
     const decorators = ts.canHaveDecorators(node) && ts.getDecorators(node);
 
-    const hidePropertyDecorator = getDecoratorOrUndefinedByNames(
-      [ApiHideProperty.name],
+    const classTransformerShim = options.classTransformerShim;
+
+    const hidePropertyDecoratorExists = getDecoratorOrUndefinedByNames(
+      classTransformerShim
+        ? [ApiHideProperty.name, 'Exclude']
+        : [ApiHideProperty.name],
       decorators,
       factory
     );
-    if (hidePropertyDecorator) {
-      return node;
-    }
 
-    const isPropertyStatic = (node.modifiers || []).some(
-      (modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
+    const annotatePropertyDecoratorExists = getDecoratorOrUndefinedByNames(
+      classTransformerShim ? [ApiProperty.name, 'Expose'] : [ApiProperty.name],
+      decorators,
+      factory
     );
-    if (isPropertyStatic) {
+
+    if (
+      !annotatePropertyDecoratorExists &&
+      (hidePropertyDecoratorExists || classTransformerShim === 'exclusive')
+    ) {
+      return node;
+    } else if (annotatePropertyDecoratorExists && hidePropertyDecoratorExists) {
+      pluginDebugLogger.debug(
+        `"${node.parent.name.getText()}->${node.name.getText()}" has conflicting decorators, excluding as @ApiHideProperty() takes priority.`
+      );
       return node;
     }
+
     try {
       this.inspectPropertyDeclaration(
         ctx.factory,
@@ -695,7 +715,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
           return result;
         }
 
-        const clonedMinLength = this.clonePrimitiveLiteral(factory, minLength) ?? minLength;
+        const clonedMinLength =
+          this.clonePrimitiveLiteral(factory, minLength) ?? minLength;
         if (clonedMinLength) {
           result.push(
             factory.createPropertyAssignment('minLength', clonedMinLength)
@@ -707,10 +728,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
           if (!canReferenceNode(maxLength, options)) {
             return result;
           }
-          const clonedMaxLength = this.clonePrimitiveLiteral(
-            factory,
-            maxLength
-          ) ?? maxLength;
+          const clonedMaxLength =
+            this.clonePrimitiveLiteral(factory, maxLength) ?? maxLength;
           if (clonedMaxLength) {
             result.push(
               factory.createPropertyAssignment('maxLength', clonedMaxLength)


Test patch:
diff --git a/test/plugin/fixtures/create-cat-exclude.dto.ts b/test/plugin/fixtures/create-cat-exclude.dto.ts
new file mode 100644
index 00000000..1e2dce52
--- /dev/null
+++ b/test/plugin/fixtures/create-cat-exclude.dto.ts
@@ -0,0 +1,187 @@
+export const createCatExcludeDtoText = `
+import { IsInt, IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+
+enum Status {
+    ENABLED,
+    DISABLED
+}
+
+enum OneValueEnum {
+    ONE
+}
+
+interface Node {
+    id: number;
+}
+
+class OtherNode {
+    id: number;
+}
+
+export class CreateCatDto {
+  @IsIn(['a', 'b'])
+  isIn: string;
+  @Matches(/^[+]?abc$/)
+  pattern: string;
+  name: string;
+  @Min(0)
+  @Max(10)
+  age: number = 3;
+  @IsPositive()
+  positive: number = 5;
+  @IsNegative()
+  negative: number = -1;
+  @Length(2)
+  lengthMin: string;
+  @Length(3, 5)
+  lengthMinMax: string;
+  tags: string[];
+  status: Status = Status.ENABLED;
+  status2?: Status;
+  statusArr?: Status[];
+  oneValueEnum?: OneValueEnum;
+  oneValueEnumArr?: OneValueEnum[];
+
+  /** this is breed im comment */
+  @ApiProperty({ description: "this is breed", type: String })
+  @IsString()
+  readonly breed?: string;
+
+  nodes: Node[];
+  optionalBoolean?: boolean;
+  date: Date;
+  
+  twoDimensionPrimitives: string[][];
+  twoDimensionNodes: OtherNode[][];
+
+  @ApiHideProperty()
+  hidden: number;
+
+  @Exclude()
+  excluded: number;
+
+  static staticProperty: string;
+}
+`;
+
+export const createCatExcludeDtoTextTranspiled = `import * as openapi from "@nestjs/swagger";
+import { IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+var Status;
+(function (Status) {
+    Status[Status[\"ENABLED\"] = 0] = \"ENABLED\";
+    Status[Status[\"DISABLED\"] = 1] = \"DISABLED\";
+})(Status || (Status = {}));
+var OneValueEnum;
+(function (OneValueEnum) {
+    OneValueEnum[OneValueEnum[\"ONE\"] = 0] = \"ONE\";
+})(OneValueEnum || (OneValueEnum = {}));
+class OtherNode {
+    static _OPENAPI_METADATA_FACTORY() {
+        return { id: { required: true, type: () => Number } };
+    }
+}
+export class CreateCatDto {
+    constructor() {
+        this.age = 3;
+        this.positive = 5;
+        this.negative = -1;
+        this.status = Status.ENABLED;
+    }
+    static _OPENAPI_METADATA_FACTORY() {
+        return { isIn: { required: true, type: () => String, enum: ['a', 'b'] }, pattern: { required: true, type: () => String, pattern: "/^[+]?abc$/" }, name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3, minimum: 0, maximum: 10 }, positive: { required: true, type: () => Number, default: 5, minimum: 1 }, negative: { required: true, type: () => Number, default: -1, maximum: -1 }, lengthMin: { required: true, type: () => String, minLength: 2 }, lengthMinMax: { required: true, type: () => String, minLength: 3, maxLength: 5 }, tags: { required: true, type: () => [String] }, status: { required: true, default: Status.ENABLED, enum: Status }, status2: { required: false, enum: Status }, statusArr: { required: false, enum: Status, isArray: true }, oneValueEnum: { required: false, enum: OneValueEnum }, oneValueEnumArr: { required: false, enum: OneValueEnum }, breed: { required: false, type: () => String, title: "this is breed im comment" }, nodes: { required: true, type: () => [Object] }, optionalBoolean: { required: false, type: () => Boolean }, date: { required: true, type: () => Date }, twoDimensionPrimitives: { required: true, type: () => [[String]] }, twoDimensionNodes: { required: true, type: () => [[OtherNode]] } };
+    }
+}
+__decorate([
+    IsIn(['a', 'b'])
+], CreateCatDto.prototype, \"isIn\", void 0);
+__decorate([
+    Matches(/^[+]?abc$/)
+], CreateCatDto.prototype, \"pattern\", void 0);
+__decorate([
+    Min(0),
+    Max(10)
+], CreateCatDto.prototype, \"age\", void 0);
+__decorate([
+    IsPositive()
+], CreateCatDto.prototype, \"positive\", void 0);
+__decorate([
+    IsNegative()
+], CreateCatDto.prototype, \"negative\", void 0);
+__decorate([
+    Length(2)
+], CreateCatDto.prototype, \"lengthMin\", void 0);
+__decorate([
+    Length(3, 5)
+], CreateCatDto.prototype, \"lengthMinMax\", void 0);
+__decorate([
+    ApiProperty({ description: "this is breed", type: String }),
+    IsString()
+], CreateCatDto.prototype, \"breed\", void 0);
+__decorate([
+    ApiHideProperty()
+], CreateCatDto.prototype, \"hidden\", void 0);
+__decorate([
+    Exclude()
+], CreateCatDto.prototype, "excluded", void 0);
+`;
+
+export const createCatIgnoreExcludeDtoTextTranspiled = `import * as openapi from "@nestjs/swagger";
+import { IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+var Status;
+(function (Status) {
+    Status[Status[\"ENABLED\"] = 0] = \"ENABLED\";
+    Status[Status[\"DISABLED\"] = 1] = \"DISABLED\";
+})(Status || (Status = {}));
+var OneValueEnum;
+(function (OneValueEnum) {
+    OneValueEnum[OneValueEnum[\"ONE\"] = 0] = \"ONE\";
+})(OneValueEnum || (OneValueEnum = {}));
+class OtherNode {
+    static _OPENAPI_METADATA_FACTORY() {
+        return { id: { required: true, type: () => Number } };
+    }
+}
+export class CreateCatDto {
+    constructor() {
+        this.age = 3;
+        this.positive = 5;
+        this.negative = -1;
+        this.status = Status.ENABLED;
+    }
+    static _OPENAPI_METADATA_FACTORY() {
+        return { isIn: { required: true, type: () => String, enum: ['a', 'b'] }, pattern: { required: true, type: () => String, pattern: "/^[+]?abc$/" }, name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3, minimum: 0, maximum: 10 }, positive: { required: true, type: () => Number, default: 5, minimum: 1 }, negative: { required: true, type: () => Number, default: -1, maximum: -1 }, lengthMin: { required: true, type: () => String, minLength: 2 }, lengthMinMax: { required: true, type: () => String, minLength: 3, maxLength: 5 }, tags: { required: true, type: () => [String] }, status: { required: true, default: Status.ENABLED, enum: Status }, status2: { required: false, enum: Status }, statusArr: { required: false, enum: Status, isArray: true }, oneValueEnum: { required: false, enum: OneValueEnum }, oneValueEnumArr: { required: false, enum: OneValueEnum }, breed: { required: false, type: () => String, title: "this is breed im comment" }, nodes: { required: true, type: () => [Object] }, optionalBoolean: { required: false, type: () => Boolean }, date: { required: true, type: () => Date }, twoDimensionPrimitives: { required: true, type: () => [[String]] }, twoDimensionNodes: { required: true, type: () => [[OtherNode]] }, excluded: { required: true, type: () => Number } };
+    }
+}
+__decorate([
+    IsIn(['a', 'b'])
+], CreateCatDto.prototype, \"isIn\", void 0);
+__decorate([
+    Matches(/^[+]?abc$/)
+], CreateCatDto.prototype, \"pattern\", void 0);
+__decorate([
+    Min(0),
+    Max(10)
+], CreateCatDto.prototype, \"age\", void 0);
+__decorate([
+    IsPositive()
+], CreateCatDto.prototype, \"positive\", void 0);
+__decorate([
+    IsNegative()
+], CreateCatDto.prototype, \"negative\", void 0);
+__decorate([
+    Length(2)
+], CreateCatDto.prototype, \"lengthMin\", void 0);
+__decorate([
+    Length(3, 5)
+], CreateCatDto.prototype, \"lengthMinMax\", void 0);
+__decorate([
+    ApiProperty({ description: "this is breed", type: String }),
+    IsString()
+], CreateCatDto.prototype, \"breed\", void 0);
+__decorate([
+    ApiHideProperty()
+], CreateCatDto.prototype, \"hidden\", void 0);
+__decorate([
+    Exclude()
+], CreateCatDto.prototype, "excluded", void 0);
+`;
diff --git a/test/plugin/fixtures/create-cat-exclusive.dto.ts b/test/plugin/fixtures/create-cat-exclusive.dto.ts
new file mode 100644
index 00000000..b8a03a68
--- /dev/null
+++ b/test/plugin/fixtures/create-cat-exclusive.dto.ts
@@ -0,0 +1,132 @@
+export const createCatExclusiveDtoText = `
+import { IsInt, IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+
+enum Status {
+    ENABLED,
+    DISABLED
+}
+
+enum OneValueEnum {
+    ONE
+}
+
+interface Node {
+    id: number;
+}
+
+class OtherNode {
+    id: number;
+}
+
+export class CreateCatDto {
+  @IsIn(['a', 'b'])
+  isIn: string;
+  @Matches(/^[+]?abc$/)
+  pattern: string;
+  name: string;
+  @Min(0)
+  @Max(10)
+  age: number = 3;
+  @IsPositive()
+  positive: number = 5;
+  @IsNegative()
+  negative: number = -1;
+  @Length(2)
+  lengthMin: string;
+  @Length(3, 5)
+  lengthMinMax: string;
+  tags: string[];
+  status: Status = Status.ENABLED;
+  status2?: Status;
+  statusArr?: Status[];
+  oneValueEnum?: OneValueEnum;
+  oneValueEnumArr?: OneValueEnum[];
+
+  /** this is breed im comment */
+  @ApiProperty({ description: "this is breed", type: String })
+  @IsString()
+  readonly breed?: string;
+
+  nodes: Node[];
+  optionalBoolean?: boolean;
+  date: Date;
+  
+  twoDimensionPrimitives: string[][];
+  twoDimensionNodes: OtherNode[][];
+
+  @ApiHideProperty()
+  hidden: number;
+
+  @Exclude()
+  excluded: number;
+
+  @Expose()
+  exposed: number;
+
+  static staticProperty: string;
+}
+`;
+
+export const createCatExclusiveDtoTextTranspiled = `import * as openapi from "@nestjs/swagger";
+import { IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+var Status;
+(function (Status) {
+    Status[Status[\"ENABLED\"] = 0] = \"ENABLED\";
+    Status[Status[\"DISABLED\"] = 1] = \"DISABLED\";
+})(Status || (Status = {}));
+var OneValueEnum;
+(function (OneValueEnum) {
+    OneValueEnum[OneValueEnum[\"ONE\"] = 0] = \"ONE\";
+})(OneValueEnum || (OneValueEnum = {}));
+class OtherNode {
+    static _OPENAPI_METADATA_FACTORY() {
+        return {};
+    }
+}
+export class CreateCatDto {
+    constructor() {
+        this.age = 3;
+        this.positive = 5;
+        this.negative = -1;
+        this.status = Status.ENABLED;
+    }
+    static _OPENAPI_METADATA_FACTORY() {
+        return { breed: { required: false, type: () => String, title: "this is breed im comment" }, exposed: { required: true, type: () => Number } };
+    }
+}
+__decorate([
+    IsIn(['a', 'b'])
+], CreateCatDto.prototype, \"isIn\", void 0);
+__decorate([
+    Matches(/^[+]?abc$/)
+], CreateCatDto.prototype, \"pattern\", void 0);
+__decorate([
+    Min(0),
+    Max(10)
+], CreateCatDto.prototype, \"age\", void 0);
+__decorate([
+    IsPositive()
+], CreateCatDto.prototype, \"positive\", void 0);
+__decorate([
+    IsNegative()
+], CreateCatDto.prototype, \"negative\", void 0);
+__decorate([
+    Length(2)
+], CreateCatDto.prototype, \"lengthMin\", void 0);
+__decorate([
+    Length(3, 5)
+], CreateCatDto.prototype, \"lengthMinMax\", void 0);
+__decorate([
+    ApiProperty({ description: "this is breed", type: String }),
+    IsString()
+], CreateCatDto.prototype, \"breed\", void 0);
+__decorate([
+    ApiHideProperty()
+], CreateCatDto.prototype, \"hidden\", void 0);
+__decorate([
+    Exclude()
+], CreateCatDto.prototype, "excluded", void 0);
+__decorate([
+    Expose()
+], CreateCatDto.prototype, "exposed", void 0);
+`;
diff --git a/test/plugin/fixtures/create-cat-priority.dto.ts b/test/plugin/fixtures/create-cat-priority.dto.ts
new file mode 100644
index 00000000..9757b6c5
--- /dev/null
+++ b/test/plugin/fixtures/create-cat-priority.dto.ts
@@ -0,0 +1,128 @@
+export const createCatPriorityDtoText = `
+import { IsInt, IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+
+enum Status {
+    ENABLED,
+    DISABLED
+}
+
+enum OneValueEnum {
+    ONE
+}
+
+interface Node {
+    id: number;
+}
+
+class OtherNode {
+    id: number;
+}
+
+export class CreateCatDto {
+  @IsIn(['a', 'b'])
+  isIn: string;
+  @Matches(/^[+]?abc$/)
+  pattern: string;
+  name: string;
+  @Min(0)
+  @Max(10)
+  age: number = 3;
+  @IsPositive()
+  positive: number = 5;
+  @IsNegative()
+  negative: number = -1;
+  @Length(2)
+  lengthMin: string;
+  @Length(3, 5)
+  lengthMinMax: string;
+  tags: string[];
+  status: Status = Status.ENABLED;
+  status2?: Status;
+  statusArr?: Status[];
+  oneValueEnum?: OneValueEnum;
+  oneValueEnumArr?: OneValueEnum[];
+
+  /** this is breed im comment */
+  @ApiProperty({ description: "this is breed", type: String })
+  @IsString()
+  readonly breed?: string;
+
+  nodes: Node[];
+  optionalBoolean?: boolean;
+  date: Date;
+  
+  twoDimensionPrimitives: string[][];
+  twoDimensionNodes: OtherNode[][];
+
+  @Expose()
+  @ApiHideProperty()
+  hidden: number;
+
+  @Exclude()
+  excluded: number;
+
+  static staticProperty: string;
+}
+`;
+
+export const createCatPriorityDtoTextTranspiled = `import * as openapi from "@nestjs/swagger";
+import { IsString, IsPositive, IsNegative, Length, Matches, IsIn } from 'class-validator';
+var Status;
+(function (Status) {
+    Status[Status[\"ENABLED\"] = 0] = \"ENABLED\";
+    Status[Status[\"DISABLED\"] = 1] = \"DISABLED\";
+})(Status || (Status = {}));
+var OneValueEnum;
+(function (OneValueEnum) {
+    OneValueEnum[OneValueEnum[\"ONE\"] = 0] = \"ONE\";
+})(OneValueEnum || (OneValueEnum = {}));
+class OtherNode {
+    static _OPENAPI_METADATA_FACTORY() {
+        return { id: { required: true, type: () => Number } };
+    }
+}
+export class CreateCatDto {
+    constructor() {
+        this.age = 3;
+        this.positive = 5;
+        this.negative = -1;
+        this.status = Status.ENABLED;
+    }
+    static _OPENAPI_METADATA_FACTORY() {
+        return { isIn: { required: true, type: () => String, enum: ['a', 'b'] }, pattern: { required: true, type: () => String, pattern: "/^[+]?abc$/" }, name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3, minimum: 0, maximum: 10 }, positive: { required: true, type: () => Number, default: 5, minimum: 1 }, negative: { required: true, type: () => Number, default: -1, maximum: -1 }, lengthMin: { required: true, type: () => String, minLength: 2 }, lengthMinMax: { required: true, type: () => String, minLength: 3, maxLength: 5 }, tags: { required: true, type: () => [String] }, status: { required: true, default: Status.ENABLED, enum: Status }, status2: { required: false, enum: Status }, statusArr: { required: false, enum: Status, isArray: true }, oneValueEnum: { required: false, enum: OneValueEnum }, oneValueEnumArr: { required: false, enum: OneValueEnum }, breed: { required: false, type: () => String, title: "this is breed im comment" }, nodes: { required: true, type: () => [Object] }, optionalBoolean: { required: false, type: () => Boolean }, date: { required: true, type: () => Date }, twoDimensionPrimitives: { required: true, type: () => [[String]] }, twoDimensionNodes: { required: true, type: () => [[OtherNode]] } };
+    }
+}
+__decorate([
+    IsIn(['a', 'b'])
+], CreateCatDto.prototype, \"isIn\", void 0);
+__decorate([
+    Matches(/^[+]?abc$/)
+], CreateCatDto.prototype, \"pattern\", void 0);
+__decorate([
+    Min(0),
+    Max(10)
+], CreateCatDto.prototype, \"age\", void 0);
+__decorate([
+    IsPositive()
+], CreateCatDto.prototype, \"positive\", void 0);
+__decorate([
+    IsNegative()
+], CreateCatDto.prototype, \"negative\", void 0);
+__decorate([
+    Length(2)
+], CreateCatDto.prototype, \"lengthMin\", void 0);
+__decorate([
+    Length(3, 5)
+], CreateCatDto.prototype, \"lengthMinMax\", void 0);
+__decorate([
+    ApiProperty({ description: "this is breed", type: String }),
+    IsString()
+], CreateCatDto.prototype, \"breed\", void 0);
+__decorate([
+    Expose(),
+    ApiHideProperty()
+], CreateCatDto.prototype, \"hidden\", void 0);
+__decorate([
+    Exclude()
+], CreateCatDto.prototype, "excluded", void 0);
+`;
diff --git a/test/plugin/model-class-visitor.spec.ts b/test/plugin/model-class-visitor.spec.ts
index d3c1d338..9ea87c32 100644
--- a/test/plugin/model-class-visitor.spec.ts
+++ b/test/plugin/model-class-visitor.spec.ts
@@ -34,6 +34,20 @@ import {
   parameterPropertyDtoText,
   parameterPropertyDtoTextTranspiled
 } from './fixtures/parameter-property.dto';
+import {
+  createCatExcludeDtoText,
+  createCatExcludeDtoTextTranspiled,
+  createCatIgnoreExcludeDtoTextTranspiled
+} from './fixtures/create-cat-exclude.dto';
+import {
+  createCatExclusiveDtoText,
+  createCatExclusiveDtoTextTranspiled
+} from './fixtures/create-cat-exclusive.dto';
+import {
+  createCatPriorityDtoText,
+  createCatPriorityDtoTextTranspiled
+} from './fixtures/create-cat-priority.dto';
+import { pluginDebugLogger } from '../../lib/plugin/plugin-debug-logger';
 
 describe('API model properties', () => {
   it('should add the metadata factory when no decorators exist, and generated propertyKey is title', () => {
@@ -275,4 +289,138 @@ describe('API model properties', () => {
     });
     expect(result.outputText).toEqual(parameterPropertyDtoTextTranspiled);
   });
+
+  it('should ignore Exclude decorator', () => {
+    const options: ts.CompilerOptions = {
+      module: ts.ModuleKind.ES2020,
+      target: ts.ScriptTarget.ES2020,
+      newLine: ts.NewLineKind.LineFeed,
+      noEmitHelpers: true,
+      experimentalDecorators: true,
+      strict: true
+    };
+    const filename = 'create-cat-exclude.dto.ts';
+    const fakeProgram = ts.createProgram([filename], options);
+
+    const result = ts.transpileModule(createCatExcludeDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            {
+              classValidatorShim: true,
+              classTransformerShim: false,
+              dtoKeyOfComment: 'title',
+              introspectComments: true
+            },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(result.outputText).toEqual(createCatIgnoreExcludeDtoTextTranspiled);
+  });
+
+  it('should hide properties decorated with the Exclude decorator', () => {
+    const options: ts.CompilerOptions = {
+      module: ts.ModuleKind.ES2020,
+      target: ts.ScriptTarget.ES2020,
+      newLine: ts.NewLineKind.LineFeed,
+      noEmitHelpers: true,
+      experimentalDecorators: true,
+      strict: true
+    };
+    const filename = 'create-cat-exclude.dto.ts';
+    const fakeProgram = ts.createProgram([filename], options);
+
+    const result = ts.transpileModule(createCatExcludeDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            {
+              classValidatorShim: true,
+              classTransformerShim: true,
+              dtoKeyOfComment: 'title',
+              introspectComments: true
+            },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(result.outputText).toEqual(createCatExcludeDtoTextTranspiled);
+  });
+
+  it('should hide a property with conflicting decorators', () => {
+    const options: ts.CompilerOptions = {
+      module: ts.ModuleKind.ES2020,
+      target: ts.ScriptTarget.ES2020,
+      newLine: ts.NewLineKind.LineFeed,
+      noEmitHelpers: true,
+      experimentalDecorators: true,
+      strict: true
+    };
+    const filename = 'create-cat-priority.dto.ts';
+    const fakeProgram = ts.createProgram([filename], options);
+
+    const debugLoggerSpy = jest.spyOn(pluginDebugLogger, 'debug');
+
+    const result = ts.transpileModule(createCatPriorityDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            {
+              classValidatorShim: true,
+              classTransformerShim: true,
+              dtoKeyOfComment: 'title',
+              introspectComments: true,
+              debug: true
+            },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(result.outputText).toEqual(createCatPriorityDtoTextTranspiled);
+    expect(debugLoggerSpy).toHaveBeenCalledWith(
+      '"CreateCatDto->hidden" has conflicting decorators, excluding as @ApiHideProperty() takes priority.'
+    );
+  });
+
+  it('should add the metadata factory only when decorators exist', () => {
+    const options: ts.CompilerOptions = {
+      module: ts.ModuleKind.ES2020,
+      target: ts.ScriptTarget.ES2020,
+      newLine: ts.NewLineKind.LineFeed,
+      noEmitHelpers: true,
+      experimentalDecorators: true,
+      strict: true
+    };
+    const filename = 'create-cat-exclusive.dto.ts';
+    const fakeProgram = ts.createProgram([filename], options);
+
+    const result = ts.transpileModule(createCatExclusiveDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            {
+              classValidatorShim: true,
+              classTransformerShim: 'exclusive',
+              dtoKeyOfComment: 'title',
+              introspectComments: true
+            },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(result.outputText).toEqual(createCatExclusiveDtoTextTranspiled);
+  });
 });
diff --git a/test/plugin/readonly-visitor.spec.ts b/test/plugin/readonly-visitor.spec.ts
index daa14e99..1331989c 100644
--- a/test/plugin/readonly-visitor.spec.ts
+++ b/test/plugin/readonly-visitor.spec.ts
@@ -50,7 +50,10 @@ describe('Readonly visitor', () => {
     const expectedOutput = readFileSync(
       join(__dirname, 'fixtures', 'serialized-meta.fixture.ts'),
       'utf-8'
-    );
+    )
+      .replace(/\r\n/g, '\n')
+      .replace(/\r/g, '\n');
+    /** Normalize the file line endings to LF */
 
     // writeFileSync(
     //   join(__dirname, 'fixtures', 'serialized-meta.fixture.ts'),