@dismissible/nestjs-validation 0.0.2-canary.5daf195.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -0
- package/jest.config.ts +27 -0
- package/package.json +51 -0
- package/project.json +34 -0
- package/src/index.ts +4 -0
- package/src/transform-boolean.decorator.spec.ts +102 -0
- package/src/transform-boolean.decorator.ts +29 -0
- package/src/transform-comma-separated.decorator.spec.ts +40 -0
- package/src/transform-comma-separated.decorator.ts +16 -0
- package/src/validation.module.ts +8 -0
- package/src/validation.service.spec.ts +170 -0
- package/src/validation.service.ts +55 -0
- package/tsconfig.json +16 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.spec.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @dismissible/nestjs-validation
|
|
2
|
+
|
|
3
|
+
A validation service for NestJS applications using class-validator and class-transformer.
|
|
4
|
+
|
|
5
|
+
> **Part of the Dismissible API** - This library is part of the [Dismissible API](https://dismissible.io) ecosystem. Visit [dismissible.io](https://dismissible.io) for more information and documentation.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
This library provides a `ValidationService` that wraps `class-validator` and `class-transformer` to provide a consistent validation API for DTOs and class instances in NestJS applications.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @dismissible/nestjs-validation
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
You'll also need to install the peer dependencies:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install class-validator class-transformer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Getting Started
|
|
24
|
+
|
|
25
|
+
### Basic Setup
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Module } from '@nestjs/common';
|
|
29
|
+
import { ValidationModule } from '@dismissible/nestjs-validation';
|
|
30
|
+
|
|
31
|
+
@Module({
|
|
32
|
+
imports: [ValidationModule],
|
|
33
|
+
})
|
|
34
|
+
export class AppModule {}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Validating DTOs
|
|
38
|
+
|
|
39
|
+
The `ValidationService` can validate plain objects against DTO classes:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { Injectable } from '@nestjs/common';
|
|
43
|
+
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
44
|
+
import { IsString, IsEmail, IsOptional } from 'class-validator';
|
|
45
|
+
|
|
46
|
+
class CreateUserDto {
|
|
47
|
+
@IsString()
|
|
48
|
+
name!: string;
|
|
49
|
+
|
|
50
|
+
@IsEmail()
|
|
51
|
+
email!: string;
|
|
52
|
+
|
|
53
|
+
@IsOptional()
|
|
54
|
+
@IsString()
|
|
55
|
+
phone?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Injectable()
|
|
59
|
+
export class UserService {
|
|
60
|
+
constructor(private readonly validationService: ValidationService) {}
|
|
61
|
+
|
|
62
|
+
async createUser(data: unknown) {
|
|
63
|
+
// Validates and transforms the data, throws BadRequestException if invalid
|
|
64
|
+
const dto = await this.validationService.validateDto(CreateUserDto, data);
|
|
65
|
+
|
|
66
|
+
// dto is now a validated instance of CreateUserDto
|
|
67
|
+
// Use dto.name, dto.email, etc.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Validating Existing Instances
|
|
73
|
+
|
|
74
|
+
You can also validate class instances that have already been created:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { Injectable } from '@nestjs/common';
|
|
78
|
+
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
79
|
+
import { IsString, IsDate } from 'class-validator';
|
|
80
|
+
|
|
81
|
+
class MyDto {
|
|
82
|
+
@IsString()
|
|
83
|
+
name!: string;
|
|
84
|
+
|
|
85
|
+
@IsDate()
|
|
86
|
+
createdAt!: Date;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Injectable()
|
|
90
|
+
export class MyService {
|
|
91
|
+
constructor(private readonly validationService: ValidationService) {}
|
|
92
|
+
|
|
93
|
+
async validateInstance(dto: MyDto) {
|
|
94
|
+
// Validates the instance, throws BadRequestException if invalid
|
|
95
|
+
await this.validationService.validateInstance(dto);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Error Handling
|
|
101
|
+
|
|
102
|
+
The validation service throws `BadRequestException` with formatted error messages when validation fails:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { BadRequestException } from '@nestjs/common';
|
|
106
|
+
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
107
|
+
|
|
108
|
+
@Injectable()
|
|
109
|
+
export class MyService {
|
|
110
|
+
constructor(private readonly validationService: ValidationService) {}
|
|
111
|
+
|
|
112
|
+
async handleRequest(data: unknown) {
|
|
113
|
+
try {
|
|
114
|
+
const dto = await this.validationService.validateDto(MyDto, data);
|
|
115
|
+
// Process valid data
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof BadRequestException) {
|
|
118
|
+
// Handle validation errors
|
|
119
|
+
console.error('Validation failed:', error.message);
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## API Reference
|
|
128
|
+
|
|
129
|
+
### ValidationService
|
|
130
|
+
|
|
131
|
+
#### `validateDto<T>(dtoClass, data): Promise<T>`
|
|
132
|
+
|
|
133
|
+
Validates and transforms plain data into a DTO instance.
|
|
134
|
+
|
|
135
|
+
**Parameters:**
|
|
136
|
+
|
|
137
|
+
- `dtoClass: ClassConstructor<T>` - The DTO class to validate against
|
|
138
|
+
- `data: unknown` - The data to validate
|
|
139
|
+
|
|
140
|
+
**Returns:** `Promise<T>` - A validated instance of the DTO class
|
|
141
|
+
|
|
142
|
+
**Throws:** `BadRequestException` if validation fails
|
|
143
|
+
|
|
144
|
+
#### `validateInstance<T>(instance): Promise<void>`
|
|
145
|
+
|
|
146
|
+
Validates an existing class instance.
|
|
147
|
+
|
|
148
|
+
**Parameters:**
|
|
149
|
+
|
|
150
|
+
- `instance: T` - The instance to validate
|
|
151
|
+
|
|
152
|
+
**Throws:** `BadRequestException` if validation fails
|
|
153
|
+
|
|
154
|
+
### ValidationModule
|
|
155
|
+
|
|
156
|
+
A NestJS module that provides `ValidationService` as a singleton.
|
|
157
|
+
|
|
158
|
+
**Exports:**
|
|
159
|
+
|
|
160
|
+
- `ValidationService` - The validation service
|
|
161
|
+
|
|
162
|
+
## Features
|
|
163
|
+
|
|
164
|
+
- Automatic transformation using `class-transformer`
|
|
165
|
+
- Validation using `class-validator` decorators
|
|
166
|
+
- Nested validation error extraction
|
|
167
|
+
- Formatted error messages
|
|
168
|
+
- Type-safe DTOs with TypeScript generics
|
|
169
|
+
|
|
170
|
+
## Example: Using in a Controller
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { Controller, Post, Body } from '@nestjs/common';
|
|
174
|
+
import { ValidationService } from '@dismissible/nestjs-validation';
|
|
175
|
+
import { IsString, IsEmail } from 'class-validator';
|
|
176
|
+
|
|
177
|
+
class CreateUserDto {
|
|
178
|
+
@IsString()
|
|
179
|
+
name!: string;
|
|
180
|
+
|
|
181
|
+
@IsEmail()
|
|
182
|
+
email!: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@Controller('users')
|
|
186
|
+
export class UserController {
|
|
187
|
+
constructor(private readonly validationService: ValidationService) {}
|
|
188
|
+
|
|
189
|
+
@Post()
|
|
190
|
+
async create(@Body() body: unknown) {
|
|
191
|
+
const dto = await this.validationService.validateDto(CreateUserDto, body);
|
|
192
|
+
// Use validated dto
|
|
193
|
+
return { message: `Creating user: ${dto.name}` };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Related Packages
|
|
199
|
+
|
|
200
|
+
This validation service is used by:
|
|
201
|
+
|
|
202
|
+
- `@dismissible/nestjs-dismissible` - Validates dismissible items
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
displayName: 'validation',
|
|
3
|
+
preset: '../../jest.preset.js',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
|
7
|
+
},
|
|
8
|
+
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
|
+
coverageDirectory: '../../coverage/libs/validation',
|
|
10
|
+
collectCoverageFrom: [
|
|
11
|
+
'src/**/*.ts',
|
|
12
|
+
'!src/**/*.spec.ts',
|
|
13
|
+
'!src/**/*.interface.ts',
|
|
14
|
+
'!src/**/*.dto.ts',
|
|
15
|
+
'!src/**/*.enum.ts',
|
|
16
|
+
'!src/**/index.ts',
|
|
17
|
+
'!src/**/*.module.ts',
|
|
18
|
+
],
|
|
19
|
+
coverageThreshold: {
|
|
20
|
+
global: {
|
|
21
|
+
branches: 80,
|
|
22
|
+
functions: 80,
|
|
23
|
+
lines: 80,
|
|
24
|
+
statements: 80,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dismissible/nestjs-validation",
|
|
3
|
+
"version": "0.0.2-canary.5daf195.0",
|
|
4
|
+
"description": "Validation service module for NestJS applications using class-validator and class-transformer",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"types": "./src/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.mjs",
|
|
10
|
+
"require": "./src/index.js",
|
|
11
|
+
"types": "./src/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@nestjs/common": "^11.0.0",
|
|
17
|
+
"class-validator": "^0.14.0",
|
|
18
|
+
"class-transformer": "^0.5.0",
|
|
19
|
+
"reflect-metadata": "^0.2.2"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"@nestjs/common": {
|
|
23
|
+
"optional": false
|
|
24
|
+
},
|
|
25
|
+
"class-validator": {
|
|
26
|
+
"optional": false
|
|
27
|
+
},
|
|
28
|
+
"class-transformer": {
|
|
29
|
+
"optional": false
|
|
30
|
+
},
|
|
31
|
+
"reflect-metadata": {
|
|
32
|
+
"optional": false
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"nestjs",
|
|
37
|
+
"validation",
|
|
38
|
+
"class-validator",
|
|
39
|
+
"class-transformer",
|
|
40
|
+
"dto"
|
|
41
|
+
],
|
|
42
|
+
"author": "",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/DismissibleIo/dismissible-api"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "validation",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "libs/validation/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"build": {
|
|
9
|
+
"executor": "@nx/js:tsc",
|
|
10
|
+
"outputs": ["{options.outputPath}"],
|
|
11
|
+
"options": {
|
|
12
|
+
"outputPath": "dist/libs/validation",
|
|
13
|
+
"main": "libs/validation/src/index.ts",
|
|
14
|
+
"tsConfig": "libs/validation/tsconfig.lib.json",
|
|
15
|
+
"assets": ["libs/validation/package.json", "libs/validation/README.md"],
|
|
16
|
+
"generatePackageJson": true
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"lint": {
|
|
20
|
+
"executor": "@nx/eslint:lint",
|
|
21
|
+
"outputs": ["{options.outputFile}"],
|
|
22
|
+
"options": {
|
|
23
|
+
"lintFilePatterns": ["libs/validation/**/*.ts"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"npm-publish": {
|
|
27
|
+
"executor": "nx:run-commands",
|
|
28
|
+
"options": {
|
|
29
|
+
"command": "npm publish --access public",
|
|
30
|
+
"cwd": "dist/libs/validation"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { IsBoolean } from 'class-validator';
|
|
3
|
+
import { plainToInstance } from 'class-transformer';
|
|
4
|
+
import { TransformBoolean } from './transform-boolean.decorator';
|
|
5
|
+
|
|
6
|
+
class TestConfig {
|
|
7
|
+
@IsBoolean()
|
|
8
|
+
@TransformBoolean()
|
|
9
|
+
value!: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class TestConfigWithDefault {
|
|
13
|
+
@IsBoolean()
|
|
14
|
+
@TransformBoolean(true)
|
|
15
|
+
value!: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('TransformBoolean', () => {
|
|
19
|
+
describe('without default value', () => {
|
|
20
|
+
it('should transform string "true" to boolean true', () => {
|
|
21
|
+
const config = plainToInstance(TestConfig, { value: 'true' });
|
|
22
|
+
expect(config.value).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should transform string "false" to boolean false', () => {
|
|
26
|
+
const config = plainToInstance(TestConfig, { value: 'false' });
|
|
27
|
+
expect(config.value).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should keep boolean true as true', () => {
|
|
31
|
+
const config = plainToInstance(TestConfig, { value: true });
|
|
32
|
+
expect(config.value).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should keep boolean false as false', () => {
|
|
36
|
+
const config = plainToInstance(TestConfig, { value: false });
|
|
37
|
+
expect(config.value).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should transform other string values to false', () => {
|
|
41
|
+
const config = plainToInstance(TestConfig, { value: 'yes' });
|
|
42
|
+
expect(config.value).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should handle case-insensitive "true"', () => {
|
|
46
|
+
const config = plainToInstance(TestConfig, { value: 'TRUE' });
|
|
47
|
+
expect(config.value).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle case-insensitive "True"', () => {
|
|
51
|
+
const config = plainToInstance(TestConfig, { value: 'True' });
|
|
52
|
+
expect(config.value).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should preserve undefined when no default provided', () => {
|
|
56
|
+
const config = plainToInstance(TestConfig, { value: undefined });
|
|
57
|
+
expect(config.value).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should preserve null when no default provided', () => {
|
|
61
|
+
const config = plainToInstance(TestConfig, { value: null });
|
|
62
|
+
expect(config.value).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should preserve other types when no default provided', () => {
|
|
66
|
+
const config = plainToInstance(TestConfig, { value: 123 });
|
|
67
|
+
expect(config.value).toBe(123);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('with default value', () => {
|
|
72
|
+
it('should use default value for undefined', () => {
|
|
73
|
+
const config = plainToInstance(TestConfigWithDefault, { value: undefined });
|
|
74
|
+
expect(config.value).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should use default value for null', () => {
|
|
78
|
+
const config = plainToInstance(TestConfigWithDefault, { value: null });
|
|
79
|
+
expect(config.value).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should use default value for other types', () => {
|
|
83
|
+
const config = plainToInstance(TestConfigWithDefault, { value: 123 });
|
|
84
|
+
expect(config.value).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should still transform string "true" to boolean true', () => {
|
|
88
|
+
const config = plainToInstance(TestConfigWithDefault, { value: 'true' });
|
|
89
|
+
expect(config.value).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should still transform string "false" to boolean false', () => {
|
|
93
|
+
const config = plainToInstance(TestConfigWithDefault, { value: 'false' });
|
|
94
|
+
expect(config.value).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should still keep boolean true as true', () => {
|
|
98
|
+
const config = plainToInstance(TestConfigWithDefault, { value: true });
|
|
99
|
+
expect(config.value).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Transform } from 'class-transformer';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transforms string values to boolean, preserving existing boolean values.
|
|
5
|
+
* Useful for environment variable configuration where boolean values may be passed as strings.
|
|
6
|
+
*
|
|
7
|
+
* @param defaultValue - Optional default value to return if the value is not a boolean or string.
|
|
8
|
+
* If not provided, the original value is returned unchanged.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* class Config {
|
|
13
|
+
* @IsBoolean()
|
|
14
|
+
* @TransformBoolean()
|
|
15
|
+
* enabled!: boolean;
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function TransformBoolean(defaultValue?: boolean): PropertyDecorator {
|
|
20
|
+
return Transform(({ value }) => {
|
|
21
|
+
if (typeof value === 'boolean') {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === 'string') {
|
|
25
|
+
return value.toLowerCase() === 'true';
|
|
26
|
+
}
|
|
27
|
+
return defaultValue !== undefined ? defaultValue : value;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { TransformCommaSeparated } from './transform-comma-separated.decorator';
|
|
4
|
+
|
|
5
|
+
class TestConfig {
|
|
6
|
+
@TransformCommaSeparated()
|
|
7
|
+
value!: string[] | string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('TransformCommaSeparated', () => {
|
|
11
|
+
it('should transform comma-separated string into array of trimmed strings', () => {
|
|
12
|
+
const config = plainToInstance(TestConfig, { value: 'GET,POST,DELETE' });
|
|
13
|
+
expect(config.value).toEqual(['GET', 'POST', 'DELETE']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should trim whitespace from each element', () => {
|
|
17
|
+
const config = plainToInstance(TestConfig, { value: 'a , b , c' });
|
|
18
|
+
expect(config.value).toEqual(['a', 'b', 'c']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should handle single value string', () => {
|
|
22
|
+
const config = plainToInstance(TestConfig, { value: 'GET' });
|
|
23
|
+
expect(config.value).toEqual(['GET']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should return array as-is when value is already an array', () => {
|
|
27
|
+
const config = plainToInstance(TestConfig, { value: ['a', 'b'] });
|
|
28
|
+
expect(config.value).toEqual(['a', 'b']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should handle empty string', () => {
|
|
32
|
+
const config = plainToInstance(TestConfig, { value: '' });
|
|
33
|
+
expect(config.value).toEqual(['']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should handle string with only whitespace', () => {
|
|
37
|
+
const config = plainToInstance(TestConfig, { value: ' ' });
|
|
38
|
+
expect(config.value).toEqual(['']);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Transform } from 'class-transformer';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transforms a comma-separated string into an array of trimmed strings.
|
|
5
|
+
* If the value is already an array, it is returned as-is.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Input: "GET,POST,DELETE" → Output: ["GET", "POST", "DELETE"]
|
|
9
|
+
* // Input: "a , b , c" → Output: ["a", "b", "c"]
|
|
10
|
+
* // Input: ["a", "b"] → Output: ["a", "b"]
|
|
11
|
+
*/
|
|
12
|
+
export function TransformCommaSeparated(): PropertyDecorator {
|
|
13
|
+
return Transform(({ value }) =>
|
|
14
|
+
typeof value === 'string' ? value.split(',').map((s) => s.trim()) : value,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
2
|
+
import { ValidationService } from './validation.service';
|
|
3
|
+
import { IsString, IsOptional, Length, IsEmail } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
class TestDto {
|
|
6
|
+
@IsString()
|
|
7
|
+
@Length(1, 10)
|
|
8
|
+
name: string;
|
|
9
|
+
|
|
10
|
+
@IsEmail()
|
|
11
|
+
email: string;
|
|
12
|
+
|
|
13
|
+
@IsOptional()
|
|
14
|
+
@IsString()
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('ValidationService', () => {
|
|
19
|
+
let service: ValidationService;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
service = new ValidationService();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('validateDto', () => {
|
|
26
|
+
it('should validate a valid DTO successfully', async () => {
|
|
27
|
+
const data = {
|
|
28
|
+
name: 'test',
|
|
29
|
+
email: 'test@example.com',
|
|
30
|
+
description: 'optional field',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const result = await service.validateDto(TestDto, data);
|
|
34
|
+
|
|
35
|
+
expect(result).toBeInstanceOf(TestDto);
|
|
36
|
+
expect(result.name).toBe('test');
|
|
37
|
+
expect(result.email).toBe('test@example.com');
|
|
38
|
+
expect(result.description).toBe('optional field');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should validate a valid DTO with optional fields missing', async () => {
|
|
42
|
+
const data = {
|
|
43
|
+
name: 'test',
|
|
44
|
+
email: 'test@example.com',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const result = await service.validateDto(TestDto, data);
|
|
48
|
+
|
|
49
|
+
expect(result).toBeInstanceOf(TestDto);
|
|
50
|
+
expect(result.name).toBe('test');
|
|
51
|
+
expect(result.email).toBe('test@example.com');
|
|
52
|
+
expect(result.description).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw BadRequestException for invalid string length', async () => {
|
|
56
|
+
const data = {
|
|
57
|
+
name: 'this name is too long',
|
|
58
|
+
email: 'test@example.com',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
await expect(service.validateDto(TestDto, data)).rejects.toThrow(BadRequestException);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should throw BadRequestException for invalid email', async () => {
|
|
65
|
+
const data = {
|
|
66
|
+
name: 'test',
|
|
67
|
+
email: 'invalid-email',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await expect(service.validateDto(TestDto, data)).rejects.toThrow(BadRequestException);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should throw BadRequestException for missing required fields', async () => {
|
|
74
|
+
const data = {
|
|
75
|
+
name: 'test',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await expect(service.validateDto(TestDto, data)).rejects.toThrow(BadRequestException);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should combine multiple validation errors', async () => {
|
|
82
|
+
const data = {
|
|
83
|
+
name: 'this name is way too long for validation',
|
|
84
|
+
email: 'invalid-email',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await service.validateDto(TestDto, data);
|
|
89
|
+
fail('Should have thrown BadRequestException');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
expect(error).toBeInstanceOf(BadRequestException);
|
|
92
|
+
const message = (error as BadRequestException).message;
|
|
93
|
+
expect(message).toContain('name must be shorter than or equal to 10 characters');
|
|
94
|
+
expect(message).toContain('email must be an email');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should handle empty data', async () => {
|
|
99
|
+
const data = {};
|
|
100
|
+
|
|
101
|
+
await expect(service.validateDto(TestDto, data)).rejects.toThrow(BadRequestException);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle null data', async () => {
|
|
105
|
+
const data = null;
|
|
106
|
+
|
|
107
|
+
await expect(service.validateDto(TestDto, data)).rejects.toThrow(BadRequestException);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('validateInstance', () => {
|
|
112
|
+
it('should validate a valid instance successfully', async () => {
|
|
113
|
+
const instance = new TestDto();
|
|
114
|
+
instance.name = 'test';
|
|
115
|
+
instance.email = 'test@example.com';
|
|
116
|
+
|
|
117
|
+
await expect(service.validateInstance(instance)).resolves.toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should throw BadRequestException for invalid instance', async () => {
|
|
121
|
+
const instance = new TestDto();
|
|
122
|
+
instance.name = 'this name is too long';
|
|
123
|
+
instance.email = 'invalid-email';
|
|
124
|
+
|
|
125
|
+
await expect(service.validateInstance(instance)).rejects.toThrow(BadRequestException);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle instance with missing required fields', async () => {
|
|
129
|
+
const instance = new TestDto();
|
|
130
|
+
instance.name = 'test';
|
|
131
|
+
|
|
132
|
+
await expect(service.validateInstance(instance)).rejects.toThrow(BadRequestException);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('error message formatting', () => {
|
|
137
|
+
it('should format single error correctly', async () => {
|
|
138
|
+
const data = {
|
|
139
|
+
name: 'test',
|
|
140
|
+
email: 'invalid-email',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await service.validateDto(TestDto, data);
|
|
145
|
+
fail('Should have thrown BadRequestException');
|
|
146
|
+
} catch (error) {
|
|
147
|
+
expect(error).toBeInstanceOf(BadRequestException);
|
|
148
|
+
expect(error.message).toBe('email must be an email');
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should format multiple errors with semicolon separator', async () => {
|
|
153
|
+
const data = {
|
|
154
|
+
name: '',
|
|
155
|
+
email: 'invalid',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await service.validateDto(TestDto, data);
|
|
160
|
+
fail('Should have thrown BadRequestException');
|
|
161
|
+
} catch (error) {
|
|
162
|
+
expect(error).toBeInstanceOf(BadRequestException);
|
|
163
|
+
const message = (error as BadRequestException).message;
|
|
164
|
+
expect(message).toContain(';');
|
|
165
|
+
expect(message).toContain('name must be longer than or equal to 1 characters');
|
|
166
|
+
expect(message).toContain('email must be an email');
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Injectable, BadRequestException } from '@nestjs/common';
|
|
2
|
+
import { validate, ValidationError } from 'class-validator';
|
|
3
|
+
import { plainToInstance, ClassConstructor } from 'class-transformer';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class ValidationService {
|
|
7
|
+
async validateDto<T extends object>(dtoClass: ClassConstructor<T>, data: unknown): Promise<T> {
|
|
8
|
+
if (data === null || data === undefined) {
|
|
9
|
+
throw new BadRequestException('Data cannot be null or undefined');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const dtoInstance = plainToInstance(dtoClass, data);
|
|
13
|
+
const validationErrors = await validate(dtoInstance as object);
|
|
14
|
+
|
|
15
|
+
if (validationErrors.length > 0) {
|
|
16
|
+
const errorMessages = this.formatValidationErrors(validationErrors);
|
|
17
|
+
throw new BadRequestException(errorMessages);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return dtoInstance;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async validateInstance<T extends object>(instance: T): Promise<void> {
|
|
24
|
+
const validationErrors = await validate(instance as object);
|
|
25
|
+
|
|
26
|
+
if (validationErrors.length > 0) {
|
|
27
|
+
const errorMessages = this.formatValidationErrors(validationErrors);
|
|
28
|
+
throw new BadRequestException(errorMessages);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private formatValidationErrors(errors: ValidationError[]): string {
|
|
33
|
+
return errors
|
|
34
|
+
.map((error) => this.extractErrorMessage(error))
|
|
35
|
+
.filter((message) => message.length > 0)
|
|
36
|
+
.join('; ');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private extractErrorMessage(error: ValidationError): string {
|
|
40
|
+
const messages: string[] = [];
|
|
41
|
+
|
|
42
|
+
if (error.constraints) {
|
|
43
|
+
messages.push(...Object.values(error.constraints));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (error.children && error.children.length > 0) {
|
|
47
|
+
const childMessages = error.children
|
|
48
|
+
.map((child) => this.extractErrorMessage(child))
|
|
49
|
+
.filter((message) => message.length > 0);
|
|
50
|
+
messages.push(...childMessages);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return messages.join(', ');
|
|
54
|
+
}
|
|
55
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"module": "commonjs",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"emitDecoratorMetadata": true,
|
|
9
|
+
"experimentalDecorators": true,
|
|
10
|
+
"target": "ES2021"
|
|
11
|
+
},
|
|
12
|
+
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"],
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"types": ["node", "jest"],
|
|
7
|
+
"emitDecoratorMetadata": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"target": "ES2021"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
12
|
+
}
|