ephys / nestjs-joi

Easy to use JoiPipe as an interface between joi and NestJS with optional decorator-based schema construction.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

nestjs-joi

NPM Version Package License Coverage Status test publish

Easy to use JoiPipe as an interface between joi and NestJS with optional decorator-based schema construction. Based on joi-class-decorators.

Installation

npm install --save nestjs-joi

Peer dependencies

npm install --save @nestjs/common@^7 @nestjs/core@^7 joi@^17 reflect-metadata@^0.1

Usage

Annotate your type/DTO classes with property schemas and options, then set up your NestJS module to import JoiPipeModule to have your controller routes auto-validated everywhere the type/DTO class is used.

The built-in groups CREATE and UPDATE are available for POST/PUT and PATCH, respectively.

The @JoiSchema(), @JoiSchemaOptions(), @JoiSchemaExtends() decorators and the getTypeSchema() function are re-exported from the joi-class-decorators package.

import { JoiPipeModule, JoiSchema, JoiSchemaOptions, CREATE, UPDATE } from 'nestjs-joi';
import * as Joi from 'joi';

@Module({
  controllers: [BookController],
  imports: [JoiPipeModule],
})
export class AppModule {}

@JoiSchemaOptions({
  allowUnknown: false,
})
export class BookDto {
  @JoiSchema(Joi.string().required())
  @JoiSchema([CREATE], Joi.string().required())
  @JoiSchema([UPDATE], Joi.string().optional())
  name!: string;

  @JoiSchema(Joi.string().required())
  @JoiSchema([CREATE], Joi.string().required())
  @JoiSchema([UPDATE], Joi.string().optional())
  author!: string;

  @JoiSchema(Joi.number().optional())
  publicationYear?: number;
}

@Controller('/books')
export class BookController {
  @Post('/')
  async createBook(@Body() createData: BookDto) {
    // Validated creation data!
    return await this.bookService.createBook(createData);
  }

  @Put('/')
  async createBook(@Body() createData: BookDto) {
    // Validated create data!
    return await this.bookService.createBook(createData);
  }

  @Patch('/')
  async createBook(@Body() updateData: BookDto) {
    // Validated update data!
    return await this.bookService.createBook(createData);
  }
}

It is possible to use JoiPipe on its own, without including it as a global pipe. See below for a more complete documentation.

A note on @nestjs/graphql

This module can be used with @nestjs/graphql, but with some caveats:

  1. passing new JoiPipe() to useGlobalPipes(), @UsePipes(), a pipe defined in @Args() etc. works as expected.
  2. passing the JoiPipe constructor to useGlobalPipes(), @UsePipes(), @Args() etc. does not respect the passed HTTP method, meaning that the CREATE, UPDATE etc. groups will not be used automatically. This limitation is due, to the best of understanding, to Apollo, the GraphQL server used by @nestjs/graphql, which only processed GraphQL queries for if they are sent as GET and POST.
  3. if JoiPipe is registered as a global pipe by defining an APP_PIPE provider, then JoiPipe will not be called for GraphQL requests (see nestjs/graphql#325)

If you want to make sure a validation group is used for a specific resolver mutation, create a new pipe with new JoiPipe({group: 'yourgroup'}) and pass it to @UsePipes() or @Args().

To work around the issue of OmitType() etc. breaking the inheritance chain for schema building, see @JoiSchemaExtends() below.

Reference

Validation groups

Groups can be used to annotate a property (@JoiSchema) or class (@JoiSchemaOptions) with different schemas/options for different use cases without having to define a new type.

A straightforward use case for this is a type/DTO that behaves slightly differently in each of the CREATE and UPDATE scenarios. The built-in groups explained below are meant to make interfacing with that use case easier. Have a look at the example in the Usage section.

For more information, have a look at the validation groups documentation from joi-class-decorators.

Built-in groups: DEFAULT, CREATE, UPDATE

Three built-in groups are defined:

  • DEFAULT is the default "group" assigned under the hood to any schema defined on a property, or any options defined on a class, if a group is not explicitely specified. This is the same Symbol exported from the joi-class-decorators package.
  • CREATE is used for validation if JoiPipe is used in injection-enabled mode (either through JoiPipeModule or @Body(JoiPipe) etc.) and the request method is either POST or PUT
    • PUT is defined as being capable of completely replacing a resource or creating a new one in case a unique key is not found, which means all properties must be present the same way as for POST.
  • UPDATE works the same way as CREATE, but is used if the request method is PATCH.

They can be imported in one of two ways, depending on your preference:

import { JoiValidationGroups } from 'nestjs-joi';
import { DEFAULT, CREATE, UPDATE } from 'nestjs-joi';

JoiValidationGroups.CREATE === CREATE; // true

JoiPipe

JoiPipe can be used either as a global pipe (see below for JoiPipeModule) or for specific requests inside the @Param(), @Query etc. Request decorators.

When used with the the Request decorators, there are two possibilities:

  • pass a configured JoiPipe instance
  • pass the JoiPipe constuctor itself to leverage the injection and built-in group capabilities

When handling a request, the JoiPipe instance will be provided by NestJS with the payload and, if present, the metatype (BookDto in the example below). The metatype is used to determine the schema that the payload is validated against, unless JoiPipe is instanciated with an explicit type or schema. This is done by evaluating metadata set on the metatype's class properties, if present.

@Controller('/books')
export class BookController {
  @Post('/')
  async createBook(@Body(JoiPipe) createData: BookDto) {
    // Validated creation data!
    return await this.bookService.createBook(createData);
  }
}

new JoiPipe(pipeOpts?)

A JoiPipe that will handle payloads based on a schema determined by the passed metatype, if present.

If group is passed in the pipeOpts, only decorators specified for that group or the DEFAULT group will be used to construct the schema.

  @Post('/')
  async createBook(@Body(new JoiPipe({ group: CREATE })) createData: BookDto) {
    // Validated creation data!
    return await this.bookService.createBook(createData);
  }

new JoiPipe(type, pipeOpts?)

A JoiPipe that will handle payloads based on the schema constructed from the passed type. This pipe will ignore the request metatype.

If group is passed in the pipeOpts, only decorations specified for that group or the DEFAULT group will be used to construct the schema.

  @Post('/')
  async createBook(@Body(new JoiPipe(BookDto, { group: CREATE })) createData: unknown) {
    // Validated creation data!
    return await this.bookService.createBook(createData);
  }

new JoiPipe(joiSchema, pipeOpts?)

A JoiPipe that will handle payloads based on the schema passed in the constructor parameters. This pipe will ignore the request metatype.

If group is passed in the pipeOpts, only decorations specified for that group or the DEFAULT group will be used to construct the schema.

  @Get('/:bookId')
  async getBook(@Param('bookId', new JoiPipe(Joi.string().required())) bookId: string) {
      // bookId guaranteed to be a string and defined and non-empty
      return this.bookService.getBookById(bookId);
  }

Pipe options (pipeOpts)

Currently, the following options are available:

  • group (string | symbol) When a group is defined, only decorators specified for that group or the DEFAULT group when declaring the schema will be used to construct the schema. Default: undefined
  • usePipeValidationException (boolean) By default, JoiPipe throws a NestJS BadRequestException when a validation error occurs. This results in a 400 Bad Request response, which should be suitable to most cases. If you need to have a reliable way to catch the thrown error, for example in an exception filter, set this to true to throw a JoiPipeValidationException instead. Default: false
  • defaultValidationOptions (Joi.ValidationOptions) The default Joi validation options to pass to .validate()
    • Default: { abortEarly: false, allowUnknown: true }
    • Note that validation options passed directly to a schema using .prefs() (or .options()) will always take precedence and can never be overridden with this option.

Injection-enabled mode: JoiPipe (@Query(JoiPipe), @Param(JoiPipe), ...)

Uses an injection-enabled JoiPipe which can look at the request to determine the HTTP method and, based on that, which in-built group (CREATE, UPDATE, DEFAULT) to use.

Validates against the schema constructed from the metatype, if present, taking into account the group determined as stated above.

export class BookDto {
  @JoiSchema(Joi.string().required())
  @JoiSchema([JoiValidationGroups.CREATE], Joi.string().required())
  @JoiSchema([JoiValidationGroups.UPDATE], Joi.string().optional())
  name!: string;

  @JoiSchema(Joi.string().required())
  @JoiSchema([JoiValidationGroups.CREATE], Joi.string().required())
  @JoiSchema([JoiValidationGroups.UPDATE], Joi.string().optional())
  author!: string;

  @JoiSchema(Joi.number().optional())
  publicationYear?: number;
}

@Controller()
class BookController {
  // POST: this will implicitely use the group "CREATE" to construct the schema
  @Post('/')
  async createBook(@Body(JoiPipe) createData: BookDto) {
    return await this.bookService.createBook(createData);
  }
}

Defining pipeOpts in injection-enabled mode

In injection-enabled mode, options cannot be passed to JoiPipe directly since the constructor is passed as an argument instead of an instance, which would accept the pipeOpts argument.

Instead, the options can be defined by leveraging the DI mechanism itself to provide the options through a provider:

@Module({
  ...
  controllers: [ControllerUsingJoiPipe],
  providers: [
    {
      provide: JOIPIPE_OPTIONS,
      useValue: {
        usePipeValidationException: true,
      },
    },
  ],
  ...
})
export class AppModule {}

Note: the provider must be defined on the correct module to be "visible" in the DI context in which the JoiPipe is being injected. Alternatively, it can be defined and exported in a global module. See the NestJS documentation for this.

For how to define options when using the JoiPipeModule, refer to the section on JoiPipeModule below.

Error handling and custom schema errors

As described in the pipeOpts, when a validation error occurs, JoiPipe throws a BadRequestException or a JoiPipeValidationException (if configured).

If your schema defines a custom error, that error will be thrown instead:

@JoiSchema(
  Joi.string()
    .required()
    .alphanum()
    .error(
      new Error(
        `prop must contain only alphanumeric characters`,
      ),
    ),
)
prop: string;

JoiPipeModule

Importing JoiPipeModule into a module will install JoiPipe as a global injection-enabled pipe.

This is a prerequisite for JoiPipe to be able to use the built-in groups CREATE and UPDATE, since the JoiPipe must be able to have the Request injected to determine the HTTP method. Calling useGlobalPipe(new JoiPipe()) is not enough to achieve that.

Example

import { JoiPipeModule } from 'nestjs-joi';

@Module({
  controllers: [BookController],
  imports: [JoiPipeModule],
})
export class AppModule {}

//
// Equivalent to:
import { JoiPipe } from 'nestjs-joi';

@Module({
  controllers: [BookController],
  providers: [
    {
      provide: APP_PIPE,
      useClass: JoiPipe,
    },
  ],
})
export class AppModule {}

Pipe options (pipeOpts) can be passed by using JoiPipeModule.forRoot():

import { JoiPipeModule } from 'nestjs-joi';

@Module({
  controllers: [BookController],
  imports: [
    JoiPipeModule.forRoot({
      pipeOpts: {
        usePipeValidationException: true,
      },
    }),
  ],
})
export class AppModule {}

//
// Equivalent to:
import { JoiPipe } from 'nestjs-joi';

@Module({
  controllers: [BookController],
  providers: [
    {
      provide: APP_PIPE,
      useClass: JoiPipe,
    },
    {
      provide: JOIPIPE_OPTIONS,
      useValue: {
        usePipeValidationException: true,
      },
    },
  ],
})
export class AppModule {}

@JoiSchema() property decorator

Define a schema on a type (class) property. Properties with a schema annotation are used to construct a full object schema.

API documentation in joi-class-decorators repository.

@JoiSchemaOptions() class decorator

Assign the passed Joi options to be passed to .options() on the full constructed schema.

API documentation in joi-class-decorators repository.

@JoiSchemaExtends(type) class decorator

Specify an alternative extended class for schema construction. type must be a class constructor.

API documentation in joi-class-decorators repository.

getClassSchema(typeClass, opts?: { group? }) (alias: getTypeSchema())

This function can be called to obtain the Joi schema constructed from type. This is the function used internally by JoiPipe when it is called with an explicit/implicit type/metatype. Nothing is cached.

API documentation in joi-class-decorators repository.

About

Easy to use JoiPipe as an interface between joi and NestJS with optional decorator-based schema construction.

License:MIT License


Languages

Language:TypeScript 99.6%Language:JavaScript 0.4%Language:Shell 0.1%