Skip to content

andreyyoshua/nestjsx-crud

 
 

Repository files navigation

Nestjsx Logo

A set of opinionated NestJS extensions and modules

Build Coverage License

NestJs CRUD for RESTful APIs

@nestjsx/crud has been designed for creating CRUD controllers and services for RESTful applications built with NestJs. It can be used with TypeORM repositories for now, but Mongoose functionality perhaps will be available in the future.

Features and merits

  • CRUD endpoints generation, based on a repository service and an entity.
  • Ability to generate CRUD endpoints with predefined path filter.
  • Composition of controller methods instead of inheritance (no tight coupling and less surprises)
  • Overriding controller methods with ease.
  • Request validation.
  • Query parameters parsing with filters, pagination, sorting, joins, nested joins, etc.
  • Super fast DB query building.
  • Additional handy decorators.

Table of Contents


Install

npm i @nestjsx/crud --save
npm i @nestjs/typeorm typeorm class-validator class-transformer --save

Getting Started

Entity

Assume you have some TypeORM enitity:

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class Hero {
  @PrimaryGeneratedColumn() id: number;

  @Column() name: string;
}

Service

Next, let's create a Repository Service for it:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { RepositoryService } from '@nestjsx/crud/typeorm';

import { Hero } from './hero.entity';

@Injectable()
export class HeroesService extends RepositoryService<Hero> {
  constructor(@InjectRepository(Hero) repo) {
    super(repo);
  }
}

Just like that!

Controller

Next, let create a Crud Controller that expose some RESTful endpoints for us:

import { Controller } from '@nestjs/common';
import { Crud } from '@nestjsx/crud';

import { Hero } from './hero.entity';
import { HeroesService } from './heroes.service';

@Crud(Hero)
@Controller('heroes')
export class HeroesController {
  constructor(public service: HeroesService) {}
}

And that's it, no more inheritance and tight coupling. Let's see what happens here:

@Crud(Hero)

We pass our Hero entity as a dto for Validation purpose and inject HeroesService. After that, all you have to do is to hook up everything in your module. And after being done with these simple steps your application will expose these endpoints:

API Endpoints

Get Many Entities

GET /heroes
GET /heroes/:heroId/perks

Result: array of entities | pagination object with data Status Codes: 200

Get One Entity

GET /heroes/:id
GET /heroes/:heroId/perks:id

Request Params: :id - some entity field (slug)
Result: entity object | error object
Status Codes: 200 | 404

Create One Entity

POST /heroes
POST /heroes/:heroId/perks

Request Body: entity object | entity object with nested (relational) objects
Result: created entity object | error object
Status Codes: 201 | 400

Create Many Entities

POST /heroes/bulk
POST /heroes/:heroId/perks/bulk

Request Body: array of entity objects | array of entity objects with nested (relational) objects

{
  "bulk": [{ "name": "Batman" }, { "name": "Batgirl" }]
}

Result: array of created entitie | error object
Status codes: 201 | 400

Update One Entity

PATCH /heroes/:id
PATCH /heroes/:heroId/perks/:id

Request Params: :id - some entity field (slug)
Request Body: entity object (or partial) | entity object with nested (relational) objects (or partial)
Result:: updated partial entity object | error object
Status codes: 200 | 400 | 404

Delete One Entity

DELETE /heroes/:id
DELETE /heroes/:heroId/perks/:id

Request Params: :id - some entity field (slug)
Result:: empty | error object
Status codes: 200 | 404

Swagger

Swagger support is present out of the box, including Query Parameters and Path Filter.

Query Parameters

GET endpoints that are generated by CRUD controller support some useful query parameters (all of them are optional):

  • fields - get selected fields in GET result
  • filter (alias: filter[]) - filter GET result by AND type of condition
  • or (alias: or[]) - filter GET result by OR type of condition
  • sort (alias: sort[]) - sort GET result by some field in ASC | DESC order
  • join (alias: join[]) - receive joined relational entities in GET result (with all or selected fields)
  • limit (alias per_page) - receive N amount of entities
  • offset - offset N amount of entities
  • page - receive a portion of limit (per_page) entities (alternative to offset)
  • cache - reset cache (if was enabled) and receive entities from the DB

fields

Selects fields that should be returned in the reponse body.

Syntax:

?fields=field1,field2,...

Example:

?fields=email,name

filter

Adds fields request condition (multiple conditions) to your request.

Syntax:

?filter=field||condition||value

?join=relation&filter=relation.field||condition||value

Notice: Using nested filter shall join relation first.

Examples:

?filter=name||eq||batman

?filter=isVillain||eq||false&filter=city||eq||Arkham (multiple filters are treated as a combination of AND type of conditions)

?filter=shots||in||12,26 (some conditions accept multiple values separated by commas)

?filter=power||isnull (some conditions don't accept value)

Alias: filter[]

filter conditions

(condition - operator):

  • eq (=, equal)
  • ne (!=, not equal)
  • gt (>, greater than)
  • lt (<, lower that)
  • gte (>=, greater than or equal)
  • lte (<=, lower than or equal)
  • starts (LIKE val%, starts with)
  • ends (LIKE %val, ends with)
  • cont (LIKE %val%, contains)
  • excl (NOT LIKE %val%, not contains)
  • in (IN, in range, accepts multiple values)
  • notin (NOT IN, not in range, accepts multiple values)
  • isnull (IS NULL, is NULL, doesn't accept value)
  • notnull (IS NOT NULL, not NULL, doesn't accept value)
  • between (BETWEEN, between, accepts two values)

or

Adds OR conditions to the request.

Syntax:

?or=field||condition||value

It uses the same filter conditions.

Rules and examples:

  • If there is only one or present (without filter) then it will be interpreted as simple filter:

?or=name||eq||batman

  • If there are multiple or present (without filter) then it will be interpreted as a compination of OR conditions, as follows:
    WHERE {or} OR {or} OR ...

?or=name||eq||batman&or=name||eq||joker

  • If there are one or and one filter then it will be interpreted as OR condition, as follows:
    WHERE {filter} OR {or}

?filter=name||eq||batman&or=name||eq||joker

  • If present both or and filter in any amount (one or miltiple each) then both interpreted as a combitation of AND conditions and compared with each other by OR condition, as follows:
    WHERE ({filter} AND {filter} AND ...) OR ({or} AND {or} AND ...)

?filter=type||eq||hero&filter=status||eq||alive&or=type||eq||villain&or=status||eq||dead

Alias: or[]

sort

Adds sort by field (by multiple fields) and order to query result.

Syntax:

?sort=field,ASC|DESC

Examples:

?sort=name,ASC

?sort=name,ASC&sort=id,DESC

Alias: sort[]

join

Receive joined relational objects in GET result (with all or selected fields). You can join as many relations as allowed in your Restful Options.

Syntax:

?join=relation

?join=relation||field1,field2,...

?join=relation1||field11,field12,...&join=relation1.nested||field21,field22,...&join=...

Examples:

?join=profile

?join=profile||firstName,email

?join=profile||firstName,email&join=notifications||content&join=tasks

?join=relation1&join=relation1.nested&join=relation1.nested.deepnested

Notice: id field always persists in relational objects. To use nested relations, the parent level MUST be set before the child level like example above.

Alias: join[]

limit

Receive N amount of entities.

Syntax:

?limit=number

Example:

?limit=10

Alias: per_page

offset

Offset N amount of entities

Syntax:

?offset=number

Example:

?offset=10

page

Receive a portion of limit (per_page) entities (alternative to offset). Will be applied if limit is set up.

Syntax:

?page=number

Example:

?page=2

cache

Reset cache (if was enabled) and receive entities from the DB.

Usage:

?cache=0

Repository Service

RepositoryService is the main class where all DB operations related logic is in place.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { RepositoryService } from '@nestjsx/crud/typeorm';
import { RestfulOptions } from '@nestjsx/crud';

import { Hero } from './hero.entity';

@Injectable()
export class HeroesService extends RepositoryService<Hero> {
  protected options: RestfulOptions = {};

  constructor(@InjectRepository(Hero) repo) {
    super(repo);
  }
}

This class can accept optional parameter called options that will be used as default options for GET requests. All fields inside that parameter are otional as well.

Restful Options

allow option

An Array of fields that are allowed to receive in GET request. If empty or undefined - allow all.

{
  allow: ['name', 'email'];
}

exclude option

an Array of fields that will be excluded from the GET response (and not queried from the DB).

{
  exclude: ['accessToken'];
}

persist option

An Array of fields that will be always persisted in GET response

{
  persist: ['createdAt'];
}

Notice: id field always persists automatically.

filter option

An Array of filter objects that will be merged (combined) with query filter if those are passed in GET request. If not - filter will be added to the DB query as a stand-alone condition.

If multiple items are added, they will be interpreted as AND type of conditions.

{
  filter: [
    {
      field: 'deleted',
      operator: 'ne',
      value: true,
    },
  ];
}

operator property is the same as filter conditions.

join option

An Object of relations that allowed to be fetched by passing join query parameter in GET requests.

{
  join: {
    profile: {
      persist: ['name']
    },
    tasks: {
      allow: ['content'],
    },
    notifications: {
      exclude: ['token']
    },
    company: {},
    'company.projects': {
      persist: ['status']
    },
    'users.projects.tasks': {
      exclude: ['description'],
    },
  }
}

Each key of join object must strongly match the name of the corresponding entity relation. If particular relation name is not present in this option, then user will not be able to join it in GET request.

Each relation option can have allow, exclude and persist. All of them are optional as well.

sort option

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.

{
  sort: [
    {
      field: 'id',
      order: 'DESC',
    },
  ];
}

limit option

Default limit that will be aplied to the DB query.

{
  limit: 25,
}

maxLimit option

Max amount of results that can be queried in GET request.

{
  maxLimit: 100,
}

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.

cache option

If Caching Results is implemented on you project, then you can set up default cache in milliseconds for GET response data.

{
  cache: 2000,
}

Cache.id strategy is based on a query that is built by a service, so if you change one of the query parameters in the next request, the result will be returned by DB and saved in the cache.

Cache can be reseted by using the query parameter in your GET requests.

Crud Controller

Our newly generated working horse.

...
import { Crud } from '@nestjsx/crud';

@Crud(Hero, {
  // CrudOptions goes here
})
@Controller('heroes')
export class HeroesCrudController {
  constructor(public service: HeroesService) {}
}

@Crud() decorator accepts two arguments - Entity class and CrudOptions object. All fields here are optional as well. Let's dive in some details.

Crud Options

Options (restful)

This option has the same structure as as Restful Options.

Notice: If you have this options set up in your RepositoryService, in that case they will be merged.

Routes Options

This object may have exclude and only arrays of route names which must be excluded or only which ones must be created accordingly.

{
  routes: {
    only: ['getManyBase'];
  }
}
{
  routes: {
    exclude: ['createManyBase'];
  }
}

Notice: If both are present, then exclude will be ignored.

Also, routes options object may have some options for each particular route:

{
  routes: {
    getManyBase: {
      interceptors: [],
    },
    getOneBase: {
      interceptors: [],
    },
    createOneBase: {
      interceptors: [],
    },
    createManyBase: {
      interceptors: [],
    },
    updateOneBase: {
      interceptors: [],
      allowParamsOverride: true
    },
    deleteOneBase: {
      interceptors: [],
      returnDeleted: true
    },
  }
}

interceptors - an array of your custom interceptors
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

Params Options

CrudOptions object may have params parameter that will be used for validation sake of you URL params and for defining a slug param (if it differs from id that is used by default).

Assume, you have an entity User that belongs to some Company and has a field companyId. And you whant to create UsersController so that an admin could access users from his own Company only. Let's do this:

...
import { Crud } from '@nestjsx/crud';

@Crud(Hero, {
  params: {
    companyId: 'number'
  }
})
@Controller('/company/:companyId/users')
export class UsersCrud {
  constructor(public service: UsersService) {}
}

In this example you're URL param name companyId should match the name of User.companyId field.

If you don't want to use numeric id (by default) and, say, you use some unique field, e.g. it's called slug and it's a UUID string - in that case need to add this:

{
  params: {
    slug: 'uuid';
  }
}

Or if your slug/id is just another random unique string, then:

{
  params: {
    id: 'string';
  }
}

As you might guess, all request will add companyId to the DB queries alongside with the :id (or another field that you defined) of GET, PATCH, DELETE requests. On POST (both: one and bulk) requests, companyId will be added to the dto automatically.

When you done with the controller, you'll need to add some logic to your AuthGuard or any other interface, where you do the authorization of a requester. You will need to match companyId URL param with the user.companyId entity that has been validated from the DB.

Validation Options

Request data validation is performed by using class-validator package and ValidationPipe. If you don't use this approach in your project, then you can implementat request data validation on your own.

We distinguish request validation on create and update methods. This was achieved by using validation groups.

Let's take a look at this example:

import { Entity, Column, JoinColumn, OneToOne } from 'typeorm';
import {
  IsOptional,
  IsString,
  MaxLength,
  IsNotEmpty,
  IsEmail,
  IsBoolean,
  ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CrudValidate } from '@nestjsx/crud';

import { BaseEntity } from '../base-entity';
import { UserProfile } from '../users-profiles/user-profile.entity';

const { CREATE, UPDATE } = CrudValidate;

@Entity('users')
export class User extends BaseEntity {
  @IsOptional({ groups: [UPDATE] }) // validate on PATCH only
  @IsNotEmpty({ groups: [CREATE] }) // validate on POST only
  @IsString({ always: true }) // validate on both
  @MaxLength(255, { always: true })
  @IsEmail({ require_tld: false }, { always: true })
  @Column({ type: 'varchar', length: 255, nullable: false, unique: true })
  email: string;

  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @IsBoolean({ always: true })
  @Column({ type: 'boolean', default: true })
  isActive: boolean;

  @Column({ nullable: true })
  profileId: number;

  // validate relations
  @IsOptional({ groups: [UPDATE] })
  @IsNotEmpty({ groups: [CREATE] })
  @ValidateNested({ always: true })
  @Type((t) => UserProfile)
  @OneToOne((type) => UserProfile, (p) => p.user, { cascade: true })
  @JoinColumn()
  profile: UserProfile;
}

You can import CrudValidate enum and set up validation rules for each field on firing of POST, PATCH requests or both of them.

You can pass you custom validation options here:

import { Crud } from '@nestjsx/crud';

@Crud(Hero, {
  validation: {
    validationError: {
      target: false,
      value: false
    }
  }
})
@Controller('heroes')
...

IntelliSense

Please, keep in mind that we compose HeroesController.prototype 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:

...
import { Crud, CrudController } from '@nestjsx/crud';

@Crud(Hero)
@Controller('heroes')
export class HeroesCrud {
  constructor(public service: HeroesService) {}
}

This will help to make sure that you're injecting proper Repository Service.

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, I've couldn't came up with better idea than this:

...
import { Crud, CrudController } from '@nestjsx/crud';

@Crud(Hero)
@Controller('heroes')
export class HeroesCrud {
  constructor(public service: HeroesService) {}

  get base(): CrudController<HeroesService, Hero> {
    return this;
  }
}

Routes Override

List of composed base routes methods:

getManyBase(
  @ParsedQuery() query: RestfulParamsDto,
  @ParsedOptions() options: CrudOptions,
): Promise<GetManyDefaultResponse<T> | T[]>;

getOneBase(
  @ParsedQuery() query: RestfulParamsDto,
  @ParsedOptions() options: CrudOptions,
): Promise<T>;

createOneBase(
  @ParsedParams() params: FilterParamParsed[],
  @ParsedBody() dto: T,
): Promise<T>;

createManyBase(
  @ParsedParams() params: FilterParamParsed[],
  @ParsedBody() dto: EntitiesBulk<T>,
): Promise<T[]>;

updateOneBase(
  @ParsedParams() params: FilterParamParsed[]
  @ParsedBody() dto: T,
): Promise<T>;

deleteOneBase(
  @ParsedParams() params: FilterParamParsed[]
): Promise<void | T>;

Since all composed methods have Base ending in their names, overriding those endpoints could be done in two ways:

  1. Attach @Override() decorator without any argument to the newly created method wich name doesn't contain Base ending. So if you want to override getManyBase, you need to create getMany method.

  2. 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.

...
import {
  Crud,
  CrudController,
  Override,
  RestfulParamsDto,
  ParsedQuery,
  ParsedParams,
  ParsedOptions
} from '@nestjsx/crud';

@Crud(Hero, {})
@Controller('heroes')
export class HeroesCrud {
  constructor(public service: HeroesService) {}

  get base(): CrudController<HeroesService, Hero> {
    return this;
  }

  @Override()
  getMany(
    @ParsedQuery() query: RestfulParamsDto,
    @ParsedOptions() options: CrudOptions,
  ) {
    // do some stuff
    return this.base.getManyBase(query, options);
  }

  @Override('getOneBase')
  getOneAndDoStuff(
    @ParsedQuery() query: RestfulParamsDto,
    @ParsedOptions() options: CrudOptions,
  ) {
    // do some stuff
  }

  @Override()
  createOne(
    @ParsedParams() params,
    @ParsedBody() body: Hero,
  ) {
    return this.base.createOneBase(params, body);
  }

  @Override()
  createMany(
    @ParsedBody() body: EntitiesBulk<Hero>, // validation is working ^_^
    @ParsedParams() params,
    ) {
    return this.base.createManyBase(params, body);
  }

  @Override('updateOneBase')
  coolFunction() {
    @ParsedParams() params,
    @ParsedBody() body: Hero,
  } {
    return this.base.updateOneBase(params, body);
  }

  @Override()
  async deleteOne(
    @ParsedParams() params,
  ) {
    return this.base.deleteOneBase(params);
  }

}

Notice: new custom route decorators were created to simplify process: @ParsedQuery(), @ParsedParams, @ParsedBody(), and @ParsedOptions(). 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 @ParsedQuery(), @ParsedParams, @ParsedOptions() in it. You need to use @UsePathInterceptors() method decorator in order to do that:

...
import { UsePathInterceptors } from '@nestjsx/crud';
...

@UsePathInterceptors()
@Get('/export/list.xlsx')
async exportSome(
  @ParsedQuery() query: RestfulParamsDto,
  @ParsedOptions() options: CrudOptions,
) {
  // some logic
}

By default this decorator will parse query and param. But you can specify what you need to parse by passing the appropriate argument (@UsePathInterceptors('query') or @UsePathInterceptors('param')).

Additional Decorators

There are two additional decorators that come out of the box: @Feature() and @Action():

...
import { Feature, Crud, CrudController } from '@nestjsx/crud';

@Feature('Heroes')
@Crud(Hero)
@Controller('heroes')
export class HeroesController {
  constructor(public service: HeroesService) {}
}

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',
  DeleteOne = 'Delete-One',
}

ACLGuard dummy example:

import { Reflector } from '@nestjs/core';
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { getFeature, getAction } from '@nestjsx/crud';

@Injectable()
export class ACLGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  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;
  }
}

Example Project

Here you can find an example project that uses @nestjsx/crud features. In order to run it and play with it, please do the following:

  1. If you're using Visual Studio Code it's recommended to add this option to your User Settings:
"javascript.implicitProjectConfig.experimentalDecorators": true

Or you can open integration/typeorm folder separately in the Visual Studio Code.

  1. Clone the project
git clone https://github.com/nestjsx/crud.git
cd crud/integration/typeorm
  1. Install Docker and Docker Compose if you haven't done it yet.

  2. Run Docker services:

docker-compose up -d
  1. Run application:
npm run serve

Server should start on default port 3333, you can override in PORT environment variable.

If you want to flush the DB data, run:

npm run db:flush

Contribution

Any support is wellcome. Please open an issue or submit a PR if you want to improve the functionality or help with testing edge cases.

Tests

docker-compose up -d
npm run test:e2e

License

MIT

About

NestJs CRUD for RESTful APIs

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • TypeScript 100.0%