Easy to use JoiPipe
as an interface between joi
and NestJS with optional decorator-based schema construction. Based on joi-class-decorators
.
- Installation
- Usage
- Reference
npm install --save nestjs-joi
npm install --save @nestjs/common@^7 @nestjs/core@^7 joi@^17 reflect-metadata@^0.1
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.
This module can be used with @nestjs/graphql
, but with some caveats:
- passing
new JoiPipe()
touseGlobalPipes()
,@UsePipes()
, a pipe defined in@Args()
etc. works as expected. - passing the
JoiPipe
constructor touseGlobalPipes()
,@UsePipes()
,@Args()
etc. does not respect the passed HTTP method, meaning that theCREATE
,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 asGET
andPOST
. - if
JoiPipe
is registered as a global pipe by defining anAPP_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.
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
.
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 thejoi-class-decorators
package.CREATE
is used for validation ifJoiPipe
is used in injection-enabled mode (either throughJoiPipeModule
or@Body(JoiPipe)
etc.) and the request method is eitherPOST
orPUT
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 forPOST
.
UPDATE
works the same way asCREATE
, but is used if the request method isPATCH
.
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
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);
}
}
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);
}
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);
}
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);
}
Currently, the following options are available:
group
(string | symbol
) When agroup
is defined, only decorators specified for that group or theDEFAULT
group when declaring the schema will be used to construct the schema. Default:undefined
usePipeValidationException
(boolean
) By default,JoiPipe
throws a NestJSBadRequestException
when a validation error occurs. This results in a400 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 totrue
to throw aJoiPipeValidationException
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.
- Default:
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);
}
}
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.
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;
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 {}
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.
Assign the passed Joi options to be passed to .options()
on the full constructed schema.
API documentation in joi-class-decorators
repository.
Specify an alternative extended class for schema construction. type
must be a class constructor.
API documentation in joi-class-decorators
repository.
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.