diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts index 1230acb86..b02386186 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 1ca3f4ea7..c6c7aa76a 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 = 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, 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, 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 = factory.createNodeArray(), options: PluginOptions = {}, diff --git a/test/plugin/fixtures/parameter-property.dto.ts b/test/plugin/fixtures/parameter-property.dto.ts new file mode 100644 index 000000000..3d8ef3f54 --- /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 af093b777..d3c1d338c 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); + }); });