diff --git a/README.md b/README.md index d29b041..320c1a9 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ interface RouteBaseOption { hide?: boolean; response?: Type; }; + exclude?: string[]; } ``` diff --git a/spec/exclude/exclude.spec.ts b/spec/exclude/exclude.spec.ts new file mode 100644 index 0000000..d7af006 --- /dev/null +++ b/spec/exclude/exclude.spec.ts @@ -0,0 +1,149 @@ +/* eslint-disable max-classes-per-file */ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Controller, Injectable, Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; +import { IsOptional } from 'class-validator'; +import request from 'supertest'; +import { Entity, BaseEntity, Repository, PrimaryColumn, Column, DeleteDateColumn } from 'typeorm'; + +import { Crud } from '../../src/lib/crud.decorator'; +import { CrudService } from '../../src/lib/crud.service'; +import { CrudController } from '../../src/lib/interface'; +import { TestHelper } from '../test.helper'; + +@Entity('exclude-test') +class TestEntity extends BaseEntity { + @PrimaryColumn() + @IsOptional({ always: true }) + col1: number; + + @Column({ nullable: true }) + @IsOptional({ always: true }) + col2: string; + + @Column({ nullable: true }) + @IsOptional({ always: true }) + col3: string; + + @Column({ nullable: true }) + @IsOptional({ always: true }) + col4: string; + + @DeleteDateColumn() + deletedAt?: Date; +} + +@Injectable() +class TestService extends CrudService { + constructor(@InjectRepository(TestEntity) repository: Repository) { + super(repository); + } +} + +@Crud({ + entity: TestEntity, + routes: { + readOne: { exclude: ['col1'] }, + readMany: { exclude: ['col2'] }, + search: { exclude: ['col3'] }, + create: { exclude: ['col4'] }, + update: { exclude: ['col1', 'col2'] }, + delete: { exclude: ['col1', 'col3'] }, + upsert: { exclude: ['col1', 'col4'] }, + recover: { exclude: ['col1', 'col2', 'col3'] }, + }, +}) +@Controller('base') +class TestController implements CrudController { + constructor(public readonly crudService: TestService) {} +} + +@Module({ + imports: [TypeOrmModule.forFeature([TestEntity])], + controllers: [TestController], + providers: [TestService], +}) +class TestModule {} + +describe('Exclude key of entity', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [TestModule, TestHelper.getTypeOrmPgsqlModule([TestEntity])], + }).compile(); + app = moduleFixture.createNestApplication(); + await TestEntity.delete({}); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should be excluded from the response', async () => { + // exclude col4 + const { body: createdBody } = await request(app.getHttpServer()) + .post('/base') + .send({ + col1: 1, + col2: 'col2', + col3: 'col3', + col4: 'col4', + }) + .expect(HttpStatus.CREATED); + expect(createdBody).toEqual({ + col1: 1, + col2: 'col2', + col3: 'col3', + deletedAt: null, + }); + expect(createdBody.col4).not.toBeDefined(); + + // exclude col1 + const { body: readOneBody } = await request(app.getHttpServer()).get(`/base/${createdBody.col1}`).expect(HttpStatus.OK); + expect(readOneBody).toEqual({ col2: 'col2', col3: 'col3', col4: 'col4', deletedAt: null }); + expect(readOneBody.col1).not.toBeDefined(); + + // exclude col2 + const { body: readManyBody } = await request(app.getHttpServer()).get('/base').expect(HttpStatus.OK); + expect(readManyBody.data[0]).toEqual({ col1: 1, col3: 'col3', col4: 'col4', deletedAt: null }); + expect(readManyBody.data[0].col2).not.toBeDefined(); + + // exclude col3 + const { body: searchBody } = await request(app.getHttpServer()).post('/base/search').expect(HttpStatus.OK); + expect(searchBody.data[0]).toEqual({ col1: 1, col2: 'col2', col4: 'col4', deletedAt: null }); + expect(searchBody.data[0].col3).not.toBeDefined(); + + // exclude col1, col2 + const { body: updatedBody } = await request(app.getHttpServer()) + .patch(`/base/${createdBody.col1}`) + .send({ col2: 'test' }) + .expect(HttpStatus.OK); + expect(updatedBody).toEqual({ col3: 'col3', col4: 'col4', deletedAt: null }); + expect(updatedBody.col1).not.toBeDefined(); + expect(updatedBody.col2).not.toBeDefined(); + + // exclude col1, col3 + const { body: deletedBody } = await request(app.getHttpServer()).delete(`/base/${createdBody.col1}`).expect(HttpStatus.OK); + expect(deletedBody).toEqual({ col2: 'test', col4: 'col4', deletedAt: expect.any(String) }); + expect(deletedBody.col1).not.toBeDefined(); + expect(deletedBody.col3).not.toBeDefined(); + + // exclude col1, col2, col3 + const { body: recoverBody } = await request(app.getHttpServer()) + .post(`/base/${createdBody.col1}/recover`) + .expect(HttpStatus.CREATED); + expect(recoverBody).toEqual({ col4: 'col4', deletedAt: null }); + expect(recoverBody.col1).not.toBeDefined(); + expect(recoverBody.col2).not.toBeDefined(); + expect(recoverBody.col3).not.toBeDefined(); + + // exclude col1, col4 + const { body: upsertBody } = await request(app.getHttpServer()).put('/base/100').send({ col2: 'test' }).expect(HttpStatus.OK); + expect(upsertBody).toEqual({ col2: 'test', col3: null, deletedAt: null }); + expect(upsertBody.col1).not.toBeDefined(); + expect(upsertBody.col4).not.toBeDefined(); + }); +}); diff --git a/spec/logging/logging.spec.ts b/spec/logging/logging.spec.ts index 2cb8c5a..640c797 100644 --- a/spec/logging/logging.spec.ts +++ b/spec/logging/logging.spec.ts @@ -73,7 +73,7 @@ describe('Logging', () => { await request(app.getHttpServer()).post('/base').send({ col1: 1, }); - expect(loggerSpy).toHaveBeenNthCalledWith(1, { body: { col1: 1 } }, 'CRUD POST /base'); + expect(loggerSpy).toHaveBeenNthCalledWith(1, { body: { col1: 1 }, exclude: new Set() }, 'CRUD POST /base'); await request(app.getHttpServer()).get('/base'); expect(loggerSpy).toHaveBeenNthCalledWith( @@ -87,6 +87,7 @@ describe('Logging', () => { withDeleted: false, relations: [], }, + _exclude: new Set(), _pagination: { _isNext: false, type: 'cursor', _where: btoa('{}') }, _sort: 'DESC', }), @@ -102,6 +103,7 @@ describe('Logging', () => { }, fields: [], relations: [], + exclude: new Set(), softDeleted: expect.any(Boolean), }, 'CRUD GET /base/1', @@ -117,6 +119,7 @@ describe('Logging', () => { body: { col2: 'test', }, + exclude: new Set(), }, 'CRUD PATCH /base/1', ); @@ -129,6 +132,7 @@ describe('Logging', () => { col1: '2', }, body: {}, + exclude: new Set(), }, 'CRUD PUT /base/2', ); @@ -141,6 +145,7 @@ describe('Logging', () => { col1: '1', }, softDeleted: true, + exclude: new Set(), }, 'CRUD DELETE /base/1', ); @@ -152,6 +157,7 @@ describe('Logging', () => { params: { col1: '1', }, + exclude: new Set(), }, 'CRUD POST /base/1/recover', ); diff --git a/src/lib/crud.service.spec.ts b/src/lib/crud.service.spec.ts index 30d45d3..3738016 100644 --- a/src/lib/crud.service.spec.ts +++ b/src/lib/crud.service.spec.ts @@ -24,7 +24,11 @@ describe('CrudService', () => { it('should return entity', async () => { await expect( - crudService.reservedReadOne({ params: { id: mockEntity.id } as Partial, relations: [] }), + crudService.reservedReadOne({ + params: { id: mockEntity.id } as Partial, + relations: [], + exclude: new Set(), + }), ).resolves.toEqual(mockEntity); }); }); @@ -46,6 +50,7 @@ describe('CrudService', () => { crudService.reservedDelete({ params: {}, softDeleted: false, + exclude: new Set(), }), ).rejects.toThrow(ConflictException); }); diff --git a/src/lib/crud.service.ts b/src/lib/crud.service.ts index 89aea68..de3e7dc 100644 --- a/src/lib/crud.service.ts +++ b/src/lib/crud.service.ts @@ -29,7 +29,11 @@ export class CrudService { readonly reservedReadMany = async (crudReadManyRequest: CrudReadManyRequest): Promise> => { try { const { entities, total } = await (async () => { - const findEntities = this.repository.find({ ...crudReadManyRequest.findOptions }); + const findEntities = this.repository.find({ ...crudReadManyRequest.findOptions }).then((entities) => { + return crudReadManyRequest.exclude.size === 0 + ? entities + : entities.map((entity) => this.excludeEntity(entity, crudReadManyRequest.exclude)); + }); if (crudReadManyRequest.pagination.isNext) { const entities = await findEntities; @@ -64,7 +68,7 @@ export class CrudService { if (_.isNil(entity)) { throw new NotFoundException(); } - return entity; + return this.excludeEntity(entity, crudReadOneRequest.exclude); }); }; @@ -82,7 +86,9 @@ export class CrudService { return this.repository .save(entities) .then((result) => { - return isCrudCreateManyRequest(crudCreateRequest) ? result : result[0]; + return isCrudCreateManyRequest(crudCreateRequest) + ? result.map((entity) => this.excludeEntity(entity, crudCreateRequest.exclude)) + : this.excludeEntity(result[0], crudCreateRequest.exclude); }) .catch((error) => { throw new ConflictException(error); @@ -100,7 +106,9 @@ export class CrudService { _.merge(upsertEntity, { [crudUpsertRequest.author.property]: crudUpsertRequest.author.value }); } - return this.repository.save(_.assign(upsertEntity, crudUpsertRequest.body)); + return this.repository + .save(_.assign(upsertEntity, crudUpsertRequest.body)) + .then((entity) => this.excludeEntity(entity, crudUpsertRequest.exclude)); }); }; @@ -114,7 +122,9 @@ export class CrudService { _.merge(entity, { [crudUpdateOneRequest.author.property]: crudUpdateOneRequest.author.value }); } - return this.repository.save(_.assign(entity, crudUpdateOneRequest.body)); + return this.repository + .save(_.assign(entity, crudUpdateOneRequest.body)) + .then((entity) => this.excludeEntity(entity, crudUpdateOneRequest.exclude)); }); }; @@ -133,7 +143,7 @@ export class CrudService { } await (crudDeleteOneRequest.softDeleted ? entity.softRemove() : entity.remove()); - return entity; + return this.excludeEntity(entity, crudDeleteOneRequest.exclude); }); }; @@ -143,7 +153,7 @@ export class CrudService { throw new NotFoundException(); } await this.repository.recover(entity); - return entity; + return this.excludeEntity(entity, crudRecoverRequest.exclude); }); }; @@ -162,4 +172,14 @@ export class CrudService { await runner.release(); } } + + private excludeEntity(entity: T, exclude: Set): T { + if (exclude.size === 0) { + return entity; + } + for (const excludeKey of exclude.values()) { + delete entity[excludeKey as unknown as keyof T]; + } + return entity; + } } diff --git a/src/lib/interceptor/create-request.interceptor.ts b/src/lib/interceptor/create-request.interceptor.ts index 5f90021..9ec5516 100644 --- a/src/lib/interceptor/create-request.interceptor.ts +++ b/src/lib/interceptor/create-request.interceptor.ts @@ -30,6 +30,7 @@ export function CreateRequestInterceptor(crudOptions: CrudOptions, factoryOption const crudCreateRequest: CrudCreateRequest = { body, author: this.getAuthor(req, crudOptions, Method.CREATE), + exclude: new Set(crudOptions.routes?.[Method.CREATE]?.exclude ?? []), }; this.crudLogger.logRequest(req, crudCreateRequest); diff --git a/src/lib/interceptor/delete-request.interceptor.ts b/src/lib/interceptor/delete-request.interceptor.ts index bf3f58e..723c15f 100644 --- a/src/lib/interceptor/delete-request.interceptor.ts +++ b/src/lib/interceptor/delete-request.interceptor.ts @@ -30,6 +30,7 @@ export function DeleteRequestInterceptor(crudOptions: CrudOptions, factoryOption params, softDeleted, author: this.getAuthor(req, crudOptions, method), + exclude: new Set(deleteOptions.exclude ?? []), }; this.crudLogger.logRequest(req, crudDeleteOneRequest); diff --git a/src/lib/interceptor/read-many-request.interceptor.ts b/src/lib/interceptor/read-many-request.interceptor.ts index 979be94..2b5e0bb 100644 --- a/src/lib/interceptor/read-many-request.interceptor.ts +++ b/src/lib/interceptor/read-many-request.interceptor.ts @@ -57,6 +57,7 @@ export function ReadManyRequestInterceptor(crudOptions: CrudOptions, factoryOpti .setSort(readManyOptions.sort ? Sort[readManyOptions.sort] : CRUD_POLICY[method].default.sort) .setRelations(this.getRelations(customReadManyRequestOptions)) .setDeserialize(this.deserialize) + .setExclude(readManyOptions.exclude ?? []) .generate(); this.crudLogger.logRequest(req, crudReadManyRequest.toString()); diff --git a/src/lib/interceptor/read-one-request.interceptor.ts b/src/lib/interceptor/read-one-request.interceptor.ts index 72b5b36..7de6925 100644 --- a/src/lib/interceptor/read-one-request.interceptor.ts +++ b/src/lib/interceptor/read-one-request.interceptor.ts @@ -22,14 +22,14 @@ export function ReadOneRequestInterceptor(crudOptions: CrudOptions, factoryOptio async intercept(context: ExecutionContext, next: CallHandler): Promise> { const req: Record = context.switchToHttp().getRequest(); - + const readOneOptions = crudOptions.routes?.[method] ?? {}; const customReadOneRequestOptions: CustomReadOneRequestOptions = req[CUSTOM_REQUEST_OPTIONS]; const fieldsByRequest = this.checkFields(req.query?.fields); const softDeleted = _.isBoolean(customReadOneRequestOptions?.softDeleted) ? customReadOneRequestOptions.softDeleted - : crudOptions.routes?.[method]?.softDelete ?? (CRUD_POLICY[method].default.softDeleted as boolean); + : readOneOptions.softDelete ?? (CRUD_POLICY[method].default.softDeleted as boolean); const params = await this.checkParams(crudOptions.entity, req.params, factoryOption.columns); @@ -38,6 +38,7 @@ export function ReadOneRequestInterceptor(crudOptions: CrudOptions, factoryOptio fields: this.getFields(customReadOneRequestOptions?.fields, fieldsByRequest), softDeleted, relations: this.getRelations(customReadOneRequestOptions), + exclude: new Set(readOneOptions.exclude ?? []), }; this.crudLogger.logRequest(req, crudReadOneRequest); diff --git a/src/lib/interceptor/recover-request.interceptor.ts b/src/lib/interceptor/recover-request.interceptor.ts index 744daba..a195b34 100644 --- a/src/lib/interceptor/recover-request.interceptor.ts +++ b/src/lib/interceptor/recover-request.interceptor.ts @@ -20,6 +20,7 @@ export function RecoverRequestInterceptor(crudOptions: CrudOptions, factoryOptio const crudRecoverRequest: CrudRecoverRequest = { params, author: this.getAuthor(req, crudOptions, Method.RECOVER), + exclude: new Set(crudOptions.routes?.[Method.RECOVER]?.exclude ?? []), }; this.crudLogger.logRequest(req, crudRecoverRequest); diff --git a/src/lib/interceptor/search-request.interceptor.ts b/src/lib/interceptor/search-request.interceptor.ts index ab785da..ad3fe10 100644 --- a/src/lib/interceptor/search-request.interceptor.ts +++ b/src/lib/interceptor/search-request.interceptor.ts @@ -79,6 +79,7 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption ) .setRelations(customSearchRequestOptions?.relations ?? factoryOption.relations) .setDeserialize(this.deserialize) + .setExclude(searchOptions.exclude ?? []) .generate(); this.crudLogger.logRequest(req, crudReadManyRequest.toString()); diff --git a/src/lib/interceptor/update-request.interceptor.ts b/src/lib/interceptor/update-request.interceptor.ts index c472f64..484a1ed 100644 --- a/src/lib/interceptor/update-request.interceptor.ts +++ b/src/lib/interceptor/update-request.interceptor.ts @@ -24,6 +24,7 @@ export function UpdateRequestInterceptor(crudOptions: CrudOptions, factoryOption params, body, author: this.getAuthor(req, crudOptions, Method.UPDATE), + exclude: new Set(crudOptions.routes?.[Method.UPDATE]?.exclude ?? []), }; this.crudLogger.logRequest(req, crudUpdateOneRequest); diff --git a/src/lib/interceptor/upsert-request.interceptor.ts b/src/lib/interceptor/upsert-request.interceptor.ts index f70ea18..3100b00 100644 --- a/src/lib/interceptor/upsert-request.interceptor.ts +++ b/src/lib/interceptor/upsert-request.interceptor.ts @@ -43,6 +43,7 @@ export function UpsertRequestInterceptor(crudOptions: CrudOptions, factoryOption params, body, author: this.getAuthor(req, crudOptions, Method.UPSERT), + exclude: new Set(crudOptions.routes?.[Method.UPSERT]?.exclude ?? []), }; this.crudLogger.logRequest(req, crudUpsertRequest); diff --git a/src/lib/interface/decorator-option.interface.ts b/src/lib/interface/decorator-option.interface.ts index 9a07f6a..3fa0af8 100644 --- a/src/lib/interface/decorator-option.interface.ts +++ b/src/lib/interface/decorator-option.interface.ts @@ -25,6 +25,10 @@ interface RouteBaseOption { */ response?: Type; }; + /** + * Configures the keys of entity to exclude from the route's response + */ + exclude?: string[]; } export interface PrimaryKey { @@ -63,7 +67,7 @@ export interface CrudOptions { */ params?: string[]; /** - * If set to true, soft-deleted enitity could be included in the result. + * If set to true, soft-deleted entity could be included in the result. * @default false */ softDelete?: boolean; @@ -95,7 +99,7 @@ export interface CrudOptions { */ relations?: false | string[]; /** - * If set to true, soft-deleted enitity could be included in the result. + * If set to true, soft-deleted entity could be included in the result. * @default true */ softDelete?: boolean; @@ -122,7 +126,7 @@ export interface CrudOptions { */ relations?: false | string[]; /** - * If set to true, soft-deleted enitity could be included in the result. See `crud.policy.ts` for more details. + * If set to true, soft-deleted entity could be included in the result. See `crud.policy.ts` for more details. * @default true */ softDelete?: boolean; @@ -178,7 +182,7 @@ export interface CrudOptions { */ params?: string[]; /** - * If set to true, the enitity will be soft deleted. (Records the delete date of the entity) + * If set to true, the entity will be soft deleted. (Records the delete date of the entity) * @default true */ softDelete?: boolean; diff --git a/src/lib/interface/request.interface.ts b/src/lib/interface/request.interface.ts index 035e8be..11678ef 100644 --- a/src/lib/interface/request.interface.ts +++ b/src/lib/interface/request.interface.ts @@ -7,6 +7,7 @@ export type CrudRequestId = keyof T | Array; export interface CrudRequestBase { author?: Author; + exclude: Set; } export interface CrudReadRequestBase extends CrudRequestBase { diff --git a/src/lib/request/read-many.request.ts b/src/lib/request/read-many.request.ts index 4e032cc..20fba37 100644 --- a/src/lib/request/read-many.request.ts +++ b/src/lib/request/read-many.request.ts @@ -20,6 +20,7 @@ export class CrudReadManyRequest { private _sort: Sort; private _pagination: PaginationRequest; private _deserialize: (crudReadManyRequest: CrudReadManyRequest) => Where; + private _exclude: Set = new Set(); get primaryKeys() { return this._primaryKeys; @@ -35,6 +36,10 @@ export class CrudReadManyRequest { return this._sort; } + get exclude(): Set { + return this._exclude; + } + setPagination(pagination: PaginationRequest): this { this._pagination = pagination; return this; @@ -88,6 +93,11 @@ export class CrudReadManyRequest { return this; } + setExclude(exclude: string[]): this { + this._exclude = new Set(exclude); + return this; + } + generate(): this { if (this.pagination.type === PaginationType.OFFSET) { if (this.pagination.limit != null) {