From 3e53f05c90ee97f9eb9af8bacd91122d191a5dd1 Mon Sep 17 00:00:00 2001 From: Andrea Fassina Date: Tue, 12 Sep 2023 23:45:23 +0200 Subject: [PATCH] feat: add base controller --- packages/api-gateway/src/auth/auth.service.ts | 14 +- .../api-gateway/src/base/base.controller.ts | 223 ++++++++++++++++++ packages/api-gateway/src/base/base.service.ts | 45 ++-- .../interfaces/base-controller.interface.ts | 16 ++ .../base/interfaces/base-service.interface.ts | 18 ++ .../common/pipes/abstract-validation.pipe.ts | 30 +++ .../recovery-token/recovery-token.entity.ts | 5 +- .../recovery-token/recovery-token.service.ts | 9 +- 8 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 packages/api-gateway/src/base/base.controller.ts create mode 100644 packages/api-gateway/src/base/interfaces/base-controller.interface.ts create mode 100644 packages/api-gateway/src/base/interfaces/base-service.interface.ts create mode 100644 packages/api-gateway/src/common/pipes/abstract-validation.pipe.ts diff --git a/packages/api-gateway/src/auth/auth.service.ts b/packages/api-gateway/src/auth/auth.service.ts index 73e0a65..f9653dd 100644 --- a/packages/api-gateway/src/auth/auth.service.ts +++ b/packages/api-gateway/src/auth/auth.service.ts @@ -254,11 +254,13 @@ export class AuthService { const token = createRandomString(); const hashedToken = await hash(token, 10); - await this.recoveryToken.create({ - userId: user.id, - token: hashedToken, - expiresAt: new Date(Date.now() + this.expResetPassword), - }); + await this.recoveryToken.create( + { + token: hashedToken, + expiresAt: new Date(Date.now() + this.expResetPassword), + }, + user.id, + ); const payload: EmailResetPasswordDto = { token, email, @@ -274,7 +276,7 @@ export class AuthService { ): Promise { const hashedToken = await hash(token, 10); const exist = await this.recoveryToken.findOne({ - where: { token: hashedToken }, + token: hashedToken, }); if (!exist) throw new UnauthorizedException('Invalid password reset token'); diff --git a/packages/api-gateway/src/base/base.controller.ts b/packages/api-gateway/src/base/base.controller.ts new file mode 100644 index 0000000..967226b --- /dev/null +++ b/packages/api-gateway/src/base/base.controller.ts @@ -0,0 +1,223 @@ +import { + Body, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Type, + UseGuards, + UsePipes, +} from '@nestjs/common'; +import { AbstractValidationPipe } from '../common/pipes/abstract-validation.pipe'; +import { IBaseController } from './interfaces/base-controller.interface'; +import { IBaseService } from './interfaces/base-service.interface'; +import { BaseEntity } from './base.entity'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtGuard } from '../auth/guards/jwt.guard'; +import { UserRole } from '../app.roles'; +import { DeepPartial, FindManyOptions, FindOptionsWhere } from 'typeorm'; +import { Collections } from '../common/constants'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { User } from '../user/entities/user.entity'; +import { ACGuard, UseRoles } from 'nest-access-control'; + +/** + * Every controller in the application can use the ControllerFactory in order + * to avoid code duplication. Magically you have all CRUD methods to create, read, + * updated and delete an entity protected by the Role Base ACL and the api documentaion. + */ +export function ControllerFactory< + Entity extends BaseEntity, + CreateDTO extends DeepPartial, + UpdateDTO extends QueryDeepPartialEntity, +>( + createDto: Type, + updateDto: Type, + resource: Collections, +): Type> { + const createPipe = new AbstractValidationPipe( + { + forbidNonWhitelisted: true, + forbidUnknownValues: true, + whitelist: true, + transform: true, + stopAtFirstError: true, + }, + { body: createDto }, + ); + const updatePipe = new AbstractValidationPipe( + { + forbidNonWhitelisted: true, + forbidUnknownValues: true, + whitelist: true, + transform: true, + stopAtFirstError: true, + }, + { body: updateDto }, + ); + + class BaseController< + Entity extends BaseEntity, + CreateDTO extends DeepPartial, + UpdateDTO extends QueryDeepPartialEntity, + > implements IBaseController + { + protected readonly service: IBaseService; + constructor(service: IBaseService) { + this.service = service; + } + + /** + * Create a given entity + * @param {CreateDTO} dto The entity to be created + * @param {User} user The user that is making the request + * @returns The created resource + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @UseGuards(JwtGuard, ACGuard) + @UseRoles({ resource, action: 'create', possession: 'own' }) + @UsePipes(createPipe) + @ApiBearerAuth() + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized.', + }) + create(@Body() dto: CreateDTO, @CurrentUser() user: User): Promise { + // Create owned resource + return this.service.create(dto, user.id); + } + + /** + * Find entity by ID. If entity was not found in the database - returns null. + * @param {string} id The ID of the entity + * @param {User} user The user that is making the request + * @returns The entity that match the conditions or null. + */ + @Get(':id') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtGuard, ACGuard) + @UseRoles({ resource, action: 'read', possession: 'own' }) + @ApiBearerAuth() + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return the record or null.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized.', + }) + findById( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + // Admin can view any resource + if (user.role.includes(UserRole.ADMIN)) { + return this.service.findById(id); + } + // Member can view owned resource only + return this.service.findOne({ + id, + userId: user.id, + } as unknown as FindOptionsWhere); + } + + /** + * Find all entities. + * @param {User} user The user that is making the request + * @returns All the entities + */ + @Get() + @HttpCode(HttpStatus.OK) + @UseGuards(JwtGuard, ACGuard) + @UseRoles({ resource, action: 'read', possession: 'own' }) + @ApiBearerAuth() + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return an array of records.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized.', + }) + findAll(@CurrentUser() user: User): Promise { + // Admin can view any resources + if (user.role.includes(UserRole.ADMIN)) { + return this.service.find(); + } + // Member can view owned resources only + return this.service.find({ + where: { userId: user.id }, + } as unknown as FindManyOptions); + } + + /** + * Updates entity by a given conditions. + * @param {string} id The ID of the entity + * @param {UpdateDTO} dto The payload to update the entity + * @param {User} user The user that is making the request + * @returns NO_CONTENT + */ + @Patch(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtGuard, ACGuard) + @UseRoles({ resource, action: 'update', possession: 'own' }) + @UsePipes(updatePipe) + @ApiBearerAuth() + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'The record has been successfully updated.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized.', + }) + async updateById( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateDTO, + @CurrentUser() user: User, + ): Promise { + // Update owned resource + await this.service.updateById(id, dto, user.id); + } + + /** + * Soft delete entity by ID, which means an update of the deletedAt column. + * The record still exists in the database, but will not be retireved by any find/update query. + * @param {string} id The ID of the entity + * @param {User} user The user that is making the request + * @returns NO_CONTENT + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtGuard, ACGuard) + @UseRoles({ resource, action: 'delete', possession: 'own' }) + @ApiBearerAuth() + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'The record has been successfully deleted.', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized.', + }) + async deleteById( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + ): Promise { + // Delete owned resource + await this.service.deleteById(id, user.id); + } + } + + return BaseController; +} diff --git a/packages/api-gateway/src/base/base.service.ts b/packages/api-gateway/src/base/base.service.ts index 7f8006f..622e032 100644 --- a/packages/api-gateway/src/base/base.service.ts +++ b/packages/api-gateway/src/base/base.service.ts @@ -1,18 +1,29 @@ import { DeepPartial, FindManyOptions, - FindOneOptions, FindOptionsWhere, Repository, } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { BaseEntity } from './base.entity'; +import { IBaseService } from './interfaces/base-service.interface'; -export abstract class BaseService { +export abstract class BaseService< + Entity extends BaseEntity, + CreateDTO extends DeepPartial, + UpdateDTO extends QueryDeepPartialEntity, +> implements IBaseService +{ constructor(private readonly repo: Repository) {} - create(data: DeepPartial): Promise { - return this.repo.save(data); + /** + * Saves a given entity in the database. + * @param {CreateDTO} data The entity to be created + * @param {string} userId The userId of the user owner of the resource + * @returns The created resource + */ + create(data: CreateDTO, userId: string): Promise { + return this.repo.save({ ...data, userId: userId }); } /** @@ -27,11 +38,11 @@ export abstract class BaseService { /** * Finds first entity that matches given where condition. * If entity was not found in the database - returns null. - * @param {FindOneOptions} filter The matching conditions for finding + * @param {FindOptionsWhere} filter The matching conditions for finding * @returns The entity that match the conditions or null. */ - findOne(where: FindOneOptions): Promise { - return this.repo.findOne(where); + findOne(filter: FindOptionsWhere): Promise { + return this.repo.findOneBy(filter); } /** @@ -40,8 +51,7 @@ export abstract class BaseService { * @returns The entity that match the conditions or null. */ findById(id: string): Promise { - // @ts-expect-error don't know why ts raise err: Argument of type '{ id: string; }' is not assignable to parameter of type 'FindOptionsWhere | FindOptionsWhere[]' - return this.findOne({ where: { id } }); + return this.findOne({ id } as FindOptionsWhere); } /** @@ -50,24 +60,31 @@ export abstract class BaseService { * @param {FindOptionsWhere} filter The matching conditions for updating * @param {UpdateDTO} data The payload to update the entity */ - async update(filter: FindOptionsWhere, data: Entity): Promise { - await this.repo.update(filter, data as QueryDeepPartialEntity); + async update( + filter: FindOptionsWhere, + data: UpdateDTO, + ): Promise { + await this.repo.update(filter, data); } /** * Updates entity partially by ID. * Does not check if entity exist in the database. * @param {string} id The ID of the entity to update - * @param {Entity} data The payload to update the entity + * @param {UpdateDTO} data The payload to update the entity * @param {string} userId The userId of the user owner of the resource */ - async updateById(id: string, data: Entity, userId?: string): Promise { + async updateById( + id: string, + data: UpdateDTO, + userId?: string, + ): Promise { await this.repo.update( { id, ...(userId ? { userId: userId } : {}), } as FindOptionsWhere, - data as QueryDeepPartialEntity, + data, ); } diff --git a/packages/api-gateway/src/base/interfaces/base-controller.interface.ts b/packages/api-gateway/src/base/interfaces/base-controller.interface.ts new file mode 100644 index 0000000..4137ff1 --- /dev/null +++ b/packages/api-gateway/src/base/interfaces/base-controller.interface.ts @@ -0,0 +1,16 @@ +import { DeepPartial } from 'typeorm'; +import { BaseEntity } from '../base.entity'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { User } from '../../user/entities/user.entity'; + +export interface IBaseController< + T extends BaseEntity, + C extends DeepPartial, + U extends QueryDeepPartialEntity, +> { + create(data: C, user: User): Promise; + findById(id: string, user: User): Promise; + findAll(user: User): Promise; + updateById(id: string, dto: U, user: User): Promise; + deleteById(id: string, user: User): Promise; +} diff --git a/packages/api-gateway/src/base/interfaces/base-service.interface.ts b/packages/api-gateway/src/base/interfaces/base-service.interface.ts new file mode 100644 index 0000000..5837a9a --- /dev/null +++ b/packages/api-gateway/src/base/interfaces/base-service.interface.ts @@ -0,0 +1,18 @@ +import { DeepPartial, FindManyOptions, FindOptionsWhere } from 'typeorm'; +import { BaseEntity } from '../base.entity'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +export interface IBaseService< + T extends BaseEntity, + C extends DeepPartial, + U extends QueryDeepPartialEntity, +> { + create(data: C, userId: string): Promise; + find(options?: FindManyOptions): Promise; + findOne(filter: FindOptionsWhere): Promise; + findById(id: string): Promise; + update(filter: FindOptionsWhere, data: U): Promise; + updateById(id: string, data: U, userId?: string): Promise; + delete(filter: FindOptionsWhere, soft?: boolean): Promise; + deleteById(id: string, userId?: string, soft?: boolean): Promise; +} diff --git a/packages/api-gateway/src/common/pipes/abstract-validation.pipe.ts b/packages/api-gateway/src/common/pipes/abstract-validation.pipe.ts new file mode 100644 index 0000000..abb6984 --- /dev/null +++ b/packages/api-gateway/src/common/pipes/abstract-validation.pipe.ts @@ -0,0 +1,30 @@ +import { + ArgumentMetadata, + Injectable, + Type, + ValidationPipe, + ValidationPipeOptions, +} from '@nestjs/common'; + +@Injectable() +export class AbstractValidationPipe extends ValidationPipe { + constructor( + options: ValidationPipeOptions, + private readonly targetTypes: { + body?: Type; + query?: Type; + param?: Type; + custom?: Type; + }, + ) { + super(options); + } + + override async transform(value: any, metadata: ArgumentMetadata) { + const targetType = this.targetTypes[metadata.type]; + if (!targetType) { + return super.transform(value, metadata); + } + return super.transform(value, { ...metadata, metatype: targetType }); + } +} diff --git a/packages/api-gateway/src/recovery-token/recovery-token.entity.ts b/packages/api-gateway/src/recovery-token/recovery-token.entity.ts index 27a0d5a..ef4fb62 100644 --- a/packages/api-gateway/src/recovery-token/recovery-token.entity.ts +++ b/packages/api-gateway/src/recovery-token/recovery-token.entity.ts @@ -1,7 +1,8 @@ import { Column, Entity } from 'typeorm'; import { BaseEntity } from '../base/base.entity'; +import { Collections } from '../common/constants'; -@Entity({ name: 'recovery_tokens' }) +@Entity({ name: Collections.RECOVERY_TOKENS }) export class RecoveryToken extends BaseEntity { @Column({ type: 'uuid' }) userId!: string; @@ -9,6 +10,6 @@ export class RecoveryToken extends BaseEntity { @Column() token!: string; - @Column({ type: 'timestamp with time zone' }) + @Column({ type: 'timestamptz' }) expiresAt!: Date; } diff --git a/packages/api-gateway/src/recovery-token/recovery-token.service.ts b/packages/api-gateway/src/recovery-token/recovery-token.service.ts index 37ceba5..65b920d 100644 --- a/packages/api-gateway/src/recovery-token/recovery-token.service.ts +++ b/packages/api-gateway/src/recovery-token/recovery-token.service.ts @@ -1,11 +1,16 @@ import { Injectable } from '@nestjs/common'; import { BaseService } from '../base/base.service'; import { RecoveryToken } from './recovery-token.entity'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; @Injectable() -export class RecoveryTokenService extends BaseService { +export class RecoveryTokenService extends BaseService< + RecoveryToken, + DeepPartial, + QueryDeepPartialEntity +> { constructor( @InjectRepository(RecoveryToken) repo: Repository,