Closed sanjitbauli closed 2 years ago
something like this I guess: https://github.com/typestack/class-validator/issues/169#issuecomment-571481577
I want to serve translated error messages. Is it possible to access i18n translate method inside DTO where i am using class-validator for validations or maybe inside the custom ValidationPipe where from i can translate the outgoing error messages.
I'm sure that there must be some why to accomplish this. But I don't know what would be the best way. I'll have to take a look into that later this week. Any suggestions are welcome!
I created a custom validation pipe for this, changed the error from 400 to 422. After validation I translate the errors and group them for each field. Hope it helps
@Post('/login')
login(@Body(I18nValidationPipe) authCredentialsDto: AuthCredentaialsDto)
import { ArgumentMetadata, Injectable, PipeTransform, UnprocessableEntityException } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import { I18nRequestScopeService } from "nestjs-i18n";
@Injectable()
export class I18nValidationPipe implements PipeTransform<any> {
constructor(private readonly i18n: I18nRequestScopeService) { }
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
const object = plainToClass(metatype, value);
const errors = await validate(object);
if (errors.length > 0) {
throw new UnprocessableEntityException(await this.translateErrors(errors), await this.i18n.translate("UNPROCESSABLE_ENTITY"));
}
return value;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
async translateErrors(errors: any) {
const data = [];
for (let i = 0; i < errors.length; i++) {
const message = await Promise.all(Object.values(errors[i].constraints).map(async value => await this.i18n.translate(value)));
data.push({ field: errors[i].property, message: message });
}
return data;
}
}
@cosinus84 great work! It looks good, I'll try and make a proposal this week :). One thing tho, why do you use the I18nRequestScopeService
isn't it better to capture the validation exception
in a custom filter
and then use this code to format the response? Cause this way each request
creates an other I18nValidationPipe
, I'm not sure what this means on performance, but I think this can be slightly optimized :)
But nonetheless great work! :tada:
Thanks! you are right, I was thinking first of using exception filter but I gave up as I am new in nestjs&ts. Of course the problem of parameters still exists but for the moment predefined keys can do the job.
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { ValidationError } from 'class-validator/validation/ValidationError';
import { I18nService } from 'nestjs-i18n';
async catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
return response.status(exception.getStatus())
.json(await this.getMessage(exception, ctx.getRequest().i18nLang));
}
async getMessage(exception: HttpException, lang: string) {
const exceptionResponse = exception.getResponse() as any;
if (exceptionResponse.hasOwnProperty("message")) {
if (exceptionResponse.message instanceof Array) {
exceptionResponse.message = await this.translateArray(exceptionResponse.message, lang);
} else if (typeof exceptionResponse.message === "string") {
exceptionResponse.message = await this.i18n.translate(exceptionResponse.message, { lang: lang })
}
}
return exceptionResponse;
}
async translateArray(errors: any[], lang: string) {
const data = [];
for (let i = 0; i < errors.length; i++) {
const item = errors[i];
if (typeof item === "string") {
data.push(await this.i18n.translate(item, { lang: lang }));
continue;
} else if (item instanceof ValidationError) {
const message = await Promise.all(Object.values(item.constraints)
.map(async (value: string) => await this.i18n.translate(value, { lang: lang })));
data.push({ field: item.property, message: message });
continue;
}
data.push(item);
}
return data;
}
}
on app.module.ts
....
I18nModule.forRootAsync({
imports:[ConfigModule],
inject:[ConfigService],
useFactory: (configService: ConfigService) => ({
fallbackLanguage: configService.get('fallbackLanguage'),
parserOptions: {
path: path.join(__dirname, '/i18n/'),
watch: true,
},
}),
resolvers: [
new HeaderResolver(['x-lang']),
],
parser: I18nJsonParser,
}),
....
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
It works also with default ValidationPipe
[
"auth.EMAIL_MAX_INVALID",
"[FR]Email invalid!",
"[FR]Password invalid!"
]
if you return array of ValidationErrors https://github.com/ToonvanStrijp/nestjs-i18n/issues/97#issuecomment-608263029
{
"statusCode": 422,
"message": [
{
"field": "email",
"message": [
"auth.EMAIL_MAX_INVALID",
"[FR]Email invalid!"
]
},
{
"field": "password",
"message": [
"[FR]Password invalid!"
]
}
],
"error": "Unprocessable Entity"
}
Hi, Not sure this thread is right to ask this but @ToonvanStrijp how I can use translations in middlewares?
@dharmvir-patel you can inject the I18nService
inside your middleware and use it to translate your responses based on a label for example. I need more details to give better advice since the use of middleware is very broad so I can't give you a very precise answer, if you need one post your question on stack overflow with a bit more details of what you're trying to achieve.
@ToonvanStrijp Thanks, I got it working using I18nService, in which I need to pass lang as an option. I wanted to know Is there another way of doing it just like I18n decorator where we do not need to pass the language as an option. FYI I am using a simple middleware where I am validating few request parameters globally.
@cosinus84 Nice approach, personally I don't think I can afford this much customization to handle localization in my project and as you already mentioned we can't have dynamic parameters/args there.
Do we have any update on considering this for the next release/version ? Localizing validation messages seems like a big deal.
@Kannas24 I'm not sure how to design this in a good way yet... So if you have suggestions please let me know :)
If the design is clean I can easily build it and create a new release! 🎉
We somehow decided to pass keys so the frontend can use them for translation. I also want to point that dto can have nested dto: (this is from service behind, otherwise this.configService.get("NODE_ENV") can be used to control the debug)
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import * as _ from 'lodash';
interface FilterResponse {
statusCode: number,
message: string | any,
debug: any
}
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) { }
/**
* - No need to pass key message for default exception message(use only BadRequestException()
* and not BadRequestException("BAD_REQUEST")).
* - Transforms ValidationError
*
* @param exception
* @param host
*
*/
async catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception.getStatus() ?? 500;
return response.status(status)
.json(await this.getMessage(exception, status));
}
private async getMessage(exception: HttpException, status: number) {
const response = exception.getResponse() as any;
const statusValue = HttpStatus[status];
const result = {
message: statusValue,
debug: { response: response, stack: exception.stack }
} as FilterResponse;
//
if (typeof response === "object" && response.hasOwnProperty("message")) {
const message = response.message;
if (message) {
if (_.isString(message)) {
result.message = _.snakeCase(message).toUpperCase();
} else if (_.isArray(message) && message[0] instanceof ValidationError) {
result.message = this.parseValidationErrors(message as ValidationError[]);
}
}
}
this.logger.error(result);
return result;
}
private parseValidationErrors(validations: ValidationError[]) {
return validations.map(e => {
return {
[e.property]: e.constraints ? Object.values(e.constraints) : this.parseValidationErrors(e.children)
}
})
}
}
//dto will look something like
@ApiProperty({
type: String,
example: "John"
})
@IsString({ message: FieldValidationMessage.VALUE_MUST_BE_A_STRING })
first_name: string;
{
"message": [
{
"body": [
"VALUE_MUST_BE_A_STRING"
]
}
],
"debug": {
"response": {
"statusCode": 422,
"error": "Unprocessable Entity",
"message": [
{
"target": {
"body": 1
},
"value": 1,
"property": "body",
"children": [],
"constraints": {
"isString": "VALUE_MUST_BE_A_STRING"
}
}
]
},
"stack": "Error: [object Object]\n at AppValidationPipe.exceptionFactory (/Users/cosinus/projects/iapp2/clients-service/dist/common/pipes/app-validation.pipe.js:16:23)\n at AppValidationPipe.transform (/Users/cosinus/projects/iapp2/clients-service/node_modules/@nestjs/common/pipes/validation.pipe.js:50:24)\n at processTicksAndRejections (internal/process/task_queues.j ...
//nested
{
"message": [
{
"where_or": [
{
"email": [
"VALUE_MIN_LENGTH_3"
]
}
]
}
],
"debug": {
"response": {
"statusCode": 422,
The approach from @cosinus84 in this comment is an interesting approach to fix to class-validator i18n support. I got a different solution which would be a better approach but still only allows maybe 80% of the total decorators of class-validator.
First of all, my translateErrors
that @cosinus84 is using is a bit "dangerous" as it uses await
inside a loop which is a big noop.
Another thing is that every class-validator has it's own key. For example: @IsString()
has isString
, @MaxLength()
has maxLength
(It's always a titlecased version of the decorator's name). Therefore I am using Object.keys()
inside the translateErrors
function rather than Object.values()
like so:
async translateErrors(errors: any) {
const data = [];
errors.forEach((error) => {
const message = await Promise.all(
Object
.keys(error.constraints)
.map(async key => await this.i18n.translate(`validation.${key}`, {
args: { property: error.property }
}))
);
data.push({ field: error.property, message });
});
return data;
}
As you see I also add { args: { property: error.property } }
as a second function argument to the translate
method. This allows us to do the following inside /src/i18n/en/validation.json
:
{
"isString": "{property} must be a string",
"isNotEmpty": "{property} can't be empty"
}
The only thing I am looking is a solution for those with $constraints
. For example: @Length(min, max)
has two constrains named min
and max
and these are not passed to the ValidationError
. If we have these values then we can make it the class-validator completely i18n in a manual way.
I suggest to close this issue and leave this up to the class-validator package, since its them who create the content people in this issue want to be translated. Unfortunately they're slow with implementing this so people are looking for different approaches. This could be a temp option.
I managed to get a 100% translatable class-validator just based on my previous comment which I'd like to share:
NOTE: I do use i18next
in the example below, but any other custom service would do.
💡 What I'm doing below is basically taking the generated message, match it against a list of patterns to find out the original template that was being used and then replace the replaced values back to their original constraint names such as $constraint1
and $constraint2
. After that, we can simply use this whole string as the key for our translation.
🎉 The code below is a fully working version with support for English and Chinese.
⚠️ I am aware of typestack/class-validator#743 but since it hasn't been merged and the code is not ready to use, I had to come up with another way. If you need a translation in your language, you can take one from here.
The next file are all the patterns that we want to match. This is being used later to take the constraints values and revert them back to $constraint1
and $constraint2
.
If you want to add a custom language, you can choose a JSON file from here. The only thing you had to do in order to fix a key conflict is to change the following keys:
{
"$property is $constraint1": "$property is constraint1",
"maximal allowed date for ": "maximal allowed date for",
"minimal allowed date for ": "maximal allowed date for"
}
into
{
"maximal allowed date for $property is $constraint1": "maximal allowed date for {{property}} is {{constraint1}}",
"minimal allowed date for $property is $constraint1": "minimal allowed date for {{property}} is {{constraint1}}"
}
@kkoomen I use you code, but I had to change all $variables
to {{variables}}
. Maybe it's related with i18n version ;)
is there any possible method to render a template when DTO validation throws an error?
@YashKumarVerma you can use an interceptor for that. At the moment nestjs-18n
doens't provide a way to translate DTO error
automatically. But you could implement your own way if you like. I'm still looking for a good way to provide this within the package.
@YashKumarVerma you can use an interceptor for that. At the moment
nestjs-18n
doens't provide a way to translateDTO error
automatically. But you could implement your own way if you like. I'm still looking for a good way to provide this within the package.
Dto translation will be a nice feature to add to the library
@kkoomen Very cool! Based on your solution I've modified it to be able to translate errors on front-end + to give me field name where the error should be visible.
The code
import {
UnprocessableEntityException,
ValidationError,
ValidationPipe,
ValidationPipeOptions,
} from '@nestjs/common';
const classValidationPatterns = [
'$IS_INSTANCE decorator expects and object as value, but got falsy value.',
'$property is not a valid decimal number.',
'$property must be a BIC or SWIFT code',
'$property must be a boolean string',
'$property must be a boolean value',
'$property must be a BTC address',
'$property must be a credit card',
'$property must be a currency',
'$property must be a data uri format',
'$property must be a Date instance',
'$property must be a Firebase Push Id',
'$property must be a hash of type (.+)',
'$property must be a hexadecimal color',
'$property must be a hexadecimal number',
'$property must be a HSL color',
'$property must be a identity card number',
'$property must be a ISSN',
'$property must be a json string',
'$property must be a jwt string',
'$property must be a latitude string or number',
'$property must be a latitude,longitude string',
'$property must be a longitude string or number',
'$property must be a lowercase string',
'$property must be a MAC Address',
'$property must be a mongodb id',
'$property must be a negative number',
'$property must be a non-empty object',
'$property must be a number conforming to the specified constraints',
'$property must be a number string',
'$property must be a phone number',
'$property must be a port',
'$property must be a positive number',
'$property must be a postal code',
'$property must be a Semantic Versioning Specification',
'$property must be a string',
'$property must be a valid domain name',
'$property must be a valid enum value',
'$property must be a valid ISO 8601 date string',
'$property must be a valid ISO31661 Alpha2 code',
'$property must be a valid ISO31661 Alpha3 code',
'$property must be a valid phone number',
'$property must be a valid representation of military time in the format HH:MM',
'$property must be an array',
'$property must be an EAN (European Article Number)',
'$property must be an email',
'$property must be an Ethereum address',
'$property must be an IBAN',
'$property must be an instance of (.+)',
'$property must be an integer number',
'$property must be an ip address',
'$property must be an ISBN',
'$property must be an ISIN (stock/security identifier)',
'$property must be an ISRC',
'$property must be an object',
'$property must be an URL address',
'$property must be an UUID',
'$property must be base32 encoded',
'$property must be base64 encoded',
'$property must be divisible by (.+)',
'$property must be empty',
'$property must be equal to (.+)',
'$property must be locale',
'$property must be longer than or equal to (\\S+) and shorter than or equal to (\\S+) characters',
'$property must be longer than or equal to (\\S+) characters',
'$property must be magnet uri format',
'$property must be MIME type format',
'$property must be one of the following values: (\\S+)',
'$property must be RFC 3339 date',
'$property must be RGB color',
'$property must be shorter than or equal to (\\S+) characters',
'$property must be shorter than or equal to (\\S+) characters',
'$property must be uppercase',
'$property must be valid octal number',
'$property must be valid passport number',
'$property must contain (\\S+) values',
'$property must contain a (\\S+) string',
'$property must contain a full-width and half-width characters',
'$property must contain a full-width characters',
'$property must contain a half-width characters',
'$property must contain any surrogate pairs chars',
'$property must contain at least (\\S+) elements',
'$property must contain not more than (\\S+) elements',
'$property must contain one or more multibyte chars',
'$property must contain only ASCII characters',
'$property must contain only letters (a-zA-Z)',
'$property must contain only letters and numbers',
'$property must match (\\S+) regular expression',
'$property must not be greater than (.+)',
'$property must not be less than (.+)',
'$property should not be empty',
'$property should not be equal to (.+)',
'$property should not be null or undefined',
'$property should not be one of the following values: (.+)',
'$property should not contain (\\S+) values',
'$property should not contain a (\\S+) string',
"$property's byte length must fall into \\((\\S+), (\\S+)\\) range",
"All $property's elements must be unique",
'each value in ',
'maximal allowed date for $property is (.+)',
'minimal allowed date for $property is (.+)',
'nested property $property must be either object or array',
];
/**
* The class-validator package does not support i18n and thus we will
* translate the error messages ourselves.
*/
function translateErrors(validationErrors: ValidationError[]) {
const errors = validationErrors.map((error: ValidationError): string[] => Object.keys(error.constraints).map((key: string): any => { /*
Real type:
{
field: string;
txt: string;
params: {
[key: string]: any;
};
}
*/
let match: string[] | null;
let constraint: string;
// Find the matching pattern.
for (const validationPattern of classValidationPatterns) {
const pattern = validationPattern.replace('$', '\\$');
constraint = error.constraints[key].replace(error.property, '$property');
match = new RegExp(pattern, 'g').exec(constraint);
if (match) {
break;
}
}
// Replace the constraints values back to the $constraintX words.
let i18nKey = constraint;
const replacements = { property: error.property };
if (match) {
for (let i = 1; i < match.length; i += 1) {
i18nKey = i18nKey.replace(match[i], `{{constraint${i}}}`);
replacements[`constraint${i}`] = match[i];
}
}
// Get the i18n text.
return {
field: error.property,
txt: i18nKey.replace('$property', '{{property}}'),
params: replacements,
};
}));
const errorsFlattened = errors.reduce((data: string[], errors) => {
data.push(...errors);
return data;
}, []);
return new UnprocessableEntityException(errorsFlattened);
}
export const createValidationPipeWithI18n = (options: ValidationPipeOptions) => new ValidationPipe({
...options,
exceptionFactory: translateErrors,
});
And demo
@Ami777 Your code work fine but if you have nested dto not work correctly I made a little fix
/**
* The class-validator package does not support i18n and thus we will
* translate the error messages ourselves.
*/
export function translateErrors(validationErrors: ValidationError[]) {
const errors = validationErrors.map((error: ValidationError): string[] => {
const errosRecursive = recursiveSearch(error, 'constraints');
if (error.constraints) {
return parserErrors(error);
}
for (let x = 0, l = errosRecursive.length; x < l; x++) {
const errorRecursive = errosRecursive[x];
if (errorRecursive.constraints) {
return parserErrors(errorRecursive);
}
}
});
const errorsFlattened = errors.reduce((data: string[], errors) => {
data.push(...errors);
return data;
}, []);
return new UnprocessableEntityException(errorsFlattened);
}
function parserErrors(error): string[] {
return Object.keys(error.constraints).map((key: string): any => {
let match: string[] | null;
let constraint: string;
// Find the matching pattern.
for (const validationPattern of classValidationPatterns) {
const pattern = validationPattern.replace('$', '\\$');
constraint = error.constraints[key].replace(error.property, '$property');
match = new RegExp(pattern, 'g').exec(constraint);
if (match) {
break;
}
}
// Replace the constraints values back to the $constraintX words.
let i18nKey = constraint;
const replacements = { property: error.property };
if (match) {
for (let i = 1; i < match.length; i += 1) {
i18nKey = i18nKey.replace(match[i], `{constraint${i}}`);
replacements[`constraint${i}`] = match[i];
}
}
return {
field: error.property,
txt: i18nKey.replace('$property', '{property}'),
params: replacements,
};
});
}
const recursiveSearch = (obj, searchKey, results = []) => {
const r = results;
Object.keys(obj).forEach((key) => {
const value = obj[key];
if (key === searchKey) {
r.push(obj);
} else if (typeof value === 'object') {
recursiveSearch(value, searchKey, r);
}
});
return r;
};
My implementing:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
@Catch(HttpException)
export class TranslateException implements ExceptionFilter {
constructor(private readonly i18n: I18nService) {}
async catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const locale = ctx.getRequest().i18nLang;
const exceptionResponse = exception.getResponse() as any;
if (typeof exceptionResponse.message === 'string') {
exceptionResponse.message = await this.i18n.translate(
exceptionResponse.message,
{
lang: locale,
},
);
}
if (Array.isArray(exceptionResponse.message)) {
for (const item of exceptionResponse.message) {
for (const child in item) {
const [error, attr] = item[child].split('|');
item[child] = await this.i18n.translate(error, {
lang: locale,
args: attr ? JSON.parse(attr) : {},
});
}
}
}
return response.status(exception.getStatus()).json(exceptionResponse);
}
}
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
stopAtFirstError: true,
exceptionFactory: (errors: ValidationError[]) => {
const errorList = [];
errors.forEach((item) => {
errorList.push({
[item.property]: Object.values(item.constraints)[0],
});
});
return new BadRequestException(errorList);
},
}),
);
Use:
throw new BadRequestException('auth.error.login');
or
export class CreateUserDto {
@Validate(IsUserEmailExists)
@IsEmail(
{},
{
message: 'register.error.IsEmail',
},
)
@IsNotEmpty({
message: 'register.error.IsNotEmpty',
})
email: string;
@Length(8, 32, {
message:
'register.error.Length|{"min":$constraint1,"max":$constraint2}',
})
@IsNotEmpty({
message: .register.error.IsNotEmpty',
})
password: string;
}
This feature is now released in V9! See the documentation here 🌮 🎉
Cool <3
I18nValidationExceptionFilter
has errorFormatter
parameter that only allow to format errors
param in the response body what if we want to format the entire response like statusCode
to status
, message
to msg
or add new param in response body etc ... errorFormatter
not fully allow to format the response body
valid documentation link: here
This feature is now released in V9! See the documentation here taco tada
@sanjitbauli can you please explain in detail what you mean by DTO validation message?