Skip to content

Commit

Permalink
Merge pull request #299 from jiho-kr/feature/exclude
Browse files Browse the repository at this point in the history
feat: excludes values for specific key from response
  • Loading branch information
jiho-kr authored Sep 18, 2023
2 parents 51056cb + cb81862 commit 3b824af
Show file tree
Hide file tree
Showing 17 changed files with 235 additions and 24 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ interface RouteBaseOption {
hide?: boolean;
response?: Type<unknown>;
};
exclude?: string[];
}
```

Expand Down
149 changes: 149 additions & 0 deletions spec/exclude/exclude.spec.ts
Original file line number Diff line number Diff line change
@@ -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<TestEntity> {
constructor(@InjectRepository(TestEntity) repository: Repository<TestEntity>) {
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<TestEntity> {
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();
});
});
9 changes: 7 additions & 2 deletions spec/logging/logging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -84,6 +84,7 @@ describe('Logging', () => {
where: {},
take: 20,
order: { col1: 'DESC' },
select: { col1: true, col2: true, deletedAt: true },
withDeleted: false,
relations: [],
},
Expand All @@ -100,7 +101,7 @@ describe('Logging', () => {
params: {
col1: '1',
},
fields: [],
fields: { col1: true, col2: true, deletedAt: true },
relations: [],
softDeleted: expect.any(Boolean),
},
Expand All @@ -117,6 +118,7 @@ describe('Logging', () => {
body: {
col2: 'test',
},
exclude: new Set(),
},
'CRUD PATCH /base/1',
);
Expand All @@ -129,6 +131,7 @@ describe('Logging', () => {
col1: '2',
},
body: {},
exclude: new Set(),
},
'CRUD PUT /base/2',
);
Expand All @@ -141,6 +144,7 @@ describe('Logging', () => {
col1: '1',
},
softDeleted: true,
exclude: new Set(),
},
'CRUD DELETE /base/1',
);
Expand All @@ -152,6 +156,7 @@ describe('Logging', () => {
params: {
col1: '1',
},
exclude: new Set(),
},
'CRUD POST /base/1/recover',
);
Expand Down
6 changes: 5 additions & 1 deletion src/lib/crud.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ describe('CrudService', () => {

it('should return entity', async () => {
await expect(
crudService.reservedReadOne({ params: { id: mockEntity.id } as Partial<BaseEntity>, relations: [] }),
crudService.reservedReadOne({
params: { id: mockEntity.id } as Partial<BaseEntity>,
relations: [],
}),
).resolves.toEqual(mockEntity);
});
});
Expand All @@ -46,6 +49,7 @@ describe('CrudService', () => {
crudService.reservedDelete({
params: {},
softDeleted: false,
exclude: new Set(),
}),
).rejects.toThrow(ConflictException);
});
Expand Down
26 changes: 21 additions & 5 deletions src/lib/crud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export class CrudService<T extends BaseEntity> {
return this.repository
.save(entities)
.then((result) => {
return isCrudCreateManyRequest<T>(crudCreateRequest) ? result : result[0];
return isCrudCreateManyRequest<T>(crudCreateRequest)
? result.map((entity) => this.excludeEntity(entity, crudCreateRequest.exclude))
: this.excludeEntity(result[0], crudCreateRequest.exclude);
})
.catch((error) => {
throw new ConflictException(error);
Expand All @@ -100,7 +102,9 @@ export class CrudService<T extends BaseEntity> {
_.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));
});
};

Expand All @@ -114,7 +118,9 @@ export class CrudService<T extends BaseEntity> {
_.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));
});
};

Expand All @@ -133,7 +139,7 @@ export class CrudService<T extends BaseEntity> {
}

await (crudDeleteOneRequest.softDeleted ? entity.softRemove() : entity.remove());
return entity;
return this.excludeEntity(entity, crudDeleteOneRequest.exclude);
});
};

Expand All @@ -143,7 +149,7 @@ export class CrudService<T extends BaseEntity> {
throw new NotFoundException();
}
await this.repository.recover(entity);
return entity;
return this.excludeEntity(entity, crudRecoverRequest.exclude);
});
};

Expand All @@ -162,4 +168,14 @@ export class CrudService<T extends BaseEntity> {
await runner.release();
}
}

private excludeEntity(entity: T, exclude: Set<string>): T {
if (exclude.size === 0) {
return entity;
}
for (const excludeKey of exclude.values()) {
delete entity[excludeKey as unknown as keyof T];
}
return entity;
}
}
1 change: 1 addition & 0 deletions src/lib/interceptor/create-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function CreateRequestInterceptor(crudOptions: CrudOptions, factoryOption
const crudCreateRequest: CrudCreateRequest<typeof crudOptions.entity> = {
body,
author: this.getAuthor(req, crudOptions, Method.CREATE),
exclude: new Set(crudOptions.routes?.[Method.CREATE]?.exclude ?? []),
};

this.crudLogger.logRequest(req, crudCreateRequest);
Expand Down
1 change: 1 addition & 0 deletions src/lib/interceptor/delete-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/lib/interceptor/read-many-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export function ReadManyRequestInterceptor(crudOptions: CrudOptions, factoryOpti

const crudReadManyRequest: CrudReadManyRequest<typeof crudOptions.entity> = new CrudReadManyRequest<typeof crudOptions.entity>()
.setPrimaryKey(factoryOption.primaryKeys ?? [])
.setSelect(
factoryOption.columns?.reduce((acc, { name }) => {
if (readManyOptions.exclude?.includes(name)) {
return acc;
}
return { ...acc, [name]: true };
}, {}),
)
.setPagination(pagination)
.setWithDeleted(
_.isBoolean(customReadManyRequestOptions?.softDeleted)
Expand Down
2 changes: 1 addition & 1 deletion src/lib/interceptor/read-one-request.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('ReadOneRequestInterceptor', () => {
const Interceptor = ReadOneRequestInterceptor({ entity: {} as typeof BaseEntity }, { relations: [], logger: new CrudLogger() });
const interceptor = new Interceptor();

expect(interceptor.getFields(undefined, undefined)).toEqual([]);
expect(interceptor.getFields(undefined, undefined)).toBeUndefined();
expect(interceptor.getFields(undefined, ['1', '2', '3'])).toEqual(['1', '2', '3']);
expect(interceptor.getFields(['11', '12', '13'], undefined)).toEqual(['11', '12', '13']);

Expand Down
23 changes: 16 additions & 7 deletions src/lib/interceptor/read-one-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,29 @@ export function ReadOneRequestInterceptor(crudOptions: CrudOptions, factoryOptio

async intercept(context: ExecutionContext, next: CallHandler<unknown>): Promise<Observable<unknown>> {
const req: Record<string, any> = context.switchToHttp().getRequest<Request>();

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

const crudReadOneRequest: CrudReadOneRequest<typeof crudOptions.entity> = {
params,
fields: this.getFields(customReadOneRequestOptions?.fields, fieldsByRequest),
fields: (
this.getFields(customReadOneRequestOptions?.fields, fieldsByRequest) ??
factoryOption.columns?.map((column) => column.name) ??
[]
).reduce((acc, name) => {
if (readOneOptions.exclude?.includes(name)) {
return acc;
}
return { ...acc, [name]: true };
}, {}),
softDeleted,
relations: this.getRelations(customReadOneRequestOptions),
};
Expand All @@ -46,14 +55,14 @@ export function ReadOneRequestInterceptor(crudOptions: CrudOptions, factoryOptio
return next.handle();
}

getFields(interceptorFields?: string[], requestFields?: string[]): string[] {
getFields(interceptorFields?: string[], requestFields?: string[]): string[] | undefined {
if (!interceptorFields) {
return requestFields ?? [];
return requestFields;
}
if (!requestFields) {
return interceptorFields ?? [];
return interceptorFields;
}
return _.intersection(interceptorFields, requestFields) ?? [];
return _.intersection(interceptorFields, requestFields);
}

checkFields(fields?: string | QueryString.ParsedQs | string[] | QueryString.ParsedQs[]): string[] | undefined {
Expand Down
Loading

0 comments on commit 3b824af

Please sign in to comment.