Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/common/pipes/validation.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
import { loadPackage } from '../utils/load-package.util';
import { isNil, isUndefined } from '../utils/shared.utils';

/**
* @publicApi
*/
export type ValidationErrorFormat = 'list' | 'grouped';

/**
* @publicApi
*/
Expand All @@ -33,6 +38,18 @@ export interface ValidationPipeOptions extends ValidatorOptions {
expectedType?: Type<any>;
validatorPackage?: ValidatorPackage;
transformerPackage?: TransformerPackage;
/**
* Specifies the format of validation error messages.
* - 'list': Returns an array of error message strings (default). The response message is `string[]`.
* - 'grouped': Returns an object with property paths as keys and arrays of unmodified error messages as values.
* The response message is `Record<string, string[]>`. Custom messages defined in validation decorators
* (e.g., `@IsNotEmpty({ message: 'Name is required' })`) are preserved without parent path prefixes.
*
* @remarks
* When using 'grouped', the `message` property in the error response changes from `string[]` to `Record<string, string[]>`.
* If you have exception filters or interceptors that assume `message` is always an array, they will need to be updated.
*/
errorFormat?: ValidationErrorFormat;
}

let classValidator: ValidatorPackage = {} as any;
Expand All @@ -59,6 +76,7 @@ export class ValidationPipe implements PipeTransform<any> {
protected expectedType: Type<any> | undefined;
protected exceptionFactory: (errors: ValidationError[]) => any;
protected validateCustomDecorators: boolean;
protected errorFormat: ValidationErrorFormat;

constructor(@Optional() options?: ValidationPipeOptions) {
options = options || {};
Expand All @@ -69,6 +87,7 @@ export class ValidationPipe implements PipeTransform<any> {
expectedType,
transformOptions,
validateCustomDecorators,
errorFormat,
...validatorOptions
} = options;

Expand All @@ -81,6 +100,7 @@ export class ValidationPipe implements PipeTransform<any> {
this.validateCustomDecorators = validateCustomDecorators || false;
this.errorHttpStatusCode = errorHttpStatusCode || HttpStatus.BAD_REQUEST;
this.expectedType = expectedType;
this.errorFormat = errorFormat || 'list';
this.exceptionFactory =
options.exceptionFactory || this.createExceptionFactory();

Expand Down Expand Up @@ -183,6 +203,12 @@ export class ValidationPipe implements PipeTransform<any> {
if (this.isDetailedOutputDisabled) {
return new HttpErrorByCode[this.errorHttpStatusCode]();
}
if (this.errorFormat === 'grouped') {
const errors = this.groupValidationErrors(validationErrors);
return new HttpErrorByCode[this.errorHttpStatusCode]({
message: errors,
});
}
const errors = this.flattenValidationErrors(validationErrors);
return new HttpErrorByCode[this.errorHttpStatusCode](errors);
};
Expand Down Expand Up @@ -310,6 +336,25 @@ export class ValidationPipe implements PipeTransform<any> {
.toArray();
}

protected groupValidationErrors(
validationErrors: ValidationError[],
parentPath?: string,
): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const error of validationErrors) {
const path = parentPath
? `${parentPath}.${error.property}`
: error.property;
if (error.constraints) {
result[path] = Object.values(error.constraints);
}
if (error.children && error.children.length) {
Object.assign(result, this.groupValidationErrors(error.children, path));
}
}
return result;
}

protected mapChildrenToValidationErrors(
error: ValidationError,
parentPath?: string,
Expand Down
81 changes: 81 additions & 0 deletions packages/common/test/pipes/validation.pipe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IsArray,
IsBoolean,
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
Expand Down Expand Up @@ -183,6 +184,86 @@ describe('ValidationPipe', () => {
]);
}
});

describe('when errorFormat is "grouped"', () => {
beforeEach(() => {
target = new ValidationPipe({ errorFormat: 'grouped' });
});

it('should return grouped errors with property paths as keys', async () => {
try {
const model = new TestModelWithNested();
model.test = new TestModel2();
await target.transform(model, {
type: 'body',
metatype: TestModelWithNested,
});
} catch (err) {
expect(err.getResponse().message).to.be.eql({
prop: ['prop must be a string'],
'test.prop1': ['prop1 must be a string'],
'test.prop2': ['prop2 must be a boolean value'],
});
}
});

it('should return grouped errors for nested arrays', async () => {
try {
const model = new TestModelForNestedArrayValidation();
model.test = [new TestModel2()];
await target.transform(model, {
type: 'body',
metatype: TestModelForNestedArrayValidation,
});
} catch (err) {
expect(err.getResponse().message).to.be.eql({
prop: ['prop must be a string'],
'test.0.prop1': ['prop1 must be a string'],
'test.0.prop2': ['prop2 must be a boolean value'],
});
}
});

class NestedChildWithCustomMessage {
@IsNotEmpty({ message: 'Name is required' })
@IsString({ message: 'Name must be a string' })
name: string;
}

class ParentWithCustomMessage {
@IsString()
title: string;

@IsDefined()
@Type(() => NestedChildWithCustomMessage)
@ValidateNested()
child: NestedChildWithCustomMessage;
}

it('should preserve custom validation messages without prepending parent path', async () => {
try {
const model = new ParentWithCustomMessage();
model.child = new NestedChildWithCustomMessage();
await target.transform(model, {
type: 'body',
metatype: ParentWithCustomMessage,
});
} catch (err) {
const message = err.getResponse().message;
expect(message.title).to.be.eql(['title must be a string']);
// Custom messages should be preserved without 'child.' prefix in the message itself
expect(message['child.name']).to.have.members([
'Name is required',
'Name must be a string',
]);
// Verify custom messages don't have parent path prepended
expect(message['child.name']).to.not.include.members([
'child.Name is required',
'child.Name must be a string',
]);
}
});
});
});
describe('when validation transforms', () => {
it('should return a TestModel instance', async () => {
Expand Down
Loading