Repository: nestjs/swagger
Pull request #2687 (2023-11-02 11:33:25)

feat: Add basic support for parameter properties
## 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. -->

Parameter properties are not supported.

Issue Number: #2056

## What is the new behavior?

Basic support for parameter properties is added.

## 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

The feature is controlled by the `parameterProperties` option, which is disabled by default. As the feature is opt-in, it should be possible to release it as a minor version.

Worth noting, that using parameter properties has some limitations compared to the regular properties, but it can still be useful for some basic scenarios.

1. Can't use validation annotations as it is not supported by the class-validator - https://github.com/typestack/class-validator/issues/1669.
2. Can't use JSDoc tags, e.g. `@example` or `@deprecated`. Parsing description from the comment is not implemented in this PR, but can potentially be added from the `@param` JSDoc tag.
3. Can't use `@ApiProperty` and `@ApiHideProperty` decorators. This can be supported by introducing a dedicated decorators which works on parameters, e.g. `@ApiParameterProperty` and `@ApiHideParameterProperty()` with the same signatures. It shouldn't be hard to add, but just wanted to hear if you're interested in adding this feature and collect feedback before I put more work into it.

Features which are supported and work the same way as for regular properties:

1. `required` and `nullable`
2. `type`
3. `enum`
4. `default`


Patch:
diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts
index 1230acb8..b0238618 100644
--- a/lib/plugin/merge-options.ts
+++ b/lib/plugin/merge-options.ts
@@ -10,6 +10,7 @@ export interface PluginOptions {
   readonly?: boolean;
   pathToSource?: string;
   debug?: boolean;
+  parameterProperties?: boolean;
 }
 
 const defaultOptions: PluginOptions = {
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index 1ca3f4ea..c6c7aa76 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -1,7 +1,7 @@
 import { compact, flatten, head } from 'lodash';
 import { posix } from 'path';
 import * as ts from 'typescript';
-import { PropertyAssignment, factory } from 'typescript';
+import { factory, PropertyAssignment } from 'typescript';
 import { ApiHideProperty } from '../../decorators';
 import { PluginOptions } from '../merge-options';
 import { METADATA_FACTORY_NAME } from '../plugin-constants';
@@ -78,6 +78,17 @@ export class ModelClassVisitor extends AbstractFileVisitor {
               sourceFile,
               metadata
             );
+          } else if (
+            options.parameterProperties &&
+            ts.isConstructorDeclaration(node)
+          ) {
+            this.visitConstructorDeclarationNode(
+              node,
+              typeChecker,
+              options,
+              sourceFile,
+              metadata
+            );
           }
           return node;
         };
@@ -176,6 +187,41 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     }
   }
 
+  visitConstructorDeclarationNode(
+    constructorNode: ts.ConstructorDeclaration,
+    typeChecker: ts.TypeChecker,
+    options: PluginOptions,
+    sourceFile: ts.SourceFile,
+    metadata: ClassMetadata
+  ) {
+    constructorNode.forEachChild((node) => {
+      if (
+        ts.isParameter(node) &&
+        node.modifiers != null &&
+        node.modifiers.some(
+          (modifier: ts.Modifier) =>
+            modifier.kind === ts.SyntaxKind.ReadonlyKeyword ||
+            modifier.kind === ts.SyntaxKind.PrivateKeyword ||
+            modifier.kind === ts.SyntaxKind.PublicKeyword ||
+            modifier.kind === ts.SyntaxKind.ProtectedKeyword
+        )
+      ) {
+        const objectLiteralExpr = this.createDecoratorObjectLiteralExpr(
+          factory,
+          node,
+          typeChecker,
+          factory.createNodeArray(),
+          options,
+          sourceFile.fileName,
+          sourceFile
+        );
+
+        const propertyName = node.name.getText();
+        metadata[propertyName] = objectLiteralExpr;
+      }
+    });
+  }
+
   addMetadataFactory(
     factory: ts.NodeFactory,
     node: ts.ClassDeclaration,
@@ -254,7 +300,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
 
   createDecoratorObjectLiteralExpr(
     factory: ts.NodeFactory,
-    node: ts.PropertyDeclaration | ts.PropertySignature,
+    node:
+      | ts.PropertyDeclaration
+      | ts.PropertySignature
+      | ts.ParameterDeclaration,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment> = factory.createNodeArray(),
     options: PluginOptions = {},
@@ -278,7 +327,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         hostFilename,
         options
       ),
-      ...this.createDescriptionAndTsDocTagPropertyAssigments(
+      ...this.createDescriptionAndTsDocTagPropertyAssignments(
         factory,
         node,
         typeChecker,
@@ -301,7 +350,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         options
       )
     ];
-    if (options.classValidatorShim) {
+    if (
+      (ts.isPropertyDeclaration(node) || ts.isPropertySignature(node)) &&
+      options.classValidatorShim
+    ) {
       properties.push(
         this.createValidationPropertyAssignments(factory, node, options)
       );
@@ -445,7 +497,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
 
   createEnumPropertyAssignment(
     factory: ts.NodeFactory,
-    node: ts.PropertyDeclaration | ts.PropertySignature,
+    node:
+      | ts.PropertyDeclaration
+      | ts.PropertySignature
+      | ts.ParameterDeclaration,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     hostFilename: string,
@@ -512,7 +567,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
 
   createDefaultPropertyAssignment(
     factory: ts.NodeFactory,
-    node: ts.PropertyDeclaration | ts.PropertySignature,
+    node:
+      | ts.PropertyDeclaration
+      | ts.PropertySignature
+      | ts.ParameterDeclaration,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
     options: PluginOptions
   ) {
@@ -520,10 +578,13 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     if (hasPropertyKey(key, existingProperties)) {
       return undefined;
     }
-    let initializer = (node as ts.PropertyDeclaration).initializer;
-    if (!initializer) {
+    if (ts.isPropertySignature(node)) {
       return undefined;
     }
+    if (node.initializer == null) {
+      return undefined;
+    }
+    let initializer = node.initializer;
     if (ts.isAsExpression(initializer)) {
       initializer = initializer.expression;
     }
@@ -745,9 +806,12 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     metadata[propertyName] = objectLiteral;
   }
 
-  createDescriptionAndTsDocTagPropertyAssigments(
+  createDescriptionAndTsDocTagPropertyAssignments(
     factory: ts.NodeFactory,
-    node: ts.PropertyDeclaration | ts.PropertySignature,
+    node:
+      | ts.PropertyDeclaration
+      | ts.PropertySignature
+      | ts.ParameterDeclaration,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment> = factory.createNodeArray(),
     options: PluginOptions = {},


Test patch:
diff --git a/test/plugin/fixtures/parameter-property.dto.ts b/test/plugin/fixtures/parameter-property.dto.ts
new file mode 100644
index 00000000..3d8ef3f5
--- /dev/null
+++ b/test/plugin/fixtures/parameter-property.dto.ts
@@ -0,0 +1,49 @@
+export const parameterPropertyDtoText = `
+export class ParameterPropertyDto {
+  constructor(
+    readonly readonlyValue?: string, 
+    private privateValue: string | null, 
+    public publicValue: ItemDto[], 
+    regularParameter: string
+    protected protectedValue: string = '1234', 
+) {}
+}
+
+export enum LettersEnum {
+  A = 'A',
+  B = 'B',
+  C = 'C'
+}
+
+export class ItemDto {
+  constructor(readonly enumValue: LettersEnum) {}
+}
+`;
+
+export const parameterPropertyDtoTextTranspiled = `import * as openapi from "@nestjs/swagger";
+export class ParameterPropertyDto {
+    constructor(readonlyValue, privateValue, publicValue, regularParameter, protectedValue = '1234') {
+        this.readonlyValue = readonlyValue;
+        this.privateValue = privateValue;
+        this.publicValue = publicValue;
+        this.protectedValue = protectedValue;
+    }
+    static _OPENAPI_METADATA_FACTORY() {
+        return { readonlyValue: { required: false, type: () => String }, privateValue: { required: true, type: () => String, nullable: true }, publicValue: { required: true, type: () => [require("./parameter-property.dto").ItemDto] }, protectedValue: { required: true, type: () => String, default: "1234" } };
+    }
+}
+export var LettersEnum;
+(function (LettersEnum) {
+    LettersEnum["A"] = "A";
+    LettersEnum["B"] = "B";
+    LettersEnum["C"] = "C";
+})(LettersEnum || (LettersEnum = {}));
+export class ItemDto {
+    constructor(enumValue) {
+        this.enumValue = enumValue;
+    }
+    static _OPENAPI_METADATA_FACTORY() {
+        return { enumValue: { required: true, enum: require("./parameter-property.dto").LettersEnum } };
+    }
+}
+`;
diff --git a/test/plugin/model-class-visitor.spec.ts b/test/plugin/model-class-visitor.spec.ts
index af093b77..d3c1d338 100644
--- a/test/plugin/model-class-visitor.spec.ts
+++ b/test/plugin/model-class-visitor.spec.ts
@@ -30,6 +30,10 @@ import {
   stringLiteralDtoText,
   stringLiteralDtoTextTranspiled
 } from './fixtures/string-literal.dto';
+import {
+  parameterPropertyDtoText,
+  parameterPropertyDtoTextTranspiled
+} from './fixtures/parameter-property.dto';
 
 describe('API model properties', () => {
   it('should add the metadata factory when no decorators exist, and generated propertyKey is title', () => {
@@ -240,4 +244,35 @@ describe('API model properties', () => {
     });
     expect(result.outputText).toEqual(stringLiteralDtoTextTranspiled);
   });
+
+  it('should support & understand parameter properties', () => {
+    const options: ts.CompilerOptions = {
+      module: ts.ModuleKind.ES2020,
+      target: ts.ScriptTarget.ES2020,
+      newLine: ts.NewLineKind.LineFeed,
+      noEmitHelpers: true,
+      experimentalDecorators: true,
+      strict: true
+    };
+    const filename = 'parameter-property.dto.ts';
+    const fakeProgram = ts.createProgram([filename], options);
+
+    const result = ts.transpileModule(parameterPropertyDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            {
+              introspectComments: true,
+              classValidatorShim: true,
+              parameterProperties: true
+            },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(result.outputText).toEqual(parameterPropertyDtoTextTranspiled);
+  });
 });