toonvanstrijp / nestjs-i18n

The i18n module for nestjs.
https://nestjs-i18n.com
Other
643 stars 108 forks source link

add support for DTO validation message #97

Closed sanjitbauli closed 2 years ago

toonvanstrijp commented 4 years ago

@sanjitbauli can you please explain in detail what you mean by DTO validation message?

aegyed91 commented 4 years ago

something like this I guess: https://github.com/typestack/class-validator/issues/169#issuecomment-571481577

sanjitbauli commented 4 years ago

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.

toonvanstrijp commented 4 years ago

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!

cosinus84 commented 4 years ago

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;
  }
}
toonvanstrijp commented 4 years ago

@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:

cosinus84 commented 4 years ago

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"
}
dharmvir-patel commented 4 years ago

Hi, Not sure this thread is right to ask this but @ToonvanStrijp how I can use translations in middlewares?

toonvanstrijp commented 4 years ago

@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.

dharmvir-patel commented 4 years ago

@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.

Kannas24 commented 4 years ago

@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.

toonvanstrijp commented 4 years ago

@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! 🎉

cosinus84 commented 4 years ago

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,
kkoomen commented 4 years ago

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.

kkoomen commented 3 years ago

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.

Explanation

💡 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.

Code

Validation pipe

src/pipes/validation.pipe.ts ```ts import { ArgumentMetadata, HttpStatus, UnprocessableEntityException, ValidationError, ValidationPipe } from '@nestjs/common'; import i18next from 'i18next'; import { classValidationPatterns } from 'src/i18n/class-validator-patterns'; /** * 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): string => { 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. const text = i18next.t(i18nKey, { replace: replacements, ns: 'validation', keySeparator: false, nsSeparator: false, }); return text; })); const errorsFlattened = errors.reduce((data: string[], errors) => { data.push(...errors); return data; }, []); return new UnprocessableEntityException(errorsFlattened); } export const ValidationPipe = new ValidationPipe({ exceptionFactory: translateErrors, }); ```

Validation patterns

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.

src/i18n/class-validator-patterns.ts ```ts export 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', ]; ```

Init i18next

src/i18n/index.ts ```ts import appTranslationsEN from './locales/en/app.json'; import validationEN from './locales/en/validation.json'; import appTranslationsZH from './locales/zh/app.json'; import validationZH from './locales/zh/validation.json'; i18next.init({ fallbackLng: Language.ZH, lng: Language.ZH, nsSeparator: false, resources: { en: { translation: appTranslationsEN, validation: validationEN }, zh: { translation: appTranslationsZH, validation: validationZH }, }, }); ```

Locales

src/i18n/locales/en/validation.json ```json { "$IS_INSTANCE decorator expects and object as value, but got falsy value.": "$IS_INSTANCE decorator expects and object as value, but got falsy value", "$property is not a valid decimal number.": "{{property}} is not a valid decimal number", "$property must be a BIC or SWIFT code": "{{property}} must be a BIC or SWIFT code", "$property must be a boolean string": "{{property}} must be a boolean string", "$property must be a boolean value": "{{property}} must be a boolean value", "$property must be a BTC address": "{{property}} must be a BTC address", "$property must be a credit card": "{{property}} must be a credit card", "$property must be a currency": "{{property}} must be a currency", "$property must be a data uri format": "{{property}} must be a data uri format", "$property must be a Date instance": "{{property}} must be a Date instance", "$property must be a Firebase Push Id": "{{property}} must be a Firebase Push Id", "$property must be a hash of type $constraint1": "{{property}} must be a hash of type {{constraint1}}", "$property must be a hexadecimal color": "{{property}} must be a hexadecimal color", "$property must be a hexadecimal number": "{{property}} must be a hexadecimal number", "$property must be a HSL color": "{{property}} must be a HSL color", "$property must be a identity card number": "{{property}} must be a identity card number", "$property must be a ISSN": "{{property}} must be a ISSN", "$property must be a json string": "{{property}} must be a JSON string", "$property must be a jwt string": "{{property}} must be a JWT string", "$property must be a latitude string or number": "{{property}} must be a latitude string or number", "$property must be a latitude,longitude string": "{{property}} must be a latitude,longitude string", "$property must be a longitude string or number": "{{property}} must be a longitude string or number", "$property must be a lowercase string": "{{property}} must be a lowercase string", "$property must be a MAC Address": "{{property}} must be a MAC Address", "$property must be a mongodb id": "{{property}} must be a Mongodb ID", "$property must be a negative number": "{{property}} must be a negative number", "$property must be a non-empty object": "{{property}} must be a non-empty object", "$property must be a number conforming to the specified constraints": "{{property}} must be a number conforming to the specified constraints", "$property must be a number string": "{{property}} must be a number string", "$property must be a phone number": "{{property}} must be a phone number", "$property must be a port": "{{property}} must be a port", "$property must be a positive number": "{{property}} must be a positive number", "$property must be a postal code": "{{property}} must be a postal code", "$property must be a Semantic Versioning Specification": "{{property}} must be a Semantic Versioning Specification", "$property must be a string": "{{property}} must be a string", "$property must be a valid domain name": "{{property}} must be a valid domain name", "$property must be a valid enum value": "{{property}} must be a valid enum value", "$property must be a ISOString": "{{property}} must be a valid ISO string", "$property must be a valid ISO 8601 date string": "{{property}} must be a valid ISO 8601 date string", "$property must be a valid ISO31661 Alpha2 code": "{{property}} must be a valid ISO31661 Alpha2 code", "$property must be a valid ISO31661 Alpha3 code": "{{property}} must be a valid ISO31661 Alpha3 code", "$property must be a valid phone number": "{{property}} must be a valid phone number", "$property must be a valid representation of military time in the format HH:MM": "{{property}} must be a valid representation of military time in the format HH:MM", "$property must be an array": "{{property}} must be an array", "$property must be an EAN (European Article Number)": "{{property}} must be an EAN (European Article Number)", "$property must be an email": "{{property}} must be an email", "$property must be an Ethereum address": "{{property}} must be an Ethereum address", "$property must be an IBAN": "{{property}} must be an IBAN", "$property must be an instance of $constraint1": "{{property}} must be an instance of {{constraint1}}", "$property must be an integer number": "{{property}} must be an integer number", "$property must be an ip address": "{{property}} must be an ip address", "$property must be an ISBN": "{{property}} must be an ISBN", "$property must be an ISIN (stock/security identifier)": "{{property}} must be an ISIN (stock/security identifier)", "$property must be an ISRC": "{{property}} must be an ISRC", "$property must be an object": "{{property}} must be an object", "$property must be an URL address": "{{property}} must be an URL address", "$property must be an UUID": "{{property}} must be an UUID", "$property must be base32 encoded": "{{property}} must be base32 encoded", "$property must be base64 encoded": "{{property}} must be base64 encoded", "$property must be divisible by $constraint1": "{{property}} must be divisible by {{constraint1}}", "$property must be empty": "{{property}} must be empty", "$property must be equal to $constraint1": "{{property}} must be equal to {{constraint1}}", "$property must be locale": "{{property}} must be locale", "$property must be longer than or equal to $constraint1 and shorter than or equal to $constraint2 characters": "{{property}} must be longer than or equal to {{constraint1}} and shorter than or equal to {{constraint2}} characters", "$property must be longer than or equal to $constraint1 characters": "{{property}} must be longer than or equal to {{constraint1}} characters", "$property must be magnet uri format": "{{property}} must be magnet uri format", "$property must be MIME type format": "{{property}} must be MIME type format", "$property must be one of the following values: $constraint1": "{{property}} must be one of the following values: {{constraint1}}", "$property must be RFC 3339 date": "{{property}} must be RFC 3339 date", "$property must be RGB color": "{{property}} must be RGB color", "$property must be shorter than or equal to $constraint1 characters": "{{property}} must be shorter than or equal to {{constraint1}} characters", "$property must be shorter than or equal to $constraint2 characters": "{{property}} must be shorter than or equal to {{constraint2}} characters", "$property must be uppercase": "{{property}} must be uppercase", "$property must be valid octal number": "{{property}} must be valid octal number", "$property must be valid passport number": "{{property}} must be valid passport number", "$property must contain $constraint1 values": "{{property}} must contain {{constraint1}} values", "$property must contain a $constraint1 string": "{{property}} must contain a {{constraint1}} string", "$property must contain a full-width and half-width characters": "{{property}} must contain a full-width and half-width characters", "$property must contain a full-width characters": "{{property}} must contain a full-width characters", "$property must contain a half-width characters": "{{property}} must contain a half-width characters", "$property must contain any surrogate pairs chars": "{{property}} must contain any surrogate pairs chars", "$property must contain at least $constraint1 elements": "{{property}} must contain at least {{constraint1}} elements", "$property must contain not more than $constraint1 elements": "{{property}} must contain not more than {{constraint1}} elements", "$property must contain one or more multibyte chars": "{{property}} must contain one or more multibyte chars", "$property must contain only ASCII characters": "{{property}} must contain only ASCII characters", "$property must contain only letters (a-zA-Z)": "{{property}} must contain only letters (a-zA-Z)", "$property must contain only letters and numbers": "{{property}} must contain only letters and numbers", "$property must match $constraint1 regular expression": "{{property}} must match {{constraint1}} regular expression", "$property must not be greater than $constraint1": "{{property}} must not be greater than {{constraint1}}", "$property must not be less than $constraint1": "{{property}} must not be less than {{constraint1}}", "$property should not be empty": "{{property}} should not be empty", "$property should not be equal to $constraint1": "{{property}} should not be equal to {{constraint1}}", "$property should not be null or undefined": "{{property}} should not be null or undefined", "$property should not be one of the following values: $constraint1": "{{property}} should not be one of the following values: {{constraint1}}", "$property should not contain $constraint1 values": "{{property}} should not contain {{constraint1}} values", "$property should not contain a $constraint1 string": "{{property}} should not contain a {{constraint1}} string", "$property's byte length must fall into ($constraint1, $constraint2) range": "{{property}}'s byte length must fall into ({{constraint1}}, {{constraint2}}) range", "All $property's elements must be unique": "All {{property}}'s elements must be unique", "each value in ": "each value in ", "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}}", "nested property $property must be either object or array": "nested property {{property}} must be either object or array" } ```
src/i18n/locales/zh/validation.json ```json { "$IS_INSTANCE decorator expects and object as value, but got falsy value.": "$IS_INSTANCE装饰者期望值和对象作为值,但却获得虚假值", "$property is not a valid decimal number.": "{{property}}不是一个有效的十进制数字", "$property must be a BIC or SWIFT code": "{{property}}必须是BIC或SWIFT代码", "$property must be a boolean string": "{{property}}必须是一个布尔值", "$property must be a boolean value": "{{property}}必须是一个布尔值", "$property must be a BTC address": "{{property}}必须是BTC地址", "$property must be a credit card": "{{property}}必须是一张信用卡", "$property must be a currency": "{{property}}必须是货币", "$property must be a data uri format": "{{property}}必须是一个数据uri格式", "$property must be a Date instance": "{{property}}必须是日期实例", "$property must be a Firebase Push Id": "{{property}}必须是Firebase推送ID", "$property must be a hash of type $constraint1": "{{property}}必须是类型{{constraint1}}的哈希值", "$property must be a hexadecimal color": "{{property}}必须是十六进制颜色", "$property must be a hexadecimal number": "{{property}}必须是十六进制数字", "$property must be a HSL color": "{{property}}必须是一个HSL颜色", "$property must be a identity card number": "{{property}}必须是身份卡号", "$property must be a ISSN": "{{property}}必须是一个ISSN", "$property must be a json string": "{{property}}必须是JSON字符串", "$property must be a jwt string": "{{property}}必须是JWT字符串", "$property must be a latitude string or number": "{{property}}必须是纬度字符串或数字", "$property must be a latitude,longitude string": "{{property}}必须是纬度字符串,经度字符串", "$property must be a longitude string or number": "{{property}}必须是经度字符串或数字", "$property must be a lowercase string": "{{property}}必须是小写字符串", "$property must be a MAC Address": "{{property}}必须是MAC地址", "$property must be a mongodb id": "{{property}}必须是Mongodb ID", "$property must be a negative number": "{{property}}必须是负数", "$property must be a non-empty object": "{{property}}必须是非空对象", "$property must be a number conforming to the specified constraints": "{{property}}必须是符合指定约束的数字", "$property must be a number string": "{{property}}必须是一个数字字符串", "$property must be a phone number": "{{property}}必须是电话号码", "$property must be a port": "{{property}}必须是一个端口", "$property must be a positive number": "{{property}}必须是一个正数", "$property must be a postal code": "{{property}}必须是一个邮政编码", "$property must be a Semantic Versioning Specification": "{{property}}必须是语义版本规格", "$property must be a string": "{{property}}必须是字符串", "$property must be a valid domain name": "{{property}}必须是一个有效的域名", "$property must be a valid enum value": "{{property}}必须是一个有效的枚举值", "$property must be a ISOString": "{{property}}必须是一个有效的ISO字符串", "$property must be a valid ISO 8601 date string": "{{property}}必须是一个有效的ISO 8601日期字符串", "$property must be a valid ISO31661 Alpha2 code": "{{property}}必须是有效的ISO31661 Alpha2代码", "$property must be a valid ISO31661 Alpha3 code": "{{property}}必须是有效的ISO31661 Alpha3代码", "$property must be a valid phone number": "{{property}}必须是一个有效的电话号码", "$property must be a valid representation of military time in the format HH:MM": "{{property}}必须是以HH:MM格式有效的军事时间", "$property must be an array": "{{property}}必须是一个数组", "$property must be an EAN (European Article Number)": "{{property}}必须是ECO(欧洲文章编号)", "$property must be an email": "{{property}}必须是电子邮件", "$property must be an Ethereum address": "{{property}}必须是Ethereum地址", "$property must be an IBAN": "{{property}}必须是IBAN", "$property must be an instance of $constraint1name": "{{property}}必须是{{constraint1}}的实例", "$property must be an integer number": "{{property}}必须是整数", "$property must be an ip address": "{{property}}必须是一个IP地址", "$property must be an ISBN": "{{property}}必须是一个ISBN", "$property must be an ISIN (stock/security identifier)": "{{property}}必须是ISIN(库存/安全标识符)", "$property must be an ISRC": "{{property}}必须是一个ISRC", "$property must be an object": "{{property}}必须是对象", "$property must be an URL address": "{{property}}必须是一个URL地址", "$property must be an UUID": "{{property}}必须是UUID", "$property must be base32 encoded": "{{property}}必须以base32编码", "$property must be base64 encoded": "{{property}}必须以base64编码", "$property must be divisible by $constraint1": "{{property}}必须被{{constraint1}}拆分", "$property must be empty": "{{property}}必须为空", "$property must be equal to $constraint1": "{{property}}必须等于{{constraint1}}", "$property must be locale": "{{property}}必须是区域设置", "$property must be longer than or equal to $constraint1 and shorter than or equal to $constraint2 characters": "{{property}}必须长于或等于{{constraint1}}并且短于或等于{{constraint2}}个字符", "$property must be longer than or equal to $constraint1 characters": "{{property}}必须长于或等于{{constraint1}}个字符", "$property must be magnet uri format": "{{property}}必须是magnet uri格式", "$property must be MIME type format": "{{property}}必须是MIME类型格式", "$property must be one of the following values: $constraint1": "{{property}}必须是以下值之一:{{constraint1}}", "$property must be RFC 3339 date": "{{property}}必须是RFC 3339日期", "$property must be RGB color": "{{property}}必须是RGB颜色", "$property must be shorter than or equal to $constraint1 characters": "{{property}}必须小于或等于{{constraint1}}个字符", "$property must be shorter than or equal to $constraint2 characters": "{{property}}必须小于或等于{{constraint2}}个字符", "$property must be uppercase": "{{property}}必须是大写", "$property must be valid octal number": "{{property}}必须是有效的八进制数字", "$property must be valid passport number": "{{property}}必须是有效的护照号码", "$property must contain $constraint1 values": "{{property}}必须包含{{constraint1}}个值", "$property must contain a $constraint1 string": "{{property}}必须包含{{constraint1}}字符串", "$property must contain a full-width and half-width characters": "{{property}}必须包含全宽和半宽字符", "$property must contain a full-width characters": "{{property}}必须包含全宽字符", "$property must contain a half-width characters": "{{property}}必须包含半宽字符", "$property must contain any surrogate pairs chars": "{{property}}必须包含任何代理对字符", "$property must contain at least $constraint1 elements": "{{property}}必须包含至少{{constraint1}}元素", "$property must contain not more than $constraint1 elements": "{{property}}必须包含不超过{{constraint1}}元素", "$property must contain one or more multibyte chars": "{{property}}必须包含一个或多个多字节", "$property must contain only ASCII characters": "{{property}}只能包含ASCII字符", "$property must contain only letters (a-zA-Z)": "{{property}}只能包含字母(a-zA-Z)", "$property must contain only letters and numbers": "{{property}}只能包含字母和数字", "$property must match $constraint1 regular expression": "{{property}}必须匹配{{constraint1}}正则表达式", "$property must not be greater than $constraint1": "{{property}}不能大于{{constraint1}}", "$property must not be less than $constraint1": "{{property}}不能小于{{constraint1}}", "$property should not be empty": "{{property}}不应该为空", "$property should not be equal to $constraint1": "{{property}}不能等于{{constraint1}}", "$property should not be null or undefined": "{{property}}不应为空或未定义", "$property should not be one of the following values: $constraint1": "{{property}}不应该是以下值之一:{{constraint1}}", "$property should not contain $constraint1 values": "{{property}}不应该包含{{constraint1}}个值", "$property should not contain a $constraint1 string": "{{property}}不应该包含{{constraint1}}字符串", "$property's byte length must fall into ($constraint1, $constraint2) range": "{{property}}的字节长度必须到 ({{constraint1}},{{constraint2}})范围", "All $property's elements must be unique": "所有{{property}}的元素必须是唯一的", "each value in ": "每个值在 ", "maximal allowed date for $property is $constraint1": "{{property}}最大允许日期为{{constraint1}}", "minimal allowed date for $property is $constraint1": "{{property}}最小允许日期为{{constraint1}}", "nested property $property must be either object or array": "嵌套属性{{property}}必须是对象或数组" } ```

Custom language

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}}"
}
m-wasilewski commented 3 years ago

@kkoomen I use you code, but I had to change all $variables to {{variables}}. Maybe it's related with i18n version ;)

YashKumarVerma commented 3 years ago

is there any possible method to render a template when DTO validation throws an error?

toonvanstrijp commented 3 years ago

@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.

rubiin commented 3 years ago

@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.

Dto translation will be a nice feature to add to the library

Ami777 commented 3 years ago

@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 image

GoldraK commented 3 years ago

@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;
};
4uva4ok1905 commented 2 years ago

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;
}
toonvanstrijp commented 2 years ago

This feature is now released in V9! See the documentation here 🌮 🎉

rubiin commented 2 years ago

Cool <3

svarup commented 2 years ago

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