Description
@dataui/crud - core package which provides @Crud()
controller decorator for endpoints generation, global configuration, validation, helper decorators.
Table of Contents
- Install
- Getting started
- API endpoints
- Swagger
- Options
- Global options
- Request authentication
- Request validation
- Response serialization
- IntelliSense
- Routes override
- Adding routes
- Additional decorators
Install
npm i @dataui/crud class-transformer class-validator
Using TypeORM
npm i @dataui/crud-typeorm @nestjs/typeorm typeorm
Getting started
Let’s take a look at the example of using @dataui/crud
with TypeORM.
Assume we have some TypeORM entity:
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Company {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
}
Then we need to create a service:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmCrudService } from '@dataui/crud-typeorm';
import { Company } from './company.entity';
@Injectable()
export class CompaniesService extends TypeOrmCrudService<Company> {
constructor(@InjectRepository(Company) repo) {
super(repo);
}
}
We’ve done with the service so let’s create a controller:
import { Controller } from '@nestjs/common';
import { Crud, CrudController } from '@dataui/crud';
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) {}
}
All we have to do next is to connect our service and controller in the CompaniesModule
as we usually do:
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’s it.
API Endpoints
Crud()
decorator generates the following API endpoints:
Get many resources
GET /heroes
>GET /heroes/:heroId/perks
Result: array of resources | pagination object with data Status Codes: 200
Get one resource
GET /heroes/:id
>GET /heroes/:heroId/perks:id
Request Params: :id
- some resource field (slug) Result: resource object | error object Status Codes: 200 | 404
Create one resource
POST /heroes
>POST /heroes/:heroId/perks
Request Body: resource object | resource object with nested (relational) resources |
Result: created resource object | error object |
Status Codes: 201 | 400 |
Create many resources
POST /heroes/bulk
>POST /heroes/:heroId/perks/bulk
Request Body: array of resources objects | array of resources objects with nested (relational) resources |
{
"bulk": [{ "name": "Batman" }, { "name": "Batgirl" }]
}
Result: array of created resources | error object |
Status codes: 201 | 400 |
Update one resource
PATCH /heroes/:id
>PATCH /heroes/:heroId/perks/:id
Request Params: :id
- some resource field (slug) Request Body: resource object (or partial) | resource object with nested (relational) resources (or partial) Result:: updated partial resource object | error object Status codes: 200 | 400 | 404
Replace one resource
PUT /heroes/:id
>PUT /heroes/:heroId/perks/:id
Request Params: :id
- some resource field (slug) Request Body: resource object | resource object with nested (relational) resources (or partial) Result:: replaced resource object | error object Status codes: 200 | 400
Delete one resource
DELETE /heroes/:id
>DELETE /heroes/:heroId/perks/:id
Request Params: :id
- some resource field (slug) Result:: empty | resource object | error object Status codes: 200 | 404
Swagger
Swagger support is present out of the box.
Options
Crud()
decorator accepts the following CrudOptions
:
model
@Crud({
model: {
type: Entity|Model|DTO
},
...
})
Required
Entity
, Model
or DTO
class must be provided here. Everything else described bellow is optional. It’s needed for a built in validation based on NestJS ValidationPipe
.
validation
@Crud({
...
validation?: ValidationPipeOptions | false;
...
})
Optional
Accepts ValidationPipe
options or false
if you want to use your own validation implementation.
params
@Crud({
...
params?: {
[key: string]: {
field: string;
type: 'number' | 'string' | 'uuid';
primary?: boolean;
disabled?: boolean;
},
},
...
})
Optional
By default @Crud()
decorator will use id
with the type number
as a primary slug param.
If you have, for instance, a resorce field called slug
or whatever, it’s a UUID and you need it to be a primary slug by which your resource should be fetched, you can set up this params options:
@Crud({
...
params: {
slug: {
field: 'slug',
type: 'uuid',
primary: true,
},
},
...
})
If you have a controller path with that looks kinda similar to this /companies/:companyId/users
you need to add this param option:
@Crud({
...
params: {
...
companyId: {
field: 'companyId',
type: 'number'
},
},
...
})
Also, you can disable id
param if you want to have only few routs without any path params. It’s very useful, for creating something like GET /me
endpoints.
@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) {}
}
routes
@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
It’s a set of options for each of the generated routes.
interceptors
- an array of your custom interceptors decorators
- an array of your custom decorators allowParamsOverride
- whether or not to allow body data be overriten by the URL params on PATH request. Default: false
returnDeleted
- whether or not an entity object should be returned in the response body on DELETE request. Default: false
returnShallow
- whether or not to return a shallow entity
Also you can specify what routes should be excluded or what routes whould be used only by providing routes names to the exclude
or only
accordingly.
query
@Crud({
...
query?: {
allow?: string[];
exclude?: string[];
persist?: string[];
filter?: QueryFilterOption;
join?: JoinOptions;
sort?: QuerySort[];
limit?: number;
maxLimit?: number;
cache?: number | false;
alwaysPaginate?: boolean;
softDelete?: boolean;
},
...
})
Optional
It’s a set of query options for GET request.
allow
{
allow: ['name', 'email'];
}
Optional
An Array of fields that are allowed to be received in GET requests. If empty or undefined - allow all.
exclude
{
exclude: ['accessToken'];
}
Optional
An Array of fields that will be excluded from the GET response (and not queried from the DB).
persist
{
persist: ['createdAt'];
}
Optional
An Array of fields that will be always persisted in GET response.
filter
Optional
This option can be used in two scenarios:
- If you want to add some conditions to the request:
{
filter: {
isActive: {
$ne: false;
}
}
}
…which is the same as:
{
filter: [
{
field: 'isActive',
operator: '$ne',
value: false,
},
];
}
- If you want to transform your query search conditions or event return a completely new one (i.e. persist only one set of conditions and ignore search coming from the request):
- Totally ignore any query search conditions:
{
filter: () => {};
}
- Totally ignore any query search conditions and persist some conditions:
{
filter: () => ({
isActive: {
$ne: false;
}
});
}
- Transform query search conditions:
import { SCondition } from '@dataui/crud-request'
...
{
filter: (search: SCondition, getMany: boolean) => {
return getMany ? search : {
$and: [
...search.$and,
{ isActive: true },
],
}
};
}
Notice: First function parameter here, search
, will always be either { $and: [...] }
or { $or: [...] }
. It depends on if you’re using @CrudAuth()
decorator:
- if you are not using it, or if you do and it has
filter
function thensearch
will contain$and
type of conditions. - if you are using it and it has
or
function thensearch
will contain$or
type of conditions.
join
{
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
An Object of relations that allowed to be fetched by passing join
query parameter in GET requests.
Each key of join
object must strongly match the name of the corresponding resource relation. If particular relation name is not present in this option, then user will not be able to get this relational objects in GET request.
Each relation option can have (all below are optional):
allow
- an Array of fields that are allowed to be received in GET requests. If empty or undefined - allow all. exclude
- an Array of fields that will be excluded from the GET response (and not queried from the DB). persist
- an Array of fields that will be always persisted in GET response. eager
- type boolean
- whether or not current relation should persist in every GET response. require
- should a relation be required or not. For RMDBS means use either INNER
or LEFT
join. Default: false
. alias
- set alias for a relation. select
- type boolean
- if false
then the relation will be joined but not selected and not included in the response.
sort
{
sort: [
{
field: 'id',
order: 'DESC',
},
];
}
Optional
An Array of sort
objects that will be merged (combined) with query sort
if those are passed in GET request. If not - sort
will be added to the DB query as a stand-alone condition.
limit
{
limit: 25,
}
Optional
Default limit
that will be aplied to the DB query.
maxLimit
{
maxLimit: 100,
}
Optional
Max amount of results that can be queried in GET request.
Notice: it’s strongly recommended to set up this option. Otherwise DB query will be executed without any LIMIT if no limit
was passed in the query or if the limit
option hasn’t been set up in crud options.
cache
{
cache: 2000,
}
Optional
If Caching Results
is implemented on you project, then you can set up default cache
in milliseconds for GET response data.
Cache can be reseted by using cache=0
query parameter in your GET requests.
alwaysPaginate
{
alwaysPaginate: true,
}
Optional
Either or not always return an object with paginated data. Can be defined globally as well.
softDelete
{
softDelete: true,
}
Optional
A boolean value indicating whether the item should be soft-deleted. If set to true, the item will be soft-deleted instead of being permanently removed from the database.
dto
@Crud({
...
dto?: {
create?: Type<any>,
update?: Type<any>,
replace?: Type<any>
},
...
})
Optional
Request body validation DTO classes. If no DTO is provided to any of the option, then a CrudOptions.model.type
will be used as described in the Request validation section.
serialize
@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. Each option also accepts false
in order to not perform serialization for particular route.
Please see Response serialization section for more details.
Global options
In order to reduce some repetition in your CrudOptions
in every controller you can specify 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
are options for RequestQueryParser
that is being used in CrudRequestInterceptor
to parse/validate query and path params. Frontend has similar customization ability.
routes
are the same as here.
params
are the same as here.
query
are similar to options described here except the fact that limit
, maxLimit
, cache
, alwaysPaginate
can be applied only.
serialize
allows you to globally disable serialization for particular actions.
So in order to apply global options you need load them in your main.ts (index.ts) file BEFORE you import AppModule
class. That’s because TypeScript decorators are executed when we declare our class but not when we create new class instance. So in your main.ts
:
import { CrudConfigService } from '@dataui/crud';
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';
...
Notice: all those options can be overridden in each CrudController
.
Request authentication
In order to perform data filtering for authenticated requests, we provide @CrudAuth()
decorator. It accepts these options:
{
property?: string;
filter?: (req: any) => SCondition | void;
or?: (req: any) => SCondition | void;
persist?: (req: any) => ObjectLiteral;
}
property
- property on the Request
object where user’s data stored after successful authentication. Can be set globally as well.
filter
- a function that should return search condition and will be added to the query search params and path params as a $and
condition:
{Auth condition} AND {Path params} AND {Search|Filter}
or
- a function that should return search condition and will be added to the query search params and path params as a $or
condition. If it’s used then filter
function will be ignored.
{Auth condition} OR ({Path params} AND {Search|Filter})
persist
- a function that can return an object that will be added to the DTO on create
, update
, replace
actions. Useful in case if you need to prevent changing some sensitive entity properties even if it’s allowed in DTO validation.
@Crud({...})
@CrudAuth({
property: 'user',
filter: (user: User) => ({
id: user.id,
isActive: true,
})
})
Request validation
Query params and path params validation is performed by an interceptor. It parses query and path parameters and then validates them.
Body request validation is done by NestJs ValidationPipe
.
You can provide either create
, update
, replace
DTO in the CrudOptions.dto
options or use the following approach.
You can use CrudOptions.model.type
as a DTO that describes validation rules. We distinguish body validation on create
and update
methods. This was achieved by using validation groups.
Let’s take a look at this example:
import { Entity, Column, OneToMany } from 'typeorm';
import { IsOptional, IsString, MaxLength, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
import { CrudValidationGroups } from '@dataui/crud';
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[];
}
You can import CrudValidationGroups
enum and set up validation rules for each field on firing of POST
, PATCH
requests or both of them.
Response serialization
Serialization is performed using class-transformer
package and is already included and turned ON in each route.
So in your entity you can use some useful decorators:
import { Exclude } from 'class-transformer';
export class User {
email: string;
@Exclude()
password: string;
}
But there might be situations when you might need to use different serialization in different routes. In that case you can use CrudOptions.serialize
options.
IntelliSense
Please, keep in mind that we compose crud controllers by the logic inside our @Crud()
class decorator. And there are some unpleasant but not very significant side effects of this approach.
First, there is no IntelliSense on composed methods. That’s why we need to use CrudController
interface. This will help to make sure that you’re injecting proper CrudService
.
Second, even after adding CrudController
interface you still wouldn’t see composed methods, accessible from this
keyword, furthermore, you’ll get a TS error. In order to solve this, you can do as follows:
...
import { Crud, CrudController } from '@dataui/crud';
@Crud(Hero)
@Controller('heroes')
export class HeroesCrud implements CrudController<Hero> {
constructor(public service: HeroesService) {}
get base(): CrudController<Hero> {
return this;
}
}
Routes override
Here is the list of composed base routes methods by @Crud()
decorator:
{
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>;
}
Since all composed methods have Base
ending in their names, overriding those endpoints could be done in two ways:
-
Attach
@Override()
decorator without any argument to the newly created method wich name doesn’t containBase
ending. So if you want to overridegetManyBase
, you need to creategetMany
method. -
Attach
@Override('getManyBase')
decorator with passed base method name as an argument if you want to override base method with a function that has a custom name.
Example:
...
import {
Crud,
CrudController,
Override,
CrudRequest,
ParsedRequest,
ParsedBody,
CreateManyDto,
} from '@dataui/crud';
@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);
}
}
Notice: new custom route decorators were created to simplify process: @ParsedRequest()
and @ParsedBody()
. But you still can add your param decorators to any of the methods, e.g. @Param()
, @Session()
, etc. Or any of your own cutom route decorators.
Adding routes
Sometimes you might need to add a new route and to use @ParsedRequest()
in it. You need attach CrudRequestInterceptor
in order to do that:
...
import { UseInterceptors } from '@nestjs/common';
import {
ParsedRequest,
CrudRequest,
CrudRequestInterceptor,
} from '@dataui/crud';
...
@UseInterceptors(CrudRequestInterceptor)
@Get('/export/list.xlsx')
async exportSome(@ParsedRequest() req: CrudRequest) {
// some awesome feature handling
}
Additional decorators
There are two additional decorators that come out of the box: @Feature()
and @Action()
. You can use them with your ACL implementation. @Action()
will be applyed automaticaly on controller compoesd base methods. There is CrudActions
enum that you can import and use:
enum CrudActions {
ReadAll = 'Read-All',
ReadOne = 'Read-One',
CreateOne = 'Create-One',
CreateMany = 'Create-Many',
UpdateOne = 'Update-One',
ReplaceOne = 'Replace-One',
DeleteOne = 'Delete-One',
}
ACLGuard
dummy example with helper functions getFeature
and getAction
:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { getFeature, getAction } from '@dataui/crud';
@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;
}
}