Validate each Map<string, number> value using class-validator
Asked Answered
G

3

7

I'm trying to perform simple validation on a JSON input, modelled by one of my DTOs. One of the object properties is of type Map<string, number>. an example input:

{
  "type": "CUSTOM",
  "is_active": true,
  "current_plan_day": 1,
  "custom_warmup_plan": {
    "1": 123,
    "2": 456
}

On my controller I'm using a DTO to specify the body type. the class, together with class-validator decorators is this:

    export class CreateWarmupPlanRequestDto {
      @IsEnum(WarmupPlanType)
      type: string;
    
      @IsOptional()
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
      @IsPositive()
      hard_cap: number | null;
    
      @IsBoolean()
      is_active: boolean;
    
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
      @IsPositive()
      current_plan_day: number;
    
      @IsOptional()
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
      @IsPositive()
      previous_plan_day: number | null;
    
      @IsOptional()
      @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 }, { each: true })
      @IsPositive({ each: true })
      custom_warmup_plan: Map<string, number>;  // PROBLEM HERE
    }

I'm looking to validate each value of custom_warmup_plan to be an existing positive integer. Validation of the other properties of the object works just fine and as expected, but for my example input I keep getting errors (2 error messages, joined):

{
    "message": "each value in custom_warmup_plan must be a positive number. |#| each value in custom_warmup_plan must be a number conforming to the specified constraints",
    "statusCode": 400,
    "timestamp": "2021-07-29T13:18:29.331Z",
    "path": "/api/warmup-plan/bc4c3f0e-8e77-46de-a46a-a908edbdded5"
}

Documentation for this seems to be pretty straight forward, but I just cant get it to work. I've also played around with a simple Map<string, string> and the @IsString(each: true) validator, but that does not seem to work either.

any ideas?

versions:

"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/mapped-types": "^1.0.0",
"@nestjs/platform-express": "^8.0.0",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
Gongorism answered 29/7, 2021 at 13:22 Comment(0)
T
2

It is necessary to convert plain object to map. Use Transform decorator from class-transformer

@IsOptional()
@IsNumber(undefined, { each: true })
@Transform(({ value }) => new Map(Object.entries(value)))
prop?: Map<string, number>;
Tergiversate answered 9/6, 2022 at 4:32 Comment(1)
Note: this will only validate the integer aspect of the Map. It will allow for { prop: 1 } or { prop: [45, 46] } to be submitted (they will be transformed into { prop: { "0": 1 } } and { prop: { "0": 45, "1": 46 } } respectively. This is not wrong just something to note.Rainy
R
1

From the docs

If your field is an array and you want to perform validation of each item in the array you must specify a special each: true decorator option

If you want to be able to validate maps you could write a custom decorator and pass in a list of class-validator functions to validate the keys and values. For example the below decorator takes as input a list of validation functions for both the keys and values (e.g. passing in isString, isObject, etc..., class-validator has a corresponding function you can call for all the validation decorators they provide)

export function IsMap(
  key_validators: ((value: unknown) => boolean)[],
  value_validators: ((value: unknown) => boolean)[],
  validationOptions?: ValidationOptions
) {
  return function (object: unknown, propertyName: string) {
    registerDecorator({
      name: 'isMap',
      target: (object as any).constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: unknown, args: ValidationArguments) {
          if (!isObject(value)) return false;
          const keys = Object.keys(value);
          const is_invalid = keys.some((key) => {
            const is_key_invalid = key_validators.some((validator) => !validator(key));
            if (is_key_invalid) return true;

            const is_value_invalid = value_validators.some((validator) => !validator(value[key]));
            return is_value_invalid;
          });

          return !is_invalid;
        },
      },
    });
  };
}

And you can use this decorator in your example like this

import { isInt } from 'class-validator'
export class CreateWarmupPlanRequestDto {
  @IsOptional()
  @IsMap([], [isInt])
  custom_warmup_plan: Map<string, number>;
}
Rainy answered 9/4, 2022 at 15:25 Comment(2)
Thanks for the example, works great but it requires a small tweak. Validate() method expects to return "true" on valid object and "false" on invalid object and since you are returning "is_invalid" your result will always be incorrect. You should probably return !is_invalid.Usury
This is great! I found another tiny bug, maybe due to double negatives and the previous suggested edit. I think it should be if (is_key_invalid) return true; (not false).Katharinakatharine
C
0

Using the same approach with @Daniel, I modified the code little bit so that the focus is on 'isValid' rather than 'IsInvalid'. So that we could avoid double negation. Additionally, the coming object is transformed to map in the DTO.

@Transform(({ value }) => new Map(Object.entries(value)))
import {
    registerDecorator,
    ValidationArguments,
    ValidationOptions,
} from 'class-validator';
import * as $$ from 'lodash';

export function IsMap(
    keyValidators: ((value: unknown) => boolean)[],
    valueValidators: ((value: unknown) => boolean)[],
    validationOptions?: ValidationOptions,
) {
    return function (object: unknown, propertyName: string) {
        /**
         * ** value is expected to be a MAP already, we are just checking types of keys and values...
         */
        registerDecorator({
            name: 'isMap',
            target: (object as any).constructor,
            propertyName: propertyName,
            options: validationOptions,
            validator: {
                validate(value: Map<any, any>, args: ValidationArguments) {
                    if (!$$.isMap(value)) {
                        return false;
                    }
                    const keys = Array.from(value.keys());
                    return $$.every(keys, (key) => {
                        // checking if keys are valid...
                        const isKeyInvalid = keyValidators.some(
                            (validator) => !validator(key),
                        );
                        if (isKeyInvalid) {
                            return false;
                        }
                        // checking if values are valid...
                        const isValueInvalid = valueValidators.some(
                            (validator) => !validator(value.get(key)),
                        );
                        if (isValueInvalid) {
                            return false;
                        } else {
                            return true;
                        }
                    });
                },
            },
        });
    };
}
Cudlip answered 19/11, 2022 at 8:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.