JWo
JWo

Reputation: 593

Validate Enum directly in controller function

I have a query parameter in my REST API, which values should be restricted by an enum type. I'm looking for a way to throw a "Bad Request" error, when a client gives something different.

My enum looks like this:

export enum Precision {
    S = 's',
    MS = 'ms',
    U = 'u',
    NS = 'ns',
}

My controller function looks like this:

  @Get(':deviceId/:datapoint/last')
  @ApiOkResponse()
  @ApiQuery({name: 'precision', enum: Precision})
  getLastMeasurement(
    @Param('deviceId') deviceId: string,
    @Param('datapoint') datapoint: string,
    @Query('precision') precision: Precision = Precision.S,
    @Res() response: Response,
  ) {
    console.log(precision);
    ....
    response.status(HttpStatus.OK).send(body);
  }

My problem here is that the function accepts other values, too (for example I can send an f as the query parameter's value). The Function won't return an error to the client, but I want to without writing an if else block at the beginning of each controller function.
I guess there is a rather simple solution to this, but when I try to look it up on the internet I always get results for class validation in DTOs, but not for a simple enum validation directly in the query param/REST controller.

Thanks for your time,
J

Upvotes: 9

Views: 10970

Answers (4)

usersina
usersina

Reputation: 1783

Building on everyone's answers, here's a pipe that will

  • Validate any enum in a type-safe way
  • Support adding a default value
  • Correctly gives 400 for invalid values
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { isDefined, isEnum } from 'class-validator';

type EnumKey = string | number | symbol;

/**
 * A pipe that validates an enum value and sets a default value if none is provided.
 */
@Injectable()
export class EnumValidationPipe<T extends Record<EnumKey, unknown>> implements PipeTransform<unknown, T[keyof T]> {
  constructor(
    private enumEntity: T,
    private defaultValue?: T[keyof T],
  ) {}

  transform(value: unknown): T[keyof T] {
    if (!isDefined(value) && isDefined(this.defaultValue)) {
      return this.defaultValue;
    }
    if (isDefined(value) && isEnum(value, this.enumEntity)) {
      return value as T[keyof T];
    } else {
      const errorMessage = `The value ${value} is not valid. See the acceptable values: ${Object.values(this.enumEntity).join(', ')}`;
      throw new BadRequestException(errorMessage);
    }
  }
}

And you can use it like this

getLastMeasurement(
  ... // some other params
    @Query('precision', new EnumValidationPipe(Precision, Precision.S)) precision: Precision
  ) { 
  ... // do something
}

Upvotes: 0

Piradata
Piradata

Reputation: 1

Just improved a bug in Raphael's answer, it was returning the key of the enum instead of the value.

Also, used the metadata param to outpput on the error message the name of the field being validated and also changed

${Object.keys(this.enumEntity).map(key => this.enumEntity[key])}

to

${Object.values(this.enumEntity)}

There is the final code of the validator:

@Injectable()
export class EnumValidationPipe implements PipeTransform<string, Promise<any>> {
  constructor(private enumEntity: any) {}
  transform(value: string, metadata: ArgumentMetadata): Promise<any> {
    if (isDefined(value) && isEnum(value, this.enumEntity)) {
      return Promise.resolve(value);
    } else {
      const errorMessage = `the value ${value} from field ${
        metadata.data
      } is not valid. Acceptable values: ${Object.values(this.enumEntity)}`;
      throw new BadRequestException(errorMessage);
    }
  }
}

Upvotes: 0

Raphael Soares
Raphael Soares

Reputation: 645

There is 2 issues here.

The first one is that you are passing the default value of precision params in the wrong way. You must use the DefaultValuePipe like this:

getLastMeasurement(
  ... // some other params
    @Query('precision', new DefaultValuePipe(Precision.S)) precision: Precision
  ) { 
  ... // do something
}

The second one is the enum validation. NestJS comes with only 6 types of validationPipes and none of them validates an enum, so you must create your own custom validation pipe to validate enums.

There is 2 possible ways that you can do that:

  1. Create a custom pipe to validate only your specific enum;
  2. Create a custom pipe generic to validate any enum;

Based on this https://docs.nestjs.com/pipes#custom-pipes, it would be something like:

  1. Validate only the specific enum
import { BadRequestException, PipeTransform } from '@nestjs/common';
import { isDefined, isEnum } from 'class-validator';

export class PrecisionValidationPipe implements PipeTransform<string, Promise<Precision>> {

  transform(value: string): Promise<Precision> {
    if (isDefined(value) && isEnum(value, Precision)) {
      return Precision[value];
    } else {
      const errorMessage = `the value ${value} is not valid. See the acceptable values: ${Object.keys(
        Precision
      ).map(key => Precision[key])}`;
      throw new BadRequestException(errorMessage);
    }
  }
}

and then in your request, it would be like

  getLastMeasurement(
    @Query('precision', PrecisionValidationPipe, new DefaultValuePipe(Precision.S)) precision: Precision,
  ) {
    console.log(precision);
    ....
    response.status(HttpStatus.OK).send(body);
  }
  1. Validate any enum (my favorite)
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { isDefined, isEnum } from 'class-validator';

@Injectable()
export class EnumValidationPipe implements PipeTransform<string, Promise<any>> {
  constructor(private enumEntity: any) {}
  transform(value: string): Promise<any> {
      if (isDefined(value) && isEnum(value, this.enumEntity)) {
        return this.enumEntity[value];
      } else {
        const errorMessage = `the value ${value} is not valid. See the acceptable values: ${Object.keys(this.enumEntity).map(key => this.enumEntity[key])}`;
        throw new BadRequestException(errorMessage);
      }
  }
}

and then in your request, it would be like

  getLastMeasurement(
    @Query('precision', new EnumValidationPipe(Precision), new DefaultValuePipe(Precision.S)) precision: Precision,
  ) {
    console.log(precision);
    ....
    response.status(HttpStatus.OK).send(body);
  }

Upvotes: 16

Jay McDoniel
Jay McDoniel

Reputation: 70131

You should be able to create a class like LastMeasurementQueryParams that makes use of class-validator decorators, and use the built-in ValidationPipe to check and make sure that one of the expected values are sent in.

The class could look something like this:

export class LastMeasurementQueryParams {

  @IsEnum(Precision)
  precision: Precision;
}

And then your controller can look like this:

  @Get(':deviceId/:datapoint/last')
  @ApiOkResponse()
  @ApiQuery({name: 'precision', enum: Precision})
  getLastMeasurement(
    @Param('deviceId') deviceId: string,
    @Param('datapoint') datapoint: string,
    @Query('precision') precision: LastMeasurementQueryParams = { precision: Precision.S },
    @Res() response: Response,
  ) {
    console.log(precision);
    ....
    response.status(HttpStatus.OK).send(body);
  }

Upvotes: 8

Related Questions