Skip to content

Commit

Permalink
feat: Add basic support for parameter properties
Browse files Browse the repository at this point in the history
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 - typestack/class-validator#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`

Fixes #2056
  • Loading branch information
devoto13 committed Nov 2, 2023
1 parent a4f93a1 commit e3151bc
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 10 deletions.
1 change: 1 addition & 0 deletions lib/plugin/merge-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface PluginOptions {
readonly?: boolean;
pathToSource?: string;
debug?: boolean;
parameterProperties?: boolean;
}

const defaultOptions: PluginOptions = {
Expand Down
84 changes: 74 additions & 10 deletions lib/plugin/visitors/model-class.visitor.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {},
Expand All @@ -278,7 +327,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
hostFilename,
options
),
...this.createDescriptionAndTsDocTagPropertyAssigments(
...this.createDescriptionAndTsDocTagPropertyAssignments(
factory,
node,
typeChecker,
Expand All @@ -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)
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -512,18 +567,24 @@ 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
) {
const key = 'default';
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;
}
Expand Down Expand Up @@ -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 = {},
Expand Down
49 changes: 49 additions & 0 deletions test/plugin/fixtures/parameter-property.dto.ts
Original file line number Diff line number Diff line change
@@ -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 } };
}
}
`;
35 changes: 35 additions & 0 deletions test/plugin/model-class-visitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});

0 comments on commit e3151bc

Please sign in to comment.