Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: excludes values for specific key from response #299

Merged
merged 2 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Pigrabbit marked this conversation as resolved.
Show resolved Hide resolved
}
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
Loading