Skip to content

Commit

Permalink
fix: do not force decorators into controller, adds full CRUD example
Browse files Browse the repository at this point in the history
  • Loading branch information
etienne-bechara committed Apr 26, 2021
1 parent 87fe370 commit 336f26c
Show file tree
Hide file tree
Showing 16 changed files with 216 additions and 274 deletions.
57 changes: 46 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,15 @@ afterRemove(): Promise<Entity>;

### Creating an Entity Controller

Finally, you may automatic boot routes to manipulate your entities by extending the base controller and injecting the recently created service.
Finally, expose a controller injecting your service as dependency to allow manipulation through HTTP requests.

If you are looking for CRUD functionality you may copy exactly as the template below.

Example:

```ts
import { OrmController } from '@bechara/nestjs-orm';
import { Controller } from '@bechara/nestjs-core';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@bechara/nestjs-core';
import { OrmController, OrmPagination } from '@bechara/nestjs-orm';

// These DTOs are validations customized with class-validator and class-transformer
import { UserCreateDto, UserReadDto, UserUpdateDto } from './user.dto';
Expand All @@ -189,23 +191,56 @@ export class UserController extends OrmController<UserEntity> {
public constructor(
private readonly userService: UserService,
) {
super(userService, {
// Available built-in methods:
methods: [ 'GET', 'GET:id', 'POST', 'PUT', 'PUT:id', 'PATCH:id', 'DELETE:id' ],
super(userService);
}

// If no DTO is provided all requests shall pass
dto: { read: UserReadDto, create: UserCreateDto, update: UserUpdateDto },
});
@Get()
public async get(@Query() query: UserReadDto): Promise<OrmPagination<UserEntity>> {
// getReadArguments() is a built-in method that extracts pagination
// properties like sort, order, limit and offset
const { params, options } = this.getReadArguments(query);
return this.entityService.readAndCount(params, options);
}

@Get(':id')
public async getById(@Param('id') id: string): Promise<UserEntity> {
return this.entityService.readByIdOrFail(id);
}

@Post()
public async post(@Body() body: UserCreateDto): Promise<UserEntity> {
return this.entityService.createOne(body);
}

@Put()
public async put(@Body() body: UserCreateDto): Promise<UserEntity> {
return this.entityService.upsertOne(body);
}

@Put(':id')
public async putById(@Param('id') id: string, @Body() body: UserCreateDto): Promise<UserEntity> {
return this.entityService.updateById(id, body);
}

@Patch(':id')
public async patchById(@Param('id') id: string, @Body() body: UserUpdateDto): Promise<UserEntity> {
return this.entityService.updateById(id, body);
}

@Delete(':id')
public async deleteById(@Param('id') id: string): Promise<UserEntity> {
return this.entityService.removeById(id);
}

}

```


## Full Examples

Refer to `test` folder of this project for a full working example including:
- Entities with several different column types
- Entity relationships
- Entity relationships including ManyToOne and ManyToMany
- Service repository extension
- Controller extension
- Controller extension with CRUD functionality
220 changes: 16 additions & 204 deletions source/orm/orm.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { BadRequestException, Body, Delete, Get, NotFoundException, Param, Patch, Post, Put, Query, UseInterceptors } from '@bechara/nestjs-core';
import { EntityData } from '@mikro-orm/core';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import { unflatten } from 'flat';
import { UseInterceptors } from '@bechara/nestjs-core';

import { OrmPaginationDto } from './orm.dto';
import { OrmEntitySerializer } from './orm.interceptor';
import { OrmControllerOptions, OrmPaginatedResponse, OrmRequestValidation, OrmValidationData } from './orm.interface';
import { OrmReadArguments } from './orm.interface';
import { OrmService } from './orm.service';

/**
Expand All @@ -17,212 +12,29 @@ export abstract class OrmController<Entity> {

public constructor(
public readonly entityService: OrmService<Entity>,
protected readonly controllerOptions: OrmControllerOptions<Entity> = { },
) { }

/**
* Validates predefined request based on configuration options.
* @param params
*/
private async validateRequest(params: OrmRequestValidation): Promise<OrmValidationData> {
const options = this.controllerOptions;
options.methods ??= [ 'GET', 'GET:id', 'POST', 'PUT', 'PUT:id', 'PATCH:id', 'DELETE:id' ];
options.dto ??= { };

if (!options.methods.includes(params.method)) {
throw new NotFoundException(`Cannot ${params.method.split('_BY_')[0]} to path`);
}

if (params.create && options.dto.create) {
await this.plainToDto(params.create, options.dto.create);
}

if (params.update && options.dto.update) {
await this.plainToDto(params.update, options.dto.update);
}

let queryData, queryOptions;

if (params.read && options.dto.read) {
const paginationProperties = [ 'sort', 'order', 'limit', 'offset' ];
const parsedQuery = this.parseFilterOperators(params.read);

const query = await this.plainToDto(parsedQuery.stripped, options.dto.read);
queryData = parsedQuery.unflatted;
queryOptions = { ...query };

for (const key of paginationProperties) {
delete queryData[key];
}

for (const key in queryOptions) {
if (!paginationProperties.includes(key)) {
delete queryOptions[key];
}
}
}

return { queryData, queryOptions };
}

/**
* Transforms an object into desired type, returns the typed
* object or throws an exception with array of constraints.
* @param object
* @param type
* Given a request query object:
* - Split properties between params and options
* - Reorganize filter operator to expected ORM format
* - Validate filter operators.
* @param query
*/
private async plainToDto(object: any, type: any): Promise<any> {
const typedObject = plainToClass(type, object);
const validationErrors: string[] = [ ];
protected getReadArguments(query: any): OrmReadArguments<Entity> {
if (!query || typeof query !== 'object') return;

const failedConstraints = await validate(typedObject, {
whitelist: true,
forbidNonWhitelisted: true,
});
const optionsProperties = new Set([ 'sort', 'order', 'limit', 'offset' ]);
const options = { };

for (const failure of failedConstraints) {
if (failure.children) {
failure.children = failure.children.map((c) => {
return { parent: failure.property, ...c };
});
failedConstraints.push(...failure.children);
for (const key in query) {
if (optionsProperties.has(key)) {
options[key] = query[key];
delete query[key];
}

if (failure.constraints) {
let partials = Object.values(failure.constraints);

if (failure['parent']) {
partials = partials.map((p) => `${failure['parent']}: ${p}`);
}

validationErrors.push(...partials);
}
}

if (validationErrors.length > 0) {
throw new BadRequestException(validationErrors);
}

return typedObject;
}

/**
* Search provided object for ORM operators ($gt, $gte, $lt, $lte, $like, etc..)
* Returns two versions of the same object:
* • Stripped: version without operators, used for validation with DTOs
* • Unflatted: version with operators nested inside the property, used at ORM.
* @param object
*/
private parseFilterOperators(object: any = { }): { stripped: any; unflatted: any } {
const allowedOperators = new Set([ 'eq', 'gt', 'gte', 'lt', 'lte', 'ne', 'like', 're' ]);

const source = { ...object };
const stripped = { ...object };
const unflatted = { ...object };

for (const key in source) {
const operatorValidation = key.split('$');
if (operatorValidation.length <= 1) continue;

if (operatorValidation.length > 2) {
throw new BadRequestException(`${key} has too many filter operators`);
}

if (!allowedOperators.has(operatorValidation[1])) {
throw new BadRequestException(`${operatorValidation[1]} filter operator is not recognized`);
}

stripped[key.split('$')[0].replace(/\.+$/, '')] = stripped[key];
delete stripped[key];

const normalizedKey = key.replace(/\.+\$/, '$').replace('$', '.$');
unflatted[normalizedKey] = unflatted[key];
if (key !== normalizedKey) delete unflatted[key];
}

return {
stripped: unflatten(stripped),
unflatted: unflatten(unflatted),
};
}

/**
* Read all entities that matches desired criteria and
* returns within an object with pagination details.
* @param query
*/
@Get()
public async get(@Query() query: OrmPaginationDto & Entity): Promise<OrmPaginatedResponse<Entity>> {
const { queryData, queryOptions } = await this.validateRequest({ method: 'GET', read: query });
queryOptions.populate = this.controllerOptions.populate;
return this.entityService.readAndCount(queryData, queryOptions);
}

/**
* Read a single entity by its id.
* @param id
*/
@Get(':id')
public async getById(@Param('id') id: string): Promise<Entity> {
await this.validateRequest({ method: 'GET:id' });
const populate = this.controllerOptions.populateById ?? this.controllerOptions.populate;
return this.entityService.readByIdOrFail(id, { populate });
}

/**
* Creates a single entity validating its data
* across provided create DTO.
* @param body
*/
@Post()
public async post(@Body() body: EntityData<Entity>): Promise<Entity> {
await this.validateRequest({ method: 'POST', create: body });
return this.entityService.createOne(body);
}

/**
* Creates or updates a single entity validating its data
* across provided create DTO.
* @param body
*/
@Put()
public async put(@Body() body: EntityData<Entity>): Promise<Entity> {
await this.validateRequest({ method: 'PUT', create: body });
return this.entityService.upsertOne(body);
}

/**
* Replaces a single entity validating its data
* across provided create DTO.
* @param id
* @param body
*/
@Put(':id')
public async putById(@Param('id') id: string, @Body() body: EntityData<Entity>): Promise<Entity> {
await this.validateRequest({ method: 'PUT:id', create: body });
return this.entityService.updateById(id, body);
}

/**
* Updates a single entity validating its data
* across provided update DTO.
* @param id
* @param body
*/
@Patch(':id')
public async patchById(@Param('id') id: string, @Body() body: EntityData<Entity>): Promise<Entity> {
await this.validateRequest({ method: 'PATCH:id', update: body });
return this.entityService.updateById(id, body);
}

/**
* Deletes a single entity by its id.
* @param id
*/
@Delete(':id')
public async deleteById(@Param('id') id: string): Promise<Entity> {
await this.validateRequest({ method: 'DELETE:id' });
return this.entityService.removeById(id);
return { options, params: query };
}

}
Expand Down
4 changes: 2 additions & 2 deletions source/orm/orm.interface/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * from './orm.controller.options';
export * from './orm.create.options';
export * from './orm.module.options';
export * from './orm.paginated.response';
export * from './orm.pagination';
export * from './orm.read.arguments';
export * from './orm.read.options';
export * from './orm.read.params';
export * from './orm.request.validation';
Expand Down
16 changes: 0 additions & 16 deletions source/orm/orm.interface/orm.controller.options.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OrmQueryOrder } from '../orm.enum';

export interface OrmPaginatedResponse<Entity> {
export interface OrmPagination<Entity> {
sort: string;
order: OrmQueryOrder;
limit: number;
Expand Down
7 changes: 7 additions & 0 deletions source/orm/orm.interface/orm.read.arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { OrmReadOptions } from './orm.read.options';
import { OrmReadParams } from './orm.read.params';

export interface OrmReadArguments<Entity> {
params: OrmReadParams<Entity>;
options: OrmReadOptions<Entity>;
}
Loading

0 comments on commit 336f26c

Please sign in to comment.