Data Transfer Objects (DTOs) und Datenvalidierung sind fundamentale Konzepte in NestJS für die Gewährleistung von Datenintegrität, Security und API-Qualität. DTOs definieren die Struktur und Validierungsregeln für eingehende und ausgehende Daten, während class-validator und class-transformer eine typisierte, deklarative Validierung und Transformation ermöglichen. Dieses Kapitel zeigt umfassende Patterns für robuste Datenvalidierung in professionellen NestJS-Anwendungen.
Data Transfer Objects sind spezielle Klassen, die die Struktur von Daten definieren, die zwischen verschiedenen Schichten einer Anwendung übertragen werden. In NestJS dienen DTOs primär zur Validierung und Dokumentation von API-Endpunkten und stellen sicher, dass nur erwartete und validierte Daten verarbeitet werden.
DTOs sollten nach bewährten Design-Prinzipien strukturiert werden, um Wartbarkeit und Wiederverwendbarkeit zu gewährleisten:
Single Responsibility: Jedes DTO sollte einen spezifischen Anwendungsfall repräsentieren. Vermeiden Sie generische “Alles-in-einem” DTOs, die für verschiedene Operationen verwendet werden.
Immutability: DTOs sollten unveränderlich sein und nur Daten transportieren, keine Geschäftslogik enthalten.
Explizite Typisierung: Verwenden Sie TypeScript-Features vollständig aus, um Compile-Zeit-Sicherheit zu gewährleisten.
Validierungsklarheit: Validierungsregeln sollten selbstdokumentierend und verständlich sein.
// src/users/dto/create-user.dto.ts
import {
IsEmail,
IsString,
IsOptional,
IsDateString,
IsEnum,
IsBoolean,
IsArray,
IsObject,
ValidateNested,
MinLength,
MaxLength,
Matches,
IsPhoneNumber,
IsUrl,
IsNumber,
Min,
Max,
IsNotEmpty,
ArrayMinSize,
ArrayMaxSize,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum Gender {
MALE = 'male',
FEMALE = 'female',
OTHER = 'other',
PREFER_NOT_TO_SAY = 'prefer_not_to_say',
}
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
MODERATOR = 'moderator',
EDITOR = 'editor',
}
export class CreateUserDto {
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
format: 'email',
})
@IsEmail({}, { message: 'Please provide a valid email address' })
@Transform(({ value }) => value.toLowerCase().trim())
email: string;
@ApiProperty({
description: 'User password',
minLength: 8,
example: 'SecurePass123!',
})
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@MaxLength(128, { message: 'Password cannot exceed 128 characters' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
password: string;
@ApiProperty({
description: 'User first name',
minLength: 1,
maxLength: 50,
example: 'John',
})
@IsString()
@IsNotEmpty({ message: 'First name is required' })
@MinLength(1, { message: 'First name cannot be empty' })
@MaxLength(50, { message: 'First name cannot exceed 50 characters' })
@Transform(({ value }) => value.trim())
@Matches(/^[a-zA-ZäöüÄÖÜß\s-']+$/, { message: 'First name contains invalid characters' })
firstName: string;
@ApiProperty({
description: 'User last name',
minLength: 1,
maxLength: 50,
example: 'Doe',
})
@IsString()
@IsNotEmpty({ message: 'Last name is required' })
@MinLength(1, { message: 'Last name cannot be empty' })
@MaxLength(50, { message: 'Last name cannot exceed 50 characters' })
@Transform(({ value }) => value.trim())
@Matches(/^[a-zA-ZäöüÄÖÜß\s-']+$/, { message: 'Last name contains invalid characters' })
lastName: string;
@ApiPropertyOptional({
description: 'User phone number',
example: '+49 30 12345678',
})
@IsOptional()
@IsPhoneNumber('DE', { message: 'Please provide a valid German phone number' })
@Transform(({ value }) => value?.replace(/\s/g, ''))
phoneNumber?: string;
@ApiPropertyOptional({
description: 'User birth date',
example: '1990-01-15',
format: 'date',
})
@IsOptional()
@IsDateString({}, { message: 'Please provide a valid date in ISO format' })
birthDate?: string;
@ApiPropertyOptional({
description: 'User gender',
enum: Gender,
example: Gender.PREFER_NOT_TO_SAY,
})
@IsOptional()
@IsEnum(Gender, { message: 'Gender must be one of: male, female, other, prefer_not_to_say' })
gender?: Gender;
@ApiPropertyOptional({
description: 'User role',
enum: UserRole,
default: UserRole.USER,
example: UserRole.USER,
})
@IsOptional()
@IsEnum(UserRole, { message: 'Role must be one of: user, admin, moderator, editor' })
role?: UserRole = UserRole.USER;
@ApiPropertyOptional({
description: 'Newsletter subscription status',
default: false,
example: true,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return Boolean(value);
})
newsletterSubscription?: boolean = false;
@ApiPropertyOptional({
description: 'User interests/tags',
type: [String],
example: ['technology', 'sports', 'music'],
})
@IsOptional()
@IsArray()
@ArrayMinSize(0)
@ArrayMaxSize(10, { message: 'Maximum 10 interests allowed' })
@IsString({ each: true })
@Transform(({ value }) => {
if (Array.isArray(value)) {
return value.map(interest => interest.trim().toLowerCase()).filter(Boolean);
}
return [];
})
interests?: string[];
}Komplexe Datenstrukturen erfordern verschachtelte DTOs für strukturierte Validierung:
// src/users/dto/address.dto.ts
export class AddressDto {
@ApiProperty({
description: 'Street address',
example: 'Musterstraße 123',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
@Transform(({ value }) => value.trim())
street: string;
@ApiProperty({
description: 'City name',
example: 'Berlin',
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
@Transform(({ value }) => value.trim())
@Matches(/^[a-zA-ZäöüÄÖÜß\s-]+$/, { message: 'City name contains invalid characters' })
city: string;
@ApiProperty({
description: 'Postal code',
example: '10115',
})
@IsString()
@Matches(/^\d{5}$/, { message: 'Postal code must be exactly 5 digits' })
postalCode: string;
@ApiProperty({
description: 'Country code',
example: 'DE',
})
@IsString()
@MinLength(2)
@MaxLength(2)
@Transform(({ value }) => value.toUpperCase())
@Matches(/^[A-Z]{2}$/, { message: 'Country code must be exactly 2 uppercase letters' })
countryCode: string;
@ApiPropertyOptional({
description: 'State or region',
example: 'Berlin',
})
@IsOptional()
@IsString()
@MaxLength(50)
state?: string;
@ApiPropertyOptional({
description: 'Apartment or suite number',
example: 'Apt 4B',
})
@IsOptional()
@IsString()
@MaxLength(20)
apartment?: string;
}
// src/users/dto/social-media.dto.ts
export class SocialMediaDto {
@ApiPropertyOptional({
description: 'LinkedIn profile URL',
example: 'https://linkedin.com/in/johndoe',
})
@IsOptional()
@IsUrl({}, { message: 'LinkedIn URL must be a valid URL' })
@Matches(/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9-]+\/?$/, {
message: 'LinkedIn URL format is invalid',
})
linkedin?: string;
@ApiPropertyOptional({
description: 'Twitter handle',
example: '@johndoe',
})
@IsOptional()
@IsString()
@Matches(/^@?[A-Za-z0-9_]{1,15}$/, { message: 'Invalid Twitter handle format' })
@Transform(({ value }) => value.startsWith('@') ? value : `@${value}`)
twitter?: string;
@ApiPropertyOptional({
description: 'GitHub username',
example: 'johndoe',
})
@IsOptional()
@IsString()
@Matches(/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i, { message: 'Invalid GitHub username format' })
github?: string;
}
// src/users/dto/preferences.dto.ts
export enum NotificationFrequency {
NEVER = 'never',
DAILY = 'daily',
WEEKLY = 'weekly',
MONTHLY = 'monthly',
}
export enum Theme {
LIGHT = 'light',
DARK = 'dark',
AUTO = 'auto',
}
export class UserPreferencesDto {
@ApiPropertyOptional({
description: 'Notification frequency',
enum: NotificationFrequency,
default: NotificationFrequency.WEEKLY,
})
@IsOptional()
@IsEnum(NotificationFrequency)
notificationFrequency?: NotificationFrequency = NotificationFrequency.WEEKLY;
@ApiPropertyOptional({
description: 'UI theme preference',
enum: Theme,
default: Theme.AUTO,
})
@IsOptional()
@IsEnum(Theme)
theme?: Theme = Theme.AUTO;
@ApiPropertyOptional({
description: 'Language preference',
example: 'de-DE',
})
@IsOptional()
@IsString()
@Matches(/^[a-z]{2}-[A-Z]{2}$/, { message: 'Language must be in format: xx-XX' })
language?: string = 'de-DE';
@ApiPropertyOptional({
description: 'Email notifications enabled',
default: true,
})
@IsOptional()
@IsBoolean()
emailNotifications?: boolean = true;
@ApiPropertyOptional({
description: 'Push notifications enabled',
default: false,
})
@IsOptional()
@IsBoolean()
pushNotifications?: boolean = false;
}
// src/users/dto/complete-user-profile.dto.ts
export class CompleteUserProfileDto {
@ApiProperty({ type: CreateUserDto })
@ValidateNested()
@Type(() => CreateUserDto)
@IsObject()
user: CreateUserDto;
@ApiPropertyOptional({ type: AddressDto })
@IsOptional()
@ValidateNested()
@Type(() => AddressDto)
@IsObject()
address?: AddressDto;
@ApiPropertyOptional({ type: SocialMediaDto })
@IsOptional()
@ValidateNested()
@Type(() => SocialMediaDto)
@IsObject()
socialMedia?: SocialMediaDto;
@ApiPropertyOptional({ type: UserPreferencesDto })
@IsOptional()
@ValidateNested()
@Type(() => UserPreferencesDto)
@IsObject()
preferences?: UserPreferencesDto;
@ApiPropertyOptional({
description: 'Emergency contacts',
type: [Object],
example: [
{
name: 'Jane Doe',
relationship: 'Sister',
phoneNumber: '+49 30 87654321',
email: 'jane.doe@example.com'
}
],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(3, { message: 'Maximum 3 emergency contacts allowed' })
@ValidateNested({ each: true })
@Type(() => EmergencyContactDto)
emergencyContacts?: EmergencyContactDto[];
}
export class EmergencyContactDto {
@ApiProperty({
description: 'Contact name',
example: 'Jane Doe',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiProperty({
description: 'Relationship to user',
example: 'Sister',
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
relationship: string;
@ApiProperty({
description: 'Contact phone number',
example: '+49 30 87654321',
})
@IsPhoneNumber('DE')
phoneNumber: string;
@ApiPropertyOptional({
description: 'Contact email address',
example: 'jane.doe@example.com',
})
@IsOptional()
@IsEmail()
email?: string;
}Effiziente DTO-Organisation durch Vererbung und Komposition:
// src/common/dto/base.dto.ts
export abstract class BaseDto {
@ApiPropertyOptional({
description: 'Additional metadata',
type: Object,
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@ApiPropertyOptional({
description: 'Tags for categorization',
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
tags?: string[];
}
export abstract class TimestampDto {
@ApiProperty({
description: 'Creation timestamp',
example: '2023-12-01T10:30:00.000Z',
})
@IsDateString()
createdAt: string;
@ApiProperty({
description: 'Last update timestamp',
example: '2023-12-01T15:45:00.000Z',
})
@IsDateString()
updatedAt: string;
}
// src/users/dto/update-user.dto.ts
import { PartialType, OmitType, PickType } from '@nestjs/swagger';
// Partial Update DTO - alle Felder optional
export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiPropertyOptional({
description: 'Update reason',
example: 'Profile information update',
})
@IsOptional()
@IsString()
@MaxLength(500)
updateReason?: string;
}
// Patch DTO - nur spezifische Felder
export class PatchUserDto extends PickType(CreateUserDto, [
'firstName',
'lastName',
'phoneNumber',
] as const) {}
// Registration DTO - ohne role
export class UserRegistrationDto extends OmitType(CreateUserDto, ['role'] as const) {
@ApiProperty({
description: 'Terms and conditions acceptance',
example: true,
})
@IsBoolean()
@Transform(({ value }) => Boolean(value))
acceptTerms: boolean;
@ApiProperty({
description: 'Privacy policy acceptance',
example: true,
})
@IsBoolean()
@Transform(({ value }) => Boolean(value))
acceptPrivacyPolicy: boolean;
}
// Login DTO - nur email und password
export class LoginDto extends PickType(CreateUserDto, ['email', 'password'] as const) {
@ApiPropertyOptional({
description: 'Remember me flag',
default: false,
})
@IsOptional()
@IsBoolean()
rememberMe?: boolean = false;
}
// Password Change DTO
export class ChangePasswordDto {
@ApiProperty({
description: 'Current password',
example: 'CurrentPass123!',
})
@IsString()
@IsNotEmpty()
currentPassword: string;
@ApiProperty({
description: 'New password',
example: 'NewSecurePass456!',
})
@IsString()
@MinLength(8)
@MaxLength(128)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'New password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
newPassword: string;
@ApiProperty({
description: 'Confirm new password',
example: 'NewSecurePass456!',
})
@IsString()
@IsNotEmpty()
confirmPassword: string;
}class-validator bietet umfangreiche Dekoratoren für deklarative Validierung und kann mit custom Validators erweitert werden.
// src/products/dto/create-product.dto.ts
import {
IsString,
IsNumber,
IsBoolean,
IsArray,
IsEnum,
IsUrl,
IsUUID,
IsOptional,
IsNotEmpty,
Min,
Max,
Length,
ArrayMinSize,
ArrayMaxSize,
ValidateNested,
IsDecimal,
IsCurrency,
IsJSON,
Matches,
} from 'class-validator';
export enum ProductCategory {
ELECTRONICS = 'electronics',
CLOTHING = 'clothing',
BOOKS = 'books',
HOME = 'home',
SPORTS = 'sports',
BEAUTY = 'beauty',
}
export enum ProductStatus {
DRAFT = 'draft',
ACTIVE = 'active',
DISCONTINUED = 'discontinued',
OUT_OF_STOCK = 'out_of_stock',
}
export class ProductDimensionsDto {
@ApiProperty({ description: 'Length in centimeters', example: 25.5 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.1)
@Max(1000)
length: number;
@ApiProperty({ description: 'Width in centimeters', example: 15.3 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.1)
@Max(1000)
width: number;
@ApiProperty({ description: 'Height in centimeters', example: 8.7 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.1)
@Max(1000)
height: number;
@ApiProperty({ description: 'Weight in grams', example: 450 })
@IsNumber({ maxDecimalPlaces: 1 })
@Min(0.1)
@Max(50000)
weight: number;
}
export class ProductVariantDto {
@ApiProperty({ description: 'Variant name', example: 'Large Red' })
@IsString()
@IsNotEmpty()
@Length(1, 100)
name: string;
@ApiProperty({ description: 'SKU code', example: 'PROD-001-LG-RED' })
@IsString()
@Matches(/^[A-Z0-9-_]+$/, { message: 'SKU must contain only uppercase letters, numbers, hyphens and underscores' })
@Length(3, 50)
sku: string;
@ApiProperty({ description: 'Price in cents', example: 2999 })
@IsNumber()
@Min(0)
@Max(10000000) // €100,000 max
price: number;
@ApiProperty({ description: 'Stock quantity', example: 150 })
@IsNumber()
@Min(0)
@Max(999999)
stock: number;
@ApiPropertyOptional({ description: 'Variant attributes' })
@IsOptional()
@IsJSON()
attributes?: string; // JSON string for flexible attributes
}
export class CreateProductDto {
@ApiProperty({
description: 'Product name',
example: 'Premium Wireless Headphones',
})
@IsString()
@IsNotEmpty()
@Length(3, 200)
@Transform(({ value }) => value.trim())
name: string;
@ApiProperty({
description: 'Product description',
example: 'High-quality wireless headphones with noise cancellation',
})
@IsString()
@IsNotEmpty()
@Length(10, 5000)
@Transform(({ value }) => value.trim())
description: string;
@ApiProperty({
description: 'Product category',
enum: ProductCategory,
example: ProductCategory.ELECTRONICS,
})
@IsEnum(ProductCategory)
category: ProductCategory;
@ApiProperty({
description: 'Base price in cents',
example: 15999, // €159.99
})
@IsNumber()
@Min(1)
@Max(10000000) // €100,000 max
basePrice: number;
@ApiPropertyOptional({
description: 'Sale price in cents',
example: 12999, // €129.99
})
@IsOptional()
@IsNumber()
@Min(1)
@Max(10000000)
salePrice?: number;
@ApiProperty({
description: 'Currency code',
example: 'EUR',
})
@IsString()
@Length(3, 3)
@Matches(/^[A-Z]{3}$/, { message: 'Currency must be a 3-letter uppercase code' })
currency: string;
@ApiProperty({
description: 'Product brand',
example: 'AudioTech',
})
@IsString()
@IsNotEmpty()
@Length(1, 100)
@Transform(({ value }) => value.trim())
brand: string;
@ApiProperty({
description: 'Product model',
example: 'AT-WH-2000',
})
@IsString()
@IsNotEmpty()
@Length(1, 100)
@Transform(({ value }) => value.trim())
model: string;
@ApiPropertyOptional({
description: 'Product dimensions',
type: ProductDimensionsDto,
})
@IsOptional()
@ValidateNested()
@Type(() => ProductDimensionsDto)
dimensions?: ProductDimensionsDto;
@ApiPropertyOptional({
description: 'Product images URLs',
type: [String],
example: [
'https://example.com/images/product1.jpg',
'https://example.com/images/product2.jpg'
],
})
@IsOptional()
@IsArray()
@ArrayMinSize(0)
@ArrayMaxSize(10)
@IsUrl({}, { each: true })
images?: string[];
@ApiPropertyOptional({
description: 'Product tags',
type: [String],
example: ['wireless', 'bluetooth', 'noise-cancelling'],
})
@IsOptional()
@IsArray()
@ArrayMinSize(0)
@ArrayMaxSize(20)
@IsString({ each: true })
@Transform(({ value }) => {
if (Array.isArray(value)) {
return value.map(tag => tag.toLowerCase().trim()).filter(Boolean);
}
return [];
})
tags?: string[];
@ApiPropertyOptional({
description: 'Product variants',
type: [ProductVariantDto],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => ProductVariantDto)
variants?: ProductVariantDto[];
@ApiPropertyOptional({
description: 'Product status',
enum: ProductStatus,
default: ProductStatus.DRAFT,
})
@IsOptional()
@IsEnum(ProductStatus)
status?: ProductStatus = ProductStatus.DRAFT;
@ApiPropertyOptional({
description: 'SEO metadata as JSON',
example: '{"title":"Premium Headphones","description":"Best wireless headphones"}',
})
@IsOptional()
@IsJSON()
seoMetadata?: string;
@ApiPropertyOptional({
description: 'Featured product flag',
default: false,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => Boolean(value))
isFeatured?: boolean = false;
@ApiPropertyOptional({
description: 'Digital product flag',
default: false,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => Boolean(value))
isDigital?: boolean = false;
}Bedingte Validierung basierend auf anderen Feldern:
// src/common/validators/conditional-validation.ts
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
ValidationOptions,
registerDecorator,
} from 'class-validator';
@ValidatorConstraint({ name: 'isRequiredWhen', async: false })
export class IsRequiredWhenConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName, expectedValue] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
if (relatedValue === expectedValue) {
return value !== undefined && value !== null && value !== '';
}
return true;
}
defaultMessage(args: ValidationArguments) {
const [relatedPropertyName, expectedValue] = args.constraints;
return `${args.property} is required when ${relatedPropertyName} is ${expectedValue}`;
}
}
export function IsRequiredWhen(
property: string,
value: any,
validationOptions?: ValidationOptions,
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [property, value],
validator: IsRequiredWhenConstraint,
});
};
}
@ValidatorConstraint({ name: 'isGreaterThanField', async: false })
export class IsGreaterThanFieldConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
if (typeof value === 'number' && typeof relatedValue === 'number') {
return value > relatedValue;
}
return true;
}
defaultMessage(args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
return `${args.property} must be greater than ${relatedPropertyName}`;
}
}
export function IsGreaterThanField(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [property],
validator: IsGreaterThanFieldConstraint,
});
};
}
// Verwendung in DTOs
export class CreateEventDto {
@ApiProperty({
description: 'Event name',
example: 'Tech Conference 2024',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
description: 'Event type',
enum: EventType,
example: EventType.CONFERENCE,
})
@IsEnum(EventType)
type: EventType;
@ApiProperty({
description: 'Start date and time',
example: '2024-06-15T09:00:00.000Z',
})
@IsDateString()
startDate: string;
@ApiProperty({
description: 'End date and time',
example: '2024-06-15T17:00:00.000Z',
})
@IsDateString()
@IsGreaterThanField('startDate', { message: 'End date must be after start date' })
endDate: string;
@ApiPropertyOptional({
description: 'Venue address (required for in-person events)',
example: 'Messe Berlin, Messedamm 22, 14055 Berlin',
})
@IsOptional()
@IsString()
@IsRequiredWhen('type', EventType.IN_PERSON, {
message: 'Venue address is required for in-person events',
})
venueAddress?: string;
@ApiPropertyOptional({
description: 'Online meeting link (required for virtual events)',
example: 'https://zoom.us/j/123456789',
})
@IsOptional()
@IsUrl()
@IsRequiredWhen('type', EventType.VIRTUAL, {
message: 'Meeting link is required for virtual events',
})
meetingLink?: string;
@ApiProperty({
description: 'Maximum number of attendees',
example: 250,
})
@IsNumber()
@Min(1)
@Max(10000)
maxAttendees: number;
@ApiPropertyOptional({
description: 'Registration fee in cents',
example: 5000, // €50.00
})
@IsOptional()
@IsNumber()
@Min(0)
registrationFee?: number;
@ApiPropertyOptional({
description: 'Early bird discount percentage (required if registration fee > 0)',
example: 15,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(50)
@IsRequiredWhen('registrationFee', 0, {
message: 'Early bird discount is required for paid events',
})
earlyBirdDiscount?: number;
}
enum EventType {
IN_PERSON = 'in_person',
VIRTUAL = 'virtual',
HYBRID = 'hybrid',
}Komplexe Validierung für Arrays und verschachtelte Objekte:
// src/orders/dto/create-order.dto.ts
export class OrderItemDto {
@ApiProperty({
description: 'Product ID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsUUID(4, { message: 'Product ID must be a valid UUID' })
productId: string;
@ApiProperty({
description: 'Quantity to order',
example: 2,
})
@IsNumber()
@Min(1, { message: 'Quantity must be at least 1' })
@Max(999, { message: 'Quantity cannot exceed 999' })
quantity: number;
@ApiPropertyOptional({
description: 'Unit price in cents (for price verification)',
example: 1999,
})
@IsOptional()
@IsNumber()
@Min(0)
unitPrice?: number;
@ApiPropertyOptional({
description: 'Item-specific notes',
example: 'Please wrap as gift',
})
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
@ApiPropertyOptional({
description: 'Custom attributes for the item',
example: '{"color": "red", "size": "large"}',
})
@IsOptional()
@IsJSON()
customAttributes?: string;
}
export class ShippingAddressDto extends AddressDto {
@ApiPropertyOptional({
description: 'Delivery instructions',
example: 'Leave at front door if not home',
})
@IsOptional()
@IsString()
@MaxLength(500)
deliveryInstructions?: string;
@ApiPropertyOptional({
description: 'Preferred delivery time',
enum: ['morning', 'afternoon', 'evening', 'any'],
example: 'afternoon',
})
@IsOptional()
@IsEnum(['morning', 'afternoon', 'evening', 'any'])
preferredDeliveryTime?: string;
}
export class PaymentMethodDto {
@ApiProperty({
description: 'Payment method type',
enum: ['credit_card', 'paypal', 'bank_transfer', 'apple_pay', 'google_pay'],
example: 'credit_card',
})
@IsEnum(['credit_card', 'paypal', 'bank_transfer', 'apple_pay', 'google_pay'])
type: string;
@ApiPropertyOptional({
description: 'Credit card last 4 digits (for credit card payments)',
example: '1234',
})
@IsOptional()
@IsString()
@Matches(/^\d{4}$/, { message: 'Last 4 digits must be exactly 4 numbers' })
@IsRequiredWhen('type', 'credit_card')
last4Digits?: string;
@ApiPropertyOptional({
description: 'PayPal email (for PayPal payments)',
example: 'user@example.com',
})
@IsOptional()
@IsEmail()
@IsRequiredWhen('type', 'paypal')
paypalEmail?: string;
}
export class CreateOrderDto {
@ApiProperty({
description: 'Order items',
type: [OrderItemDto],
minItems: 1,
maxItems: 50,
})
@IsArray()
@ArrayMinSize(1, { message: 'Order must contain at least one item' })
@ArrayMaxSize(50, { message: 'Order cannot contain more than 50 items' })
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
@ApiProperty({
description: 'Shipping address',
type: ShippingAddressDto,
})
@ValidateNested()
@Type(() => ShippingAddressDto)
@IsObject()
shippingAddress: ShippingAddressDto;
@ApiPropertyOptional({
description: 'Billing address (same as shipping if not provided)',
type: AddressDto,
})
@IsOptional()
@ValidateNested()
@Type(() => AddressDto)
@IsObject()
billingAddress?: AddressDto;
@ApiProperty({
description: 'Payment method',
type: PaymentMethodDto,
})
@ValidateNested()
@Type(() => PaymentMethodDto)
@IsObject()
paymentMethod: PaymentMethodDto;
@ApiPropertyOptional({
description: 'Promo code',
example: 'SUMMER2024',
})
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6,20}$/, { message: 'Promo code must be 6-20 uppercase letters and numbers' })
promoCode?: string;
@ApiPropertyOptional({
description: 'Special delivery requirements',
type: [String],
example: ['fragile', 'signature_required'],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(5)
@IsEnum(['fragile', 'signature_required', 'age_verification', 'weekend_delivery', 'express'], { each: true })
specialRequirements?: string[];
@ApiPropertyOptional({
description: 'Order notes',
example: 'This is a gift order, please include gift receipt',
})
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
@ApiPropertyOptional({
description: 'Subscribe to newsletter',
default: false,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => Boolean(value))
subscribeNewsletter?: boolean = false;
}class-transformer ermöglicht die Transformation von Plain Objects zu Class Instances und umgekehrt, sowie erweiterte Datenmanipulation.
// src/common/transformers/common-transformers.ts
import { Transform } from 'class-transformer';
// String-Transformationen
export function ToLowerCase() {
return Transform(({ value }) => {
if (typeof value === 'string') {
return value.toLowerCase();
}
return value;
});
}
export function ToUpperCase() {
return Transform(({ value }) => {
if (typeof value === 'string') {
return value.toUpperCase();
}
return value;
});
}
export function Trim() {
return Transform(({ value }) => {
if (typeof value === 'string') {
return value.trim();
}
return value;
});
}
export function NormalizeSpaces() {
return Transform(({ value }) => {
if (typeof value === 'string') {
return value.replace(/\s+/g, ' ').trim();
}
return value;
});
}
// Nummer-Transformationen
export function ToNumber() {
return Transform(({ value }) => {
if (typeof value === 'string' && !isNaN(Number(value))) {
return Number(value);
}
return value;
});
}
export function ToInteger() {
return Transform(({ value }) => {
if (typeof value === 'string' && !isNaN(parseInt(value))) {
return parseInt(value, 10);
}
if (typeof value === 'number') {
return Math.floor(value);
}
return value;
});
}
export function RoundToDecimals(decimals: number = 2) {
return Transform(({ value }) => {
if (typeof value === 'number') {
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
return value;
});
}
// Boolean-Transformationen
export function ToBoolean() {
return Transform(({ value }) => {
if (typeof value === 'string') {
return value.toLowerCase() === 'true' || value === '1';
}
if (typeof value === 'number') {
return value !== 0;
}
return Boolean(value);
});
}
// Array-Transformationen
export function ToArray() {
return Transform(({ value }) => {
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string') {
return value.split(',').map(item => item.trim()).filter(Boolean);
}
return value ? [value] : [];
});
}
export function UniqueArray() {
return Transform(({ value }) => {
if (Array.isArray(value)) {
return [...new Set(value)];
}
return value;
});
}
// Datum-Transformationen
export function ToDate() {
return Transform(({ value }) => {
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return isNaN(date.getTime()) ? value : date;
}
return value;
});
}
export function ToISOString() {
return Transform(({ value }) => {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return isNaN(date.getTime()) ? value : date.toISOString();
}
return value;
});
}// src/users/dto/advanced-user.dto.ts
export class AdvancedUserDto {
@ApiProperty({
description: 'Full name (will be split into first and last name)',
example: 'John Michael Doe',
})
@IsString()
@IsNotEmpty()
@Transform(({ value, obj }) => {
if (typeof value === 'string') {
const parts = value.trim().split(/\s+/);
obj.firstName = parts[0];
obj.lastName = parts.slice(1).join(' ') || parts[0];
return value;
}
return value;
})
fullName: string;
@ApiProperty({
description: 'Email address',
example: 'JOHN.DOE@EXAMPLE.COM',
})
@IsEmail()
@ToLowerCase()
@Trim()
email: string;
@ApiProperty({
description: 'Phone number',
example: '+49 (030) 123-456-78',
})
@IsString()
@Transform(({ value }) => {
if (typeof value === 'string') {
// Normalize phone number: remove all non-digits except +
return value.replace(/[^\d+]/g, '');
}
return value;
})
@IsPhoneNumber('DE')
phoneNumber: string;
@ApiProperty({
description: 'Birth date',
example: '1990-01-15',
})
@IsDateString()
@ToISOString()
birthDate: string;
@ApiProperty({
description: 'Salary range',
example: '50000-75000',
})
@IsString()
@Transform(({ value, obj }) => {
if (typeof value === 'string' && value.includes('-')) {
const [min, max] = value.split('-').map(v => parseInt(v.trim()));
obj.salaryMin = min;
obj.salaryMax = max;
return value;
}
return value;
})
salaryRange: string;
// Diese Felder werden automatisch durch Transformationen gesetzt
firstName?: string;
lastName?: string;
salaryMin?: number;
salaryMax?: number;
@ApiProperty({
description: 'Skills as comma-separated string',
example: 'JavaScript, TypeScript, React, Node.js',
})
@IsString()
@Transform(({ value }) => {
if (typeof value === 'string') {
return value
.split(',')
.map(skill => skill.trim())
.filter(Boolean)
.map(skill => skill.toLowerCase());
}
return value;
})
@ToArray()
@UniqueArray()
skills: string[];
@ApiProperty({
description: 'Age calculation from birth date',
example: 33,
})
@IsNumber()
@Transform(({ obj }) => {
if (obj.birthDate) {
const birthDate = new Date(obj.birthDate);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
return undefined;
})
age?: number;
@ApiProperty({
description: 'Coordinates as "lat,lng"',
example: '52.5200,13.4050',
})
@IsString()
@Transform(({ value, obj }) => {
if (typeof value === 'string' && value.includes(',')) {
const [lat, lng] = value.split(',').map(v => parseFloat(v.trim()));
obj.latitude = lat;
obj.longitude = lng;
return value;
}
return value;
})
coordinates: string;
latitude?: number;
longitude?: number;
@ApiProperty({
description: 'Profile score (0-100)',
example: 85.6789,
})
@IsNumber()
@RoundToDecimals(2)
@Min(0)
@Max(100)
profileScore: number;
@ApiProperty({
description: 'Is premium user',
example: 'true',
})
@ToBoolean()
@IsBoolean()
isPremium: boolean;
@ApiProperty({
description: 'Tags as array or comma-separated string',
example: ['developer', 'senior', 'remote'],
})
@ToArray()
@UniqueArray()
@Transform(({ value }) => {
if (Array.isArray(value)) {
return value.map(tag => tag.toLowerCase().trim()).filter(Boolean);
}
return value;
})
tags: string[];
}// src/common/transformers/custom-transformers.ts
import { Transform, TransformFnParams } from 'class-transformer';
export function SanitizeHtml() {
return Transform(({ value }: TransformFnParams) => {
if (typeof value === 'string') {
// Einfache HTML-Sanitization (in Produktion sollte eine robuste Library wie DOMPurify verwendet werden)
return value
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.trim();
}
return value;
});
}
export function ParseJSON(defaultValue: any = null) {
return Transform(({ value }: TransformFnParams) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return defaultValue;
}
}
return value;
});
}
export function FormatCurrency(currency: string = 'EUR') {
return Transform(({ value }: TransformFnParams) => {
if (typeof value === 'number') {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency,
}).format(value / 100); // Assuming value is in cents
}
return value;
});
}
export function Slugify() {
return Transform(({ value }: TransformFnParams) => {
if (typeof value === 'string') {
return value
.toLowerCase()
.replace(/[äöüß]/g, (match) => {
const replacements = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' };
return replacements[match] || match;
})
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
return value;
});
}
export function MaskSensitiveData(visibleChars: number = 4) {
return Transform(({ value }: TransformFnParams) => {
if (typeof value === 'string' && value.length > visibleChars) {
const visible = value.slice(-visibleChars);
const masked = '*'.repeat(value.length - visibleChars);
return masked + visible;
}
return value;
});
}
export function DefaultValue(defaultVal: any) {
return Transform(({ value }: TransformFnParams) => {
return value === undefined || value === null || value === '' ? defaultVal : value;
});
}
// Verwendungsbeispiel
export class BlogPostDto {
@ApiProperty({
description: 'Post title',
example: 'My Amazing Blog Post',
})
@IsString()
@IsNotEmpty()
@NormalizeSpaces()
title: string;
@ApiProperty({
description: 'Post slug (auto-generated from title)',
example: 'my-amazing-blog-post',
})
@IsString()
@Slugify()
@Transform(({ obj }) => {
// Auto-generate slug from title if not provided
if (!obj.slug && obj.title) {
return obj.title
.toLowerCase()
.replace(/[äöüß]/g, (match) => {
const replacements = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' };
return replacements[match] || match;
})
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
return obj.slug;
})
slug: string;
@ApiProperty({
description: 'Post content',
example: '<p>This is my <strong>amazing</strong> blog post content.</p>',
})
@IsString()
@IsNotEmpty()
@SanitizeHtml()
content: string;
@ApiProperty({
description: 'Post metadata as JSON string',
example: '{"readTime": 5, "difficulty": "beginner"}',
})
@IsOptional()
@ParseJSON({})
metadata?: any;
@ApiProperty({
description: 'Author credit card (will be masked)',
example: '1234567890123456',
})
@IsOptional()
@IsString()
@MaskSensitiveData(4)
authorCreditCard?: string;
@ApiProperty({
description: 'Publication status',
default: 'draft',
})
@IsString()
@DefaultValue('draft')
status: string;
}Die Integration von class-validator und class-transformer mit OpenAPI/Swagger ermöglicht automatische API-Dokumentation basierend auf DTO-Definitionen.
// src/common/decorators/api-decorators.ts
import { applyDecorators, Type } from '@nestjs/common';
import {
ApiProperty,
ApiPropertyOptional,
ApiResponse,
ApiOperation,
ApiBearerAuth,
ApiBody,
ApiParam,
ApiQuery,
getSchemaPath,
} from '@nestjs/swagger';
export function ApiPaginatedResponse<TModel extends Type<any>>(model: TModel) {
return applyDecorators(
ApiResponse({
status: 200,
description: 'Successfully retrieved paginated results',
schema: {
allOf: [
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
meta: {
type: 'object',
properties: {
total: { type: 'number', example: 100 },
page: { type: 'number', example: 1 },
limit: { type: 'number', example: 10 },
totalPages: { type: 'number', example: 10 },
hasNextPage: { type: 'boolean', example: true },
hasPrevPage: { type: 'boolean', example: false },
},
},
links: {
type: 'object',
properties: {
self: { type: 'string', example: '/api/users?page=1&limit=10' },
next: { type: 'string', example: '/api/users?page=2&limit=10' },
prev: { type: 'string', nullable: true, example: null },
},
},
},
},
],
},
}),
);
}
export function ApiStandardResponse<TModel extends Type<any>>(
model: TModel,
description: string = 'Successful operation',
) {
return applyDecorators(
ApiResponse({
status: 200,
description,
schema: {
allOf: [
{
properties: {
success: { type: 'boolean', example: true },
data: { $ref: getSchemaPath(model) },
timestamp: { type: 'string', example: '2023-12-01T10:30:00.000Z' },
requestId: { type: 'string', example: 'req_123456789' },
},
},
],
},
}),
);
}
export function ApiErrorResponses() {
return applyDecorators(
ApiResponse({
status: 400,
description: 'Bad Request - Validation failed',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
statusCode: { type: 'number', example: 400 },
timestamp: { type: 'string', example: '2023-12-01T10:30:00.000Z' },
path: { type: 'string', example: '/api/users' },
method: { type: 'string', example: 'POST' },
error: {
type: 'object',
properties: {
type: { type: 'string', example: 'ValidationError' },
message: { type: 'string', example: 'Request validation failed' },
details: {
type: 'object',
example: {
email: ['Please provide a valid email address'],
password: ['Password must be at least 8 characters long'],
},
},
},
},
},
},
}),
ApiResponse({
status: 401,
description: 'Unauthorized - Authentication required',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
statusCode: { type: 'number', example: 401 },
timestamp: { type: 'string', example: '2023-12-01T10:30:00.000Z' },
error: {
type: 'object',
properties: {
type: { type: 'string', example: 'UnauthorizedException' },
message: { type: 'string', example: 'Authentication required' },
},
},
},
},
}),
ApiResponse({
status: 403,
description: 'Forbidden - Insufficient permissions',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
statusCode: { type: 'number', example: 403 },
error: {
type: 'object',
properties: {
type: { type: 'string', example: 'ForbiddenException' },
message: { type: 'string', example: 'Insufficient permissions' },
},
},
},
},
}),
ApiResponse({
status: 500,
description: 'Internal Server Error',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
statusCode: { type: 'number', example: 500 },
error: {
type: 'object',
properties: {
type: { type: 'string', example: 'InternalServerErrorException' },
message: { type: 'string', example: 'Internal server error' },
},
},
},
},
}),
);
}
export function ApiCrudOperation(
entity: string,
operation: 'create' | 'read' | 'update' | 'delete',
) {
const operations = {
create: {
summary: `Create ${entity}`,
description: `Create a new ${entity.toLowerCase()} record`,
},
read: {
summary: `Get ${entity}`,
description: `Retrieve ${entity.toLowerCase()} information`,
},
update: {
summary: `Update ${entity}`,
description: `Update an existing ${entity.toLowerCase()} record`,
},
delete: {
summary: `Delete ${entity}`,
description: `Remove a ${entity.toLowerCase()} record`,
},
};
return ApiOperation(operations[operation]);
}// src/users/users.controller.ts
import { Controller, Get, Post, Body, Param, Query, Put, Delete, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam, ApiQuery } from '@nestjs/swagger';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserQueryDto } from './dto/user-query.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { ApiPaginatedResponse, ApiStandardResponse, ApiErrorResponses, ApiCrudOperation } from '../common/decorators/api-decorators';
@ApiTags('Users')
@Controller('users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponses()
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@ApiCrudOperation('User', 'create')
@ApiBody({
type: CreateUserDto,
description: 'User data to create',
examples: {
basicUser: {
summary: 'Basic user creation',
description: 'Creating a basic user with minimal required information',
value: {
email: 'john.doe@example.com',
password: 'SecurePass123!',
firstName: 'John',
lastName: 'Doe',
phoneNumber: '+49 30 12345678',
birthDate: '1990-01-15',
role: 'user',
},
},
completeUser: {
summary: 'Complete user creation',
description: 'Creating a user with all optional fields',
value: {
email: 'jane.admin@example.com',
password: 'AdminPass456!',
firstName: 'Jane',
lastName: 'Admin',
phoneNumber: '+49 30 87654321',
birthDate: '1985-06-20',
gender: 'female',
role: 'admin',
newsletterSubscription: true,
interests: ['technology', 'management', 'innovation'],
},
},
},
})
@ApiStandardResponse(UserResponseDto, 'User created successfully')
@ApiResponse({
status: 201,
description: 'User created successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 409,
description: 'Conflict - Email already exists',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
statusCode: { type: 'number', example: 409 },
error: {
type: 'object',
properties: {
type: { type: 'string', example: 'ConflictException' },
message: { type: 'string', example: 'Email already exists' },
},
},
},
},
})
create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.usersService.create(createUserDto);
}
@Get()
@ApiCrudOperation('User', 'read')
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number for pagination',
example: 1,
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of items per page',
example: 10,
})
@ApiQuery({
name: 'search',
required: false,
type: String,
description: 'Search term for name or email',
example: 'john',
})
@ApiQuery({
name: 'role',
required: false,
enum: UserRole,
description: 'Filter by user role',
example: UserRole.USER,
})
@ApiQuery({
name: 'isActive',
required: false,
type: Boolean,
description: 'Filter by active status',
example: true,
})
@ApiPaginatedResponse(UserResponseDto)
findAll(@Query() query: UserQueryDto): Promise<PaginationResult<UserResponseDto>> {
return this.usersService.findAll(query);
}
@Get(':id')
@ApiCrudOperation('User', 'read')
@ApiParam({
name: 'id',
type: String,
description: 'User UUID',
example: '550e8400-e29b-41d4-a716-446655440000',
format: 'uuid',
})
@ApiStandardResponse(UserResponseDto, 'User retrieved successfully')
@ApiResponse({
status: 404,
description: 'User not found',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
statusCode: { type: 'number', example: 404 },
error: {
type: 'object',
properties: {
type: { type: 'string', example: 'NotFoundException' },
message: { type: 'string', example: 'User not found' },
},
},
},
},
})
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<UserResponseDto> {
return this.usersService.findOne(id);
}
@Put(':id')
@ApiCrudOperation('User', 'update')
@ApiParam({
name: 'id',
type: String,
description: 'User UUID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@ApiBody({
type: UpdateUserDto,
description: 'Updated user data',
examples: {
profileUpdate: {
summary: 'Profile information update',
description: 'Updating basic profile information',
value: {
firstName: 'John',
lastName: 'Smith',
phoneNumber: '+49 30 11111111',
},
},
roleUpdate: {
summary: 'Role update (admin only)',
description: 'Updating user role (requires admin permissions)',
value: {
role: 'moderator',
updateReason: 'Promoted to moderator role',
},
},
},
})
@ApiStandardResponse(UserResponseDto, 'User updated successfully')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
@ApiCrudOperation('User', 'delete')
@ApiParam({
name: 'id',
type: String,
description: 'User UUID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@ApiResponse({
status: 204,
description: 'User deleted successfully',
})
@ApiResponse({
status: 403,
description: 'Forbidden - Cannot delete your own account',
})
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.usersService.remove(id);
}
}Custom Validation Decorators ermöglichen spezifische Geschäftsregeln und komplexe Validierungslogik.
// src/common/validators/business-validators.ts
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
ValidationOptions,
registerDecorator,
} from 'class-validator';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../users/entities/user.entity';
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async validate(email: string, args: ValidationArguments) {
if (!email) return true;
const user = await this.userRepository.findOne({ where: { email } });
// Bei Updates prüfen wir, ob die E-Mail zu einem anderen User gehört
if (args.object && (args.object as any).id) {
return !user || user.id === (args.object as any).id;
}
return !user;
}
defaultMessage(args: ValidationArguments) {
return `Email ${args.value} is already registered`;
}
}
export function IsEmailUnique(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsEmailUniqueConstraint,
});
};
}
@ValidatorConstraint({ name: 'isValidAge', async: false })
export class IsValidAgeConstraint implements ValidatorConstraintInterface {
validate(birthDate: string, args: ValidationArguments) {
if (!birthDate) return true;
const birth = new Date(birthDate);
const today = new Date();
if (isNaN(birth.getTime())) return false;
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
const [minAge, maxAge] = args.constraints;
return age >= minAge && age <= maxAge;
}
defaultMessage(args: ValidationArguments) {
const [minAge, maxAge] = args.constraints;
return `Age must be between ${minAge} and ${maxAge} years`;
}
}
export function IsValidAge(
minAge: number = 13,
maxAge: number = 120,
validationOptions?: ValidationOptions,
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [minAge, maxAge],
validator: IsValidAgeConstraint,
});
};
}
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
validate(password: string, args: ValidationArguments) {
if (!password) return true;
const requirements = {
minLength: password.length >= 8,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumbers: /\d/.test(password),
hasSpecialChars: /[!@#$%^&*(),.?":{}|<>]/.test(password),
noCommonPatterns: !this.hasCommonPatterns(password),
noPersonalInfo: !this.hasPersonalInfo(password, args.object),
};
return Object.values(requirements).every(Boolean);
}
private hasCommonPatterns(password: string): boolean {
const commonPatterns = [
/123456/,
/password/i,
/qwerty/i,
/abc123/i,
/(.)\1{2,}/, // 3 oder mehr gleiche Zeichen hintereinander
];
return commonPatterns.some(pattern => pattern.test(password));
}
private hasPersonalInfo(password: string, user: any): boolean {
if (!user) return false;
const personalInfo = [
user.firstName,
user.lastName,
user.email?.split('@')[0],
user.birthDate?.replace(/-/g, ''),
].filter(Boolean);
return personalInfo.some(info =>
password.toLowerCase().includes(info.toLowerCase())
);
}
defaultMessage() {
return 'Password must be at least 8 characters long and contain uppercase, lowercase, numbers, special characters, and not contain common patterns or personal information';
}
}
export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsStrongPasswordConstraint,
});
};
}
@ValidatorConstraint({ name: 'isBusinessDay', async: false })
export class IsBusinessDayConstraint implements ValidatorConstraintInterface {
validate(date: string, args: ValidationArguments) {
if (!date) return true;
const targetDate = new Date(date);
if (isNaN(targetDate.getTime())) return false;
const dayOfWeek = targetDate.getDay();
// 0 = Sonntag, 6 = Samstag
return dayOfWeek >= 1 && dayOfWeek <= 5;
}
defaultMessage() {
return 'Date must be a business day (Monday to Friday)';
}
}
export function IsBusinessDay(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsBusinessDayConstraint,
});
};
}
@ValidatorConstraint({ name: 'isFutureDate', async: false })
export class IsFutureDateConstraint implements ValidatorConstraintInterface {
validate(date: string, args: ValidationArguments) {
if (!date) return true;
const targetDate = new Date(date);
if (isNaN(targetDate.getTime())) return false;
const [minDaysFromNow] = args.constraints;
const minDate = new Date();
minDate.setDate(minDate.getDate() + (minDaysFromNow || 0));
return targetDate >= minDate;
}
defaultMessage(args: ValidationArguments) {
const [minDaysFromNow] = args.constraints;
if (minDaysFromNow > 0) {
return `Date must be at least ${minDaysFromNow} days in the future`;
}
return 'Date must be in the future';
}
}
export function IsFutureDate(
minDaysFromNow: number = 0,
validationOptions?: ValidationOptions,
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [minDaysFromNow],
validator: IsFutureDateConstraint,
});
};
}// src/users/dto/register-user.dto.ts
export class RegisterUserDto {
@ApiProperty({
description: 'User email address',
example: 'john.doe@example.com',
})
@IsEmail()
@ToLowerCase()
@Trim()
@IsEmailUnique({ message: 'This email is already registered' })
email: string;
@ApiProperty({
description: 'Strong password',
example: 'MySecure123!',
})
@IsString()
@IsStrongPassword()
password: string;
@ApiProperty({
description: 'First name',
example: 'John',
})
@IsString()
@IsNotEmpty()
@Length(1, 50)
@NormalizeSpaces()
firstName: string;
@ApiProperty({
description: 'Last name',
example: 'Doe',
})
@IsString()
@IsNotEmpty()
@Length(1, 50)
@NormalizeSpaces()
lastName: string;
@ApiProperty({
description: 'Birth date',
example: '1990-01-15',
})
@IsDateString()
@IsValidAge(13, 120, { message: 'You must be between 13 and 120 years old' })
birthDate: string;
@ApiProperty({
description: 'Terms acceptance',
example: true,
})
@IsBoolean()
@ToBoolean()
acceptTerms: boolean;
}
// src/events/dto/create-event.dto.ts
export class CreateEventDto {
@ApiProperty({
description: 'Event name',
example: 'Tech Conference 2024',
})
@IsString()
@IsNotEmpty()
@Length(5, 200)
@NormalizeSpaces()
name: string;
@ApiProperty({
description: 'Event start date',
example: '2024-06-15T09:00:00.000Z',
})
@IsDateString()
@IsFutureDate(7, { message: 'Event must be scheduled at least 7 days in advance' })
@IsBusinessDay({ message: 'Events can only be scheduled on business days' })
startDate: string;
@ApiProperty({
description: 'Event end date',
example: '2024-06-15T17:00:00.000Z',
})
@IsDateString()
@IsGreaterThanField('startDate', { message: 'End date must be after start date' })
endDate: string;
@ApiProperty({
description: 'Maximum attendees',
example: 100,
})
@IsNumber()
@Min(1)
@Max(10000)
maxAttendees: number;
}// src/common/pipes/optimized-validation.pipe.ts
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
Type,
} from '@nestjs/common';
import { validate, ValidationError } from 'class-validator';
import { plainToInstance } from 'class-transformer';
interface CacheEntry {
timestamp: number;
result: any;
}
@Injectable()
export class OptimizedValidationPipe implements PipeTransform<any> {
private cache = new Map<string, CacheEntry>();
private readonly cacheTtl = 5000; // 5 Sekunden
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
// Cache-Key generieren
const cacheKey = this.generateCacheKey(value, metatype);
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTtl) {
return cached.result;
}
const object = plainToInstance(metatype, value, {
enableImplicitConversion: true,
excludeExtraneousValues: true,
});
const errors = await validate(object, {
whitelist: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
validationError: {
target: false,
value: false,
},
});
if (errors.length > 0) {
throw new BadRequestException({
message: 'Validation failed',
errors: this.formatErrors(errors),
});
}
// Ergebnis cachen
this.cache.set(cacheKey, {
timestamp: Date.now(),
result: object,
});
// Cache-Cleanup
this.cleanupCache();
return object;
}
private toValidate(metatype: Type): boolean {
const types: Type[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
private generateCacheKey(value: any, metatype: Type): string {
const valueHash = JSON.stringify(value);
return `${metatype.name}:${valueHash}`;
}
private cleanupCache(): void {
if (this.cache.size > 1000) {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > this.cacheTtl) {
this.cache.delete(key);
}
}
}
}
private formatErrors(errors: ValidationError[]): any {
return errors.reduce((acc, error) => {
acc[error.property] = Object.values(error.constraints || {});
return acc;
}, {});
}
}// src/common/filters/validation-error.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
BadRequestException,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch(BadRequestException)
export class ValidationErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(ValidationErrorFilter.name);
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse() as any;
// Spezielle Behandlung für Validation-Errors
if (exceptionResponse.message === 'Validation failed' && exceptionResponse.errors) {
const formattedErrors = this.formatValidationErrors(exceptionResponse.errors);
const errorResponse = {
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
error: {
type: 'ValidationError',
message: 'Request validation failed',
details: formattedErrors,
summary: this.createErrorSummary(formattedErrors),
},
};
this.logger.warn(`Validation failed for ${request.method} ${request.url}`, {
errors: formattedErrors,
body: request.body,
});
response.status(status).json(errorResponse);
} else {
// Standard-Behandlung für andere BadRequest-Errors
const errorResponse = {
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
error: {
type: 'BadRequestException',
message: exceptionResponse.message || exception.message,
},
};
response.status(status).json(errorResponse);
}
}
private formatValidationErrors(errors: any): any {
const formatted = {};
Object.keys(errors).forEach(field => {
const fieldErrors = errors[field];
formatted[field] = {
messages: Array.isArray(fieldErrors) ? fieldErrors : [fieldErrors],
value: this.getFieldValue(field, errors),
};
});
return formatted;
}
private getFieldValue(field: string, errors: any): any {
// Try to extract the invalid value from error context
return errors[field]?.value || null;
}
private createErrorSummary(errors: any): string {
const fieldCount = Object.keys(errors).length;
const fields = Object.keys(errors).slice(0, 3).join(', ');
if (fieldCount <= 3) {
return `Validation failed for fields: ${fields}`;
} else {
return `Validation failed for ${fieldCount} fields: ${fields} and ${fieldCount - 3} more`;
}
}
}// src/common/dto/versioned-dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
export enum ApiVersion {
V1 = '1.0',
V2 = '2.0',
}
export abstract class VersionedDto {
@ApiProperty({
description: 'API version',
enum: ApiVersion,
default: ApiVersion.V2,
})
@IsOptional()
@IsEnum(ApiVersion)
version?: ApiVersion = ApiVersion.V2;
}
// src/users/dto/create-user-v1.dto.ts (Legacy)
export class CreateUserV1Dto extends VersionedDto {
@ApiProperty()
@IsEmail()
email: string;
@ApiProperty()
@IsString()
@MinLength(6) // Alte, weniger strenge Anforderung
password: string;
@ApiProperty()
@IsString()
name: string; // Ein Feld für den gesamten Namen
}
// src/users/dto/create-user-v2.dto.ts (Current)
export class CreateUserV2Dto extends VersionedDto {
@ApiProperty()
@IsEmail()
@IsEmailUnique()
email: string;
@ApiProperty()
@IsString()
@IsStrongPassword() // Neue, stärkere Validierung
password: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
firstName: string; // Getrennte Felder
@ApiProperty()
@IsString()
@IsNotEmpty()
lastName: string;
@ApiPropertyOptional()
@IsOptional()
@IsValidAge(13, 120)
birthDate?: string; // Neues Feld in V2
}
// src/users/dto/create-user.dto.ts (Version-agnostic)
export type CreateUserDto = CreateUserV1Dto | CreateUserV2Dto;
// Version-aware Controller
@Controller('users')
export class UsersController {
@Post()
@ApiOperation({ summary: 'Create user (version-aware)' })
create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
if (createUserDto.version === ApiVersion.V1) {
return this.usersService.createV1(createUserDto as CreateUserV1Dto);
}
return this.usersService.createV2(createUserDto as CreateUserV2Dto);
}
}// src/users/dto/__tests__/create-user.dto.spec.ts
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { CreateUserDto } from '../create-user.dto';
describe('CreateUserDto', () => {
function createDto(data: Partial<CreateUserDto>): CreateUserDto {
return plainToInstance(CreateUserDto, data);
}
describe('Valid scenarios', () => {
it('should validate with minimum required fields', async () => {
const dto = createDto({
email: 'test@example.com',
password: 'SecurePass123!',
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-01-15',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('should validate with all optional fields', async () => {
const dto = createDto({
email: 'test@example.com',
password: 'SecurePass123!',
firstName: 'John',
lastName: 'Doe',
phoneNumber: '+49 30 12345678',
birthDate: '1990-01-15',
gender: 'male',
role: 'user',
newsletterSubscription: true,
interests: ['technology', 'sports'],
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe('Email validation', () => {
it('should reject invalid email format', async () => {
const dto = createDto({
email: 'invalid-email',
password: 'SecurePass123!',
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-01-15',
});
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('email');
expect(errors[0].constraints?.isEmail).toContain('valid email');
});
it('should transform email to lowercase', () => {
const dto = createDto({
email: 'TEST@EXAMPLE.COM',
});
expect(dto.email).toBe('test@example.com');
});
});
describe('Password validation', () => {
it('should reject weak passwords', async () => {
const dto = createDto({
email: 'test@example.com',
password: '123456',
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-01-15',
});
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('password');
});
it('should accept strong passwords', async () => {
const dto = createDto({
email: 'test@example.com',
password: 'StrongPass123!',
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-01-15',
});
const errors = await validate(dto);
const passwordErrors = errors.filter(e => e.property === 'password');
expect(passwordErrors).toHaveLength(0);
});
});
describe('Array transformations', () => {
it('should transform comma-separated string to array', () => {
const dto = createDto({
interests: 'technology,sports,music' as any,
});
expect(dto.interests).toEqual(['technology', 'sports', 'music']);
});
it('should remove duplicates from interests', () => {
const dto = createDto({
interests: ['technology', 'sports', 'technology', 'music'],
});
expect(dto.interests).toEqual(['technology', 'sports', 'music']);
});
});
describe('Nested validation', () => {
it('should validate nested objects', async () => {
const dto = createDto({
email: 'test@example.com',
password: 'SecurePass123!',
firstName: 'John',
lastName: 'Doe',
birthDate: '1990-01-15',
address: {
street: 'Musterstraße 123',
city: 'Berlin',
postalCode: '10115',
countryCode: 'DE',
},
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});Datenvalidierung und DTOs sind grundlegende Säulen robuster NestJS-Anwendungen. Durch die konsequente Anwendung von class-validator und class-transformer, kombiniert mit durchdachten DTO-Designs und Custom Validators, entstehen APIs, die nicht nur sicher und zuverlässig sind, sondern auch eine exzellente Entwicklererfahrung bieten. Die Integration mit OpenAPI sorgt für automatische, immer aktuelle Dokumentation, während Best Practices für Performance und Wartbarkeit langfristig erfolgreiche Projekte ermöglichen.