-
Notifications
You must be signed in to change notification settings - Fork 0
Controllers
@nestjs-crud/core provides the @Crud() controller decorator, global configuration, validation, and helper decorators.
- Install
- Getting started
- API endpoints
- Swagger
- Options
- Global options
- Request authentication
- Request validation
- Validation groups: factory vs hand-rolled controllers
- Response serialization
- IntelliSense
- Routes override
- Adding routes
- Additional decorators
npm i @nestjs-crud/core class-transformer class-validatornpm i @nestjs-crud/typeorm @nestjs/typeorm typeormWalk through @nestjs-crud/core with TypeORM.
Start with a TypeORM entity:
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Company {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
}Create a service:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmCrudService } from '@nestjs-crud/typeorm';
import { Company } from './company.entity';
@Injectable()
export class CompaniesService extends TypeOrmCrudService<Company> {
constructor(@InjectRepository(Company) repo) {
super(repo);
}
}Wire the service into a controller:
import { Controller } from '@nestjs/common';
import { Crud, CrudController } from '@nestjs-crud/core';
import { Company } from './company.entity';
import { CompaniesService } from './companies.service';
@Crud({
model: {
type: Company,
},
})
@Controller('companies')
export class CompaniesController implements CrudController<Company> {
constructor(public service: CompaniesService) {}
}Register both in the CompaniesModule:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Company } from './company.entity';
import { CompaniesService } from './companies.service';
import { CompaniesController } from './companies.controller';
@Module({
imports: [TypeOrmModule.forFeature([Company])],
providers: [CompaniesService],
exports: [CompaniesService],
controllers: [CompaniesController],
})
export class CompaniesModule {}That is the whole setup.
@Crud() generates the following endpoints:
GET /heroesGET /heroes/:heroId/perks
Result: array of resources, or a pagination object with data Status codes: 200
GET /heroes/:idGET /heroes/:heroId/perks/:id
Request params: :id (some resource field acting as the slug)
Result: resource object, or error object
Status codes: 200, 404
POST /heroesPOST /heroes/:heroId/perks
Request body: resource object, or resource object with nested (relational) resources Result: created resource object, or error object Status codes: 201, 400
POST /heroes/bulkPOST /heroes/:heroId/perks/bulk
Request body: array of resource objects, or array of resource objects with nested (relational) resources
{
"bulk": [{ "name": "Batman" }, { "name": "Batgirl" }]
}Result: array of created resources, or error object Status codes: 201, 400
PATCH /heroes/:idPATCH /heroes/:heroId/perks/:id
Request params: :id (some resource field acting as the slug)
Request body: resource object (partial allowed), or with nested (relational) resources
Result: updated partial resource object, or error object
Status codes: 200, 400, 404
PUT /heroes/:idPUT /heroes/:heroId/perks/:id
Request params: :id (some resource field acting as the slug)
Request body: resource object, or with nested (relational) resources (partial allowed)
Result: replaced resource object, or error object
Status codes: 200, 400
DELETE /heroes/:idDELETE /heroes/:heroId/perks/:id
Request params: :id (some resource field acting as the slug)
Result: empty, resource object, or error object
Status codes: 200, 404
See Swagger for setup and the full @Crud({ swagger: {...} }) customization reference.
@Crud() accepts the following CrudOptions:
@Crud({
model: {
type: Entity|Model|DTO
},
...
})Required.
The Entity, Model, or DTO class. Everything else is optional. The class is also used for built-in validation via NestJS's ValidationPipe.
@Crud({
...
validation?: ValidationPipeOptions | false;
...
})Optional.
Accepts ValidationPipe options, or false to use your own validation.
@Crud({
...
params?: {
[key: string]: {
field: string;
type: 'number' | 'string' | 'uuid';
primary?: boolean;
disabled?: boolean;
},
},
...
})Optional.
By default, @Crud() uses id of type number as the primary slug param.
If your resource uses a UUID slug field as the primary identifier:
@Crud({
...
params: {
slug: {
field: 'slug',
type: 'uuid',
primary: true,
},
},
...
})For a controller path like /companies/:companyId/users, declare the parent param:
@Crud({
...
params: {
...
companyId: {
field: 'companyId',
type: 'number'
},
},
...
})You can also disable the id param when you need a few routes without path params (for example, a GET /me endpoint):
@Crud({
model: {
type: User,
},
routes: {
only: ['getOneBase', 'updateOneBase'],
},
params: {
id: {
primary: true,
disabled: true,
},
},
query: {
join: {
company: {
eager: true,
},
profile: {
eager: true,
},
},
},
})
@CrudAuth({
property: 'user',
filter: (user: User) => ({
id: user.id,
}),
})
@Controller('me')
export class MeController {
constructor(public service: UsersService) {}
}@Crud({
...
routes?: {
exclude?: BaseRouteName[],
only?: BaseRouteName[],
getManyBase?: {
interceptors?: [],
decorators?: [],
},
getOneBase?: {
interceptors?: [],
decorators?: [],
},
createOneBase?: {
interceptors?: [],
decorators?: [],
returnShallow?: boolean;
},
createManyBase?: {
interceptors?: [],
decorators?: [],
},
updateOneBase: {
interceptors?: [],
decorators?: [],
allowParamsOverride?: boolean,
returnShallow?: boolean;
},
replaceOneBase: {
interceptors?: [],
decorators?: [],
allowParamsOverride?: boolean,
returnShallow?: boolean;
},
deleteOneBase?: {
interceptors?: [],
decorators?: [],
returnDeleted?: boolean,
},
},
...
})Optional.
Per-route configuration:
-
interceptors: an array of custom interceptors -
decorators: an array of custom decorators -
allowParamsOverride: whether body data can be overridden by URL params onPATCH. Defaultfalse. -
returnDeleted: whether to return the entity in the response body onDELETE. Defaultfalse. -
returnShallow: whether to return a shallow entity
You can also exclude or restrict routes with exclude or only and a list of route names.
@Crud({
...
query?: {
allow?: string[];
exclude?: string[];
persist?: string[];
filter?: QueryFilterOption;
join?: JoinOptions;
sort?: QuerySort[];
limit?: number;
maxLimit?: number;
cache?: number | false;
alwaysPaginate?: boolean;
},
...
})Optional.
Query options for GET requests.
{
allow: ['name', 'email'];
}Optional.
Fields allowed in GET responses. Empty or undefined allows all.
{
exclude: ['accessToken'];
}Optional.
Fields excluded from the GET response (and not queried from the DB).
{
persist: ['createdAt'];
}Optional.
Fields always present in GET responses.
Optional.
Two scenarios:
- Add conditions to the request:
{
filter: {
isActive: {
$ne: false;
}
}
}which is the same as:
{
filter: [
{
field: 'isActive',
operator: '$ne',
value: false,
},
];
}- Transform incoming search conditions, or replace them entirely (for example, persist a fixed condition and ignore the request's search):
- Ignore any incoming search conditions:
{
filter: () => {};
}- Ignore incoming search and persist your own:
{
filter: () => ({
isActive: {
$ne: false;
}
});
}- Transform the incoming search:
import { SCondition } from '@nestjs-crud/request'
...
{
filter: (search: SCondition, getMany: boolean) => {
return getMany ? search : {
$and: [
...search.$and,
{ isActive: true },
],
}
};
}The first argument,
search, is always shaped as{ $and: [...] }or{ $or: [...] }. Which one depends on@CrudAuth():
- If you do not use
@CrudAuth(), or use it with afilterfunction,searchcarries$andconditions.- If you use
@CrudAuth()with anorfunction,searchcarries$orconditions.
{
join: {
profile: {
persist: ['name'],
exclude: ['token'],
eager: true,
require: true,
},
tasks: {
allow: ['content'],
},
notifications: {
eager: true,
select: false,
},
company: {},
'company.projects': {
persist: ['status']
},
'users.projects.tasks': {
exclude: ['description'],
alias: 'projectTasks',
},
}
}Optional.
Relations allowed for the join query parameter on GET requests.
Each key must match the resource's relation name exactly. Relations that are not listed here cannot be requested by clients.
Per-relation options (all optional):
-
allow: array of fields allowed in the response. Empty orundefinedallows all. -
exclude: array of fields excluded from the response (and not queried). -
persist: array of fields always included in the response. -
eager(boolean): whether the relation is included in everyGETresponse. -
require(boolean): iftrue, generates anINNER JOINinstead ofLEFT JOINfor RDBMS adapters. Defaultfalse. -
alias: relation alias. -
select(boolean): iffalse, the relation is joined but not selected (excluded from the response).
{
sort: [
{
field: 'id',
order: 'DESC',
},
];
}Optional.
Default sort merged with any sort passed in the request. Without a request-level sort, this default applies on its own.
{
limit: 25,
}Optional.
Default LIMIT applied to the DB query.
{
maxLimit: 100,
}Optional.
Maximum number of results a single GET request can ask for.
Set this in production. Without it, queries without an explicit
limit(or without a defaultlimitconfigured) execute with noLIMITat all.
{
cache: 2000,
}Optional.
When caching is configured for the project, this is the default cache duration in milliseconds for GET responses. See Caching.
Bypass with ?cache=0 on the request.
{
alwaysPaginate: true,
}Optional.
If true, GET many always returns a paginated object (instead of a bare array). Can also be set globally.
{
pagination: 'offset' | 'cursor';
}Optional. Default 'offset'.
Switches getManyBase between offset pagination (default) and opt-in cursor pagination. Cursor mode requires a single sort field plus a configured limit (or ?limit=N); see Cursor Pagination for the response shape, navigation pattern, security caveats, and per-adapter notes.
@Crud({
...
dto?: {
create?: Type<any>,
update?: Type<any>,
replace?: Type<any>
},
...
})Optional.
Request-body validation DTO classes. If a route has no DTO declared here, the CrudOptions.model.type is used. See Request validation.
@Crud({
...
serialize?: {
getMany?: Type<any> | false;
get?: Type<any> | false;
create?: Type<any> | false;
createMany?: Type<any> | false;
update?: Type<any> | false;
replace?: Type<any> | false;
delete?: Type<any> | false;
}
...
})Optional.
Response-serialization DTO classes. Pass false for any route to skip serialization on that route.
By default, @Crud() expects the controller to expose its CrudService on a field named service. When a controller injects multiple services or prefers a domain-specific name, set serviceProperty to point to the desired field:
import { Controller } from '@nestjs/common';
import { Crud } from '@nestjs-crud/core';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { MailService } from './mail.service';
@Crud({
model: { type: User },
serviceProperty: 'usersService',
})
@Controller('users')
export class UsersController {
constructor(
public usersService: UsersService,
public mailService: MailService,
) {}
}The default 'service' is unchanged — existing controllers continue to work without setting this option. Reserved keys ('__proto__', 'constructor', 'prototype') are rejected at decoration time to prevent prototype-pollution shapes. The library throws a clear error at request time if the configured property is undefined on the controller instance.
Consumers implementing CrudController<T> directly will note that the service field is now optional on the interface — the runtime contract (which field holds the service) is enforced by serviceProperty, not by the type itself.
To reduce repetition across controllers, configure some options globally:
{
queryParser?: RequestQueryBuilderOptions;
routes?: RoutesOptions;
params?: ParamsOptions;
auth?: {
property?: string;
};
query?: {
limit?: number;
maxLimit?: number;
cache?: number | false;
alwaysPaginate?: boolean;
};
serialize?: {
getMany?: false;
get?: false;
create?: false;
createMany?: false;
update?: false;
replace?: false;
delete?: false;
};
}-
queryParser: options forRequestQueryParser(used inCrudRequestInterceptorto parse and validate query and path params). The frontend has the same customization hook. -
routes: same as the per-controllerroutes. -
params: same as the per-controllerparams. -
query: a subset ofquery. Onlylimit,maxLimit,cache, andalwaysPaginateapply globally. -
serialize: globally disable serialization per route.
Load global options in main.ts (or index.ts) before importing AppModule. TypeScript decorators run at class declaration time, not at instantiation, so the config has to be in place before any decorated class is loaded:
import { CrudConfigService } from '@nestjs-crud/core';
CrudConfigService.load({
query: {
limit: 25,
cache: 2000,
},
params: {
id: {
field: 'id',
type: 'uuid',
primary: true,
},
},
routes: {
updateOneBase: {
allowParamsOverride: true,
},
deleteOneBase: {
returnDeleted: true,
},
},
});
import { AppModule } from './app.module';
...Each
CrudControllercan override any global option.
For data filtering on authenticated requests, use the @CrudAuth() decorator. Options:
{
property?: string;
filter?: (req: any) => SCondition | void;
or?: (req: any) => SCondition | void;
persist?: (req: any) => ObjectLiteral;
}-
property: the property on theRequestobject that holds the authenticated user. Can be set globally. -
filter: returns a search condition that AND-combines with query and path params:{Auth condition} AND {Path params} AND {Search|Filter} -
or: returns a search condition that OR-combines with query and path params. When set,filteris ignored.{Auth condition} OR ({Path params} AND {Search|Filter}) -
persist: returns an object merged into the DTO oncreate,update, andreplace. Useful when you want to lock down sensitive entity properties even though DTO validation would allow them.
@Crud({...})
@CrudAuth({
property: 'user',
filter: (user: User) => ({
id: user.id,
isActive: true,
})
})Query and path param validation runs in an interceptor that parses and validates the request.
Body validation runs through NestJS's ValidationPipe.
You can supply create, update, or replace DTOs in CrudOptions.dto, or use the entity itself as the DTO.
When you use CrudOptions.model.type as the DTO, body validation uses validation groups to distinguish create from update. Example:
import { Entity, Column, OneToMany } from 'typeorm';
import { IsOptional, IsString, MaxLength, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
import { CrudValidationGroups } from '@nestjs-crud/core';
import { BaseEntity } from '../base-entity';
import { User } from '../users/user.entity';
import { Project } from '../projects/project.entity';
const { CREATE, UPDATE } = CrudValidationGroups;
@Entity('companies')
export class Company extends BaseEntity {
@IsOptional({ groups: [UPDATE] })
@IsNotEmpty({ groups: [CREATE] })
@IsString({ always: true })
@MaxLength(100, { always: true })
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@IsOptional({ groups: [UPDATE] })
@IsNotEmpty({ groups: [CREATE] })
@IsString({ groups: [CREATE, UPDATE] })
@MaxLength(100, { groups: [CREATE, UPDATE] })
@Column({ type: 'varchar', length: 100, nullable: false, unique: true })
domain: string;
@IsOptional({ always: true })
@IsString({ always: true })
@Column({ type: 'text', nullable: true, default: null })
description: string;
/**
* Relations
*/
@OneToMany((type) => User, (u) => u.company)
@Type((t) => User)
users: User[];
@OneToMany((type) => Project, (p) => p.company)
projects: Project[];
}Import CrudValidationGroups and apply CREATE and UPDATE group rules per field, individually or together.
CrudValidationGroups.CREATE and CrudValidationGroups.UPDATE are only dispatched when validation flows through the @Crud() factory's generated routes (or @Override()-decorated methods, which the factory still wraps). The factory builds a per-route ValidationPipe with the right groups: [...] set, and that is what tells class-validator which group-tagged decorators to honor.
A controller you write by hand — using @Post() or @Patch() with NestJS's default ValidationPipe (whether registered globally or applied per-route) — does not get this dispatch. Every payload flows through one pipe configuration with no groups set, so any decorator tagged groups: [CrudValidationGroups.CREATE] or groups: [CrudValidationGroups.UPDATE] silently has no effect. The validator sees no matching group and skips the rule.
If you compose routes manually, wire the group yourself on each handler.
Without the group — silently skipped:
// app.module.ts (or main.ts)
app.useGlobalPipes(new ValidationPipe());
// your.controller.ts
@Controller('users')
export class UsersController {
constructor(private readonly service: UsersService) {}
@Post()
async create(@Body() dto: User) {
// class-validator runs with no `groups` set, so any decorator tagged
// `groups: [CrudValidationGroups.CREATE]` on the User entity is skipped.
// `@IsNotEmpty({ groups: [CREATE] })` on `name` does NOT fire here.
return this.service.create(dto);
}
@Patch(':id')
async update(@Param('id') id: number, @Body() dto: User) {
// Same problem in reverse: `@IsOptional({ groups: [UPDATE] })` does not fire,
// and any `groups: [CREATE]` rule (e.g., `@IsNotEmpty`) runs on every PATCH.
return this.service.update(id, dto);
}
}With explicit per-route groups — equivalent to what @Crud() does for you:
import { Body, Controller, Param, Patch, Post, ValidationPipe } from '@nestjs/common';
import { CrudValidationGroups } from '@nestjs-crud/core';
const { CREATE, UPDATE } = CrudValidationGroups;
@Controller('users')
export class UsersController {
constructor(private readonly service: UsersService) {}
@Post()
async create(
@Body(new ValidationPipe({ groups: [CREATE] })) dto: User,
) {
return this.service.create(dto);
}
@Patch(':id')
async update(
@Param('id') id: number,
@Body(new ValidationPipe({ groups: [UPDATE] })) dto: User,
) {
return this.service.update(id, dto);
}
}Controllers built with @Crud() get this dispatch automatically — this caveat only applies if you compose routes manually.
Serialization is on by default for every route, via class-transformer.
Use the standard class-transformer decorators on your entity:
import { Exclude } from 'class-transformer';
export class User {
email: string;
@Exclude()
password: string;
}For per-route serialization (different DTO per route), use CrudOptions.serialize.
@Crud() wires the route handlers onto your controller at runtime. TypeScript doesn't see them. Two consequences.
The injected CrudService is untyped unless the controller declares implements CrudController<T>. The interface restores the type.
Calling a base method like this.getManyBase(req) fails typecheck. The methods are declared optional on the interface, and the class type doesn't include them. Workaround is a base getter that returns this cast to the interface:
...
import { Crud, CrudController } from '@nestjs-crud/core';
@Crud(Hero)
@Controller('heroes')
export class HeroesCrud implements CrudController<Hero> {
constructor(public service: HeroesService) {}
get base(): CrudController<Hero> {
return this;
}
}A TypeScript class-decorator limit, not specific to @nestjs-crud. Decorators mutate runtime behavior but can't extend the class type.
The base methods composed by @Crud():
{
getManyBase(
@ParsedRequest() req: CrudRequest,
): Promise<GetManyDefaultResponse<T> | T[]>;
getOneBase(
@ParsedRequest() req: CrudRequest,
): Promise<T>;
createOneBase(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: T,
): Promise<T>;
createManyBase(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: CreateManyDto<T>,
): Promise<T>;
updateOneBase(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: T,
): Promise<T>;
replaceOneBase(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: T,
): Promise<T>;
deleteOneBase(
@ParsedRequest() req: CrudRequest,
): Promise<void | T>;
}Every base method ends in Base. Override in one of two ways:
- Attach
@Override()(no argument) to a method whose name drops theBasesuffix. To overridegetManyBase, name the methodgetMany. - Attach
@Override('getManyBase')(base method name as argument) to a method with any custom name.
Example:
...
import {
Crud,
CrudController,
Override,
CrudRequest,
ParsedRequest,
ParsedBody,
CreateManyDto,
} from '@nestjs-crud/core';
@Crud({
model: {
type: Hero,
}
})
@Controller('heroes')
export class HeroesCrud implements CrudController<Hero> {
constructor(public service: HeroesService) {}
get base(): CrudController<Hero> {
return this;
}
@Override()
getMany(
@ParsedRequest() req: CrudRequest,
) {
return this.base.getManyBase(req);
}
@Override('getOneBase')
getOneAndDoStuff(
@ParsedRequest() req: CrudRequest,
) {
return this.base.getOneBase(req);
}
@Override()
createOne(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: Hero,
) {
return this.base.createOneBase(req, dto);
}
@Override()
createMany(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: CreateManyDto<Hero>
) {
return this.base.createManyBase(req, dto);
}
@Override('updateOneBase')
coolFunction(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: Hero,
) {
return this.base.updateOneBase(req, dto);
}
@Override('replaceOneBase')
awesomePUT(
@ParsedRequest() req: CrudRequest,
@ParsedBody() dto: Hero,
) {
return this.base.replaceOneBase(req, dto);
}
@Override()
async deleteOne(
@ParsedRequest() req: CrudRequest,
) {
return this.base.deleteOneBase(req);
}
}Two custom param decorators ship for these overrides:
@ParsedRequest()and@ParsedBody(). You can still mix in any built-in NestJS param decorators (@Param(),@Session(), etc.) and your own custom ones.
To add a fresh route that uses @ParsedRequest(), attach CrudRequestInterceptor:
...
import { UseInterceptors } from '@nestjs/common';
import {
ParsedRequest,
CrudRequest,
CrudRequestInterceptor,
} from '@nestjs-crud/core';
...
@UseInterceptors(CrudRequestInterceptor)
@Get('/export/list.xlsx')
async exportSome(@ParsedRequest() req: CrudRequest) {
// your handler
}Two additional decorators ship for ACL integration: @Feature() and @Action(). @Action() is applied automatically on the composed base methods. The CrudActions enum names them:
enum CrudActions {
ReadAll = 'Read-All',
ReadOne = 'Read-One',
CreateOne = 'Create-One',
CreateMany = 'Create-Many',
UpdateOne = 'Update-One',
ReplaceOne = 'Replace-One',
DeleteOne = 'Delete-One',
RecoverOne = 'Recover-One',
}A minimal ACLGuard example using the getFeature and getAction helpers:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { getFeature, getAction } from '@nestjs-crud/core';
@Injectable()
export class ACLGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const handler = ctx.getHandler();
const controller = ctx.getClass();
const feature = getFeature(controller);
const action = getAction(handler);
console.log(`${feature}-${action}`); // e.g. 'Heroes-Read-All'
return true;
}
}