From 5a98abfac149b61edcae0cced68640f132c4215a Mon Sep 17 00:00:00 2001 From: psachmann Date: Fri, 13 Oct 2023 15:51:37 +0200 Subject: [PATCH] EW-561 adding paging interceptor --- package-lock.json | 1 + package.json | 1 + src/console/console.module.ts | 2 +- src/modules/person/api/person.controller.ts | 16 ++++++--- src/modules/person/api/person.uc.ts | 34 +++++++++++++------ src/modules/person/domain/person.service.ts | 20 ++++++----- .../persistence/person-sorting.mapper.ts | 22 ------------ .../person/persistence/person.scope.ts | 14 ++++---- src/server/main.ts | 11 ++++-- src/server/server.module.ts | 2 +- .../global-pagination-headers.interceptor.ts | 23 +++++++++++++ src/shared/paging/helpers.ts | 8 +++++ src/shared/paging/index.ts | 5 +++ src/shared/paging/paged.query.params.ts | 15 ++++++++ src/shared/paging/paged.response.ts | 24 +++++++++++++ src/shared/persistence/index.ts | 2 +- src/shared/persistence/repo-base.ts | 26 +++++++------- src/shared/persistence/scope-base.ts | 16 ++++----- 18 files changed, 164 insertions(+), 78 deletions(-) delete mode 100644 src/modules/person/persistence/person-sorting.mapper.ts create mode 100644 src/shared/paging/global-pagination-headers.interceptor.ts create mode 100644 src/shared/paging/helpers.ts create mode 100644 src/shared/paging/index.ts create mode 100644 src/shared/paging/paged.query.params.ts create mode 100644 src/shared/paging/paged.response.ts diff --git a/package-lock.json b/package-lock.json index 192e5f4b3..1ff714d6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "axios": "^1.5.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "express": "^4.18.2", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "nest-commander": "^3.9.0", diff --git a/package.json b/package.json index c5941e3f9..ad32d7765 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "axios": "^1.5.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "express": "^4.18.2", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "nest-commander": "^3.9.0", diff --git a/src/console/console.module.ts b/src/console/console.module.ts index 443503b5f..03d5b8e1d 100644 --- a/src/console/console.module.ts +++ b/src/console/console.module.ts @@ -33,7 +33,7 @@ import { DbInitConsole } from './db-init.console.js'; entitiesTs: ['./src/**/*.entity.ts'], driverOptions: { connection: { - ssl: true, + ssl: false, }, }, }); diff --git a/src/modules/person/api/person.controller.ts b/src/modules/person/api/person.controller.ts index 390be6956..49cba7bb7 100644 --- a/src/modules/person/api/person.controller.ts +++ b/src/modules/person/api/person.controller.ts @@ -1,6 +1,6 @@ import { Mapper } from '@automapper/core'; import { getMapperToken } from '@automapper/nestjs'; -import { Body, Controller, Get, Inject, Post, Param, HttpException, HttpStatus, Query } from '@nestjs/common'; +import { Body, Controller, Get, Inject, Post, Param, HttpException, HttpStatus, Query, Res } from '@nestjs/common'; import { ApiBadRequestResponse, ApiCreatedResponse, @@ -18,6 +18,8 @@ import { PersonByIdParams } from './person-by-id.param.js'; import { PersonenQueryParam } from './personen-query.param.js'; import { FindPersonDatensatzDTO } from './finde-persondatensatz-dto.js'; import { PersonenDatensatz } from './personendatensatz.js'; +import { Paged, setPaginationHeaders } from '../../../shared/paging/index.js'; +import { Response } from 'express'; @ApiTags('person') @Controller({ path: 'person' }) @@ -56,13 +58,19 @@ export class PersonController { @ApiUnauthorizedResponse({ description: 'Not authorized to get persons.' }) @ApiForbiddenResponse({ description: 'Insufficient permissions to get persons.' }) @ApiInternalServerErrorResponse({ description: 'Internal server error while getting all persons.' }) - public async findPersons(@Query() queryParams: PersonenQueryParam): Promise { + public async findPersons( + @Query() queryParams: PersonenQueryParam, + @Res() res: Response, + ): Promise { const persondatensatzDTO: FindPersonDatensatzDTO = this.mapper.map( queryParams, PersonenQueryParam, FindPersonDatensatzDTO, ); - const persons: PersonenDatensatz[] = await this.uc.findAll(persondatensatzDTO); - return persons; + const persons: Paged = await this.uc.findAll(persondatensatzDTO); + + setPaginationHeaders(res, persons); + + return persons.items; } } diff --git a/src/modules/person/api/person.uc.ts b/src/modules/person/api/person.uc.ts index cc914b862..d971aa864 100644 --- a/src/modules/person/api/person.uc.ts +++ b/src/modules/person/api/person.uc.ts @@ -5,8 +5,9 @@ import { KeycloakUserService, UserDo } from '../../keycloak-administration/index import { CreatePersonDto } from '../domain/create-person.dto.js'; import { PersonService } from '../domain/person.service.js'; import { PersonDo } from '../domain/person.do.js'; -import { FindPersonDatensatzDTO } from './finde-persondatensatz-dto.js'; +import { FindPersonDatensatzDTO as FindPersonDatensatzDto } from './finde-persondatensatz-dto.js'; import { PersonenDatensatz } from './personendatensatz.js'; +import { Paged } from '../../../shared/paging/index.js'; @Injectable() export class PersonUc { @@ -51,15 +52,28 @@ export class PersonUc { throw result.error; } - public async findAll(personDto: FindPersonDatensatzDTO): Promise { - const personDo: PersonDo = this.mapper.map(personDto, FindPersonDatensatzDTO, PersonDo); - const result: PersonDo[] = await this.personService.findAllPersons(personDo); - if (result.length !== 0) { - const persons: PersonenDatensatz[] = result.map((person: PersonDo) => - this.mapper.map(person, PersonDo, PersonenDatensatz), - ); - return persons; + public async findAll(personDto: FindPersonDatensatzDto): Promise> { + const personDo: PersonDo = this.mapper.map(personDto, FindPersonDatensatzDto, PersonDo); + const result: Paged> = await this.personService.findAllPersons(undefined, undefined, personDo); + + if (result.total === 0) { + return { + total: result.total, + offset: result.offset, + limit: result.limit, + items: [], + }; } - return []; + + const persons: PersonenDatensatz[] = result.items.map((person: PersonDo) => + this.mapper.map(person, PersonDo, PersonenDatensatz), + ); + + return { + total: result.total, + offset: result.offset, + limit: result.limit, + items: persons, + }; } } diff --git a/src/modules/person/domain/person.service.ts b/src/modules/person/domain/person.service.ts index ca7c77b86..147da2c4e 100644 --- a/src/modules/person/domain/person.service.ts +++ b/src/modules/person/domain/person.service.ts @@ -29,21 +29,25 @@ export class PersonService { return { ok: false, error: new EntityNotFoundError(`Person with the following ID ${id} does not exist`) }; } - public async findAllPersons(personDto: PersonDo): Promise>> { + public async findAllPersons( + offset: Option, + limit: Option, + personDo: Partial>, + ): Promise>> { const scope: PersonScope = new PersonScope() - .searchBy({ - firstName: personDto.firstName, - lastName: personDto.lastName, - birthDate: personDto.birthDate, + .findBy({ + firstName: personDo.firstName, + lastName: personDo.lastName, + birthDate: personDo.birthDate, }) .sortBy('firstName', ScopeOrder.ASC) - .paged(0, 100); + .paged(offset, limit); const [persons, total]: Counted> = await this.personRepo.findBy(scope); return { total, - offset: 0, - limit: 100, + offset: offset ?? 0, + limit: limit ?? total, items: persons, }; } diff --git a/src/modules/person/persistence/person-sorting.mapper.ts b/src/modules/person/persistence/person-sorting.mapper.ts deleted file mode 100644 index 4d0e5500a..000000000 --- a/src/modules/person/persistence/person-sorting.mapper.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ -/* eslint-disable import/extensions */ -import { QueryOrderMap } from '@mikro-orm/core'; -import { SortOrderMap, SortOrder } from '../../../shared/interface/find-options'; -import { PersonDo } from '../domain/person.do'; -import { PersonEntity } from './person.entity'; - -export class PersonSortingMapper { - static mapDOSortOrderToQueryOrder(sort: SortOrderMap>): QueryOrderMap { - const queryOrderMap: QueryOrderMap = { - id: sort ? (sort.id ? sort.id : SortOrder.asc) : SortOrder.asc, - firstName: sort ? (sort.firstName ? sort.firstName : SortOrder.asc) : SortOrder.asc, - lastName: sort ? (sort.lastName ? sort.lastName : SortOrder.asc) : SortOrder.asc, - birthDate: sort ? (sort.birthDate ? sort.birthDate : SortOrder.asc) : SortOrder.asc, - }; - // ehmaliger filter von undefined funktioniert nicht - // Object.keys(queryOrderMap) - // .filter((key) => queryOrderMap[key] === undefined) - // .forEach((key) => delete queryOrderMap[key]); - return queryOrderMap; - } -} diff --git a/src/modules/person/persistence/person.scope.ts b/src/modules/person/persistence/person.scope.ts index c5a376d7a..734dc8a84 100644 --- a/src/modules/person/persistence/person.scope.ts +++ b/src/modules/person/persistence/person.scope.ts @@ -2,7 +2,7 @@ import { EntityName } from '@mikro-orm/core'; import { ScopeBase, ScopeOperator } from '../../../shared/persistence/index.js'; import { PersonEntity } from './person.entity.js'; -type SearchProps = { +type FindProps = { firstName?: string; lastName?: string; birthDate?: Date; @@ -13,14 +13,14 @@ export class PersonScope extends ScopeBase { return PersonEntity; } - public searchBy(searchProps: SearchProps): this { - this.findBy( + public findBy(findProps: FindProps, operator: ScopeOperator = ScopeOperator.AND): this { + this.findByInternal( { - firstName: searchProps.firstName, - lastName: searchProps.lastName, - birthDate: searchProps.birthDate, + firstName: findProps.firstName, + lastName: findProps.lastName, + birthDate: findProps.birthDate, }, - ScopeOperator.AND, + operator, ); return this; diff --git a/src/server/main.ts b/src/server/main.ts index 561db1e91..addcc0507 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -6,23 +6,28 @@ import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; import { HostConfig, ServerConfig } from '../shared/config/index.js'; import { GlobalValidationPipe } from '../shared/validation/index.js'; import { ServerModule } from './server.module.js'; +import { GlobalPaginationHeadersInterceptor } from '../shared/paging/index.js'; async function bootstrap(): Promise { const app: INestApplication = await NestFactory.create(ServerModule); - app.useGlobalPipes(new GlobalValidationPipe()); + const configService: ConfigService = app.get(ConfigService); + const port: number = configService.getOrThrow('HOST').PORT; const swagger: Omit = new DocumentBuilder() .setTitle('dBildungs IAM') .setDescription('The dBildungs IAM server API description') .setVersion('1.0') .build(); - const configService: ConfigService = app.get(ConfigService); - const port: number = configService.getOrThrow('HOST').PORT; + + app.useGlobalInterceptors(new GlobalPaginationHeadersInterceptor()); + app.useGlobalPipes(new GlobalValidationPipe()); app.setGlobalPrefix('api', { exclude: ['health'], }); + SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swagger)); await app.listen(port); + console.info(`\nListening on: http://127.0.0.1:${port}`); console.info(`API documentation can be found on: http://127.0.0.1:${port}/docs`); } diff --git a/src/server/server.module.ts b/src/server/server.module.ts index cfd2deacb..69eea7674 100644 --- a/src/server/server.module.ts +++ b/src/server/server.module.ts @@ -36,7 +36,7 @@ import { OrganisationApiModule } from '../modules/organisation/organisation-api. type: 'postgresql', driverOptions: { connection: { - ssl: true, + ssl: false, }, }, }); diff --git a/src/shared/paging/global-pagination-headers.interceptor.ts b/src/shared/paging/global-pagination-headers.interceptor.ts new file mode 100644 index 000000000..e75cba70b --- /dev/null +++ b/src/shared/paging/global-pagination-headers.interceptor.ts @@ -0,0 +1,23 @@ +import { Observable, map } from 'rxjs'; +import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; +import { setPaginationHeaders } from './helpers.js'; +import { PagedResponse } from './paged.response.js'; +import { Response } from 'express'; + +export class GlobalPaginationHeadersInterceptor implements NestInterceptor { + public intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((value: unknown) => { + if (value instanceof PagedResponse) { + const response: Response = context.switchToHttp().getResponse(); + + setPaginationHeaders(response, value); + + return value.items as unknown[]; + } + + return value; + }), + ); + } +} diff --git a/src/shared/paging/helpers.ts b/src/shared/paging/helpers.ts new file mode 100644 index 000000000..1d3fcfb31 --- /dev/null +++ b/src/shared/paging/helpers.ts @@ -0,0 +1,8 @@ +import { Response } from 'express'; +import { PagedResponse } from './paged.response.js'; + +export function setPaginationHeaders(response: Response, payload: PagedResponse): void { + response.setHeader('Pagination-Total', payload.total); + response.setHeader('Pagination-Offset', payload.offset); + response.setHeader('Pagination-Limit', payload.limit); +} diff --git a/src/shared/paging/index.ts b/src/shared/paging/index.ts new file mode 100644 index 000000000..f4e621e97 --- /dev/null +++ b/src/shared/paging/index.ts @@ -0,0 +1,5 @@ +export * from './global-pagination-headers.interceptor.js'; +export * from './helpers.js'; +export * from './paged.js'; +export * from './paged.query.params.js'; +export * from './paged.response.js'; diff --git a/src/shared/paging/paged.query.params.ts b/src/shared/paging/paged.query.params.ts new file mode 100644 index 000000000..d371c7520 --- /dev/null +++ b/src/shared/paging/paged.query.params.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export abstract class PagedQueryParams { + @ApiPropertyOptional({ + description: 'The offset of the paginated list.', + }) + public readonly offset?: number; + + @ApiPropertyOptional({ + description: 'The requested limit for the page size.', + }) + public readonly limit?: number; +} + + diff --git a/src/shared/paging/paged.response.ts b/src/shared/paging/paged.response.ts new file mode 100644 index 000000000..20ada1c2b --- /dev/null +++ b/src/shared/paging/paged.response.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { Paged } from './paged.js'; + +export class PagedResponse { + @Exclude() + public readonly total: number; + + @Exclude() + public readonly offset: number; + + @Exclude() + public readonly limit: number; + + @ApiProperty() + public readonly items: T[]; + + public constructor(page: Paged) { + this.total = page.total; + this.offset = page.offset; + this.limit = page.limit; + this.items = page.items; + } +} diff --git a/src/shared/persistence/index.ts b/src/shared/persistence/index.ts index 03de5e705..1ac8552db 100644 --- a/src/shared/persistence/index.ts +++ b/src/shared/persistence/index.ts @@ -1,3 +1,3 @@ -export * from './repo-base.js'; +// export * from './repo-base.js'; export * from './scope-base.js'; export * from './scope.enums.js'; diff --git a/src/shared/persistence/repo-base.ts b/src/shared/persistence/repo-base.ts index bf2be9442..adaf4bc34 100644 --- a/src/shared/persistence/repo-base.ts +++ b/src/shared/persistence/repo-base.ts @@ -1,16 +1,16 @@ -import { AnyEntity, EntityName } from '@mikro-orm/core'; -import { EntityManager } from '@mikro-orm/postgresql'; +// import { AnyEntity, EntityName } from '@mikro-orm/core'; +// import { EntityManager } from '@mikro-orm/postgresql'; -export abstract class RepoBase { - protected constructor(protected readonly em: EntityManager) {} +// export abstract class RepoBase { +// protected constructor(protected readonly em: EntityManager) {} - public abstract get entityName(): EntityName; +// public abstract get entityName(): EntityName; - public async findById(id: string): Promise>> { - const person: Option = await this.em.findOne(this.entityName, { id }); - if (person) { - return this.mapper.map(person, PersonEntity, PersonDo); - } - return null; - } -} +// public async findById(id: string): Promise>> { +// const person: Option = await this.em.findOne(this.entityName, { id }); +// if (person) { +// return this.mapper.map(person, PersonEntity, PersonDo); +// } +// return null; +// } +// } diff --git a/src/shared/persistence/scope-base.ts b/src/shared/persistence/scope-base.ts index 8735a101f..6dba8c16a 100644 --- a/src/shared/persistence/scope-base.ts +++ b/src/shared/persistence/scope-base.ts @@ -7,20 +7,20 @@ export abstract class ScopeBase { private readonly queryOrderMaps: QBQueryOrderMap[] = []; - private offset: number | undefined; + private offset: Option; - private limit: number | undefined; + private limit: Option; public abstract get entityName(): EntityName; - public async executeQuery(em: EntityManager): Promise<[T[], number]> { + public async executeQuery(em: EntityManager): Promise> { const qb: QueryBuilder = em.createQueryBuilder(this.entityName); - const result: [T[], number] = await qb + const result: Counted = await qb .select('*') .where(this.queryFilters) .orderBy(this.queryOrderMaps) - .offset(this.offset) - .limit(this.limit) + .offset(this.offset ?? undefined) + .limit(this.limit ?? undefined) .getResultAndCount(); return result; @@ -34,14 +34,14 @@ export abstract class ScopeBase { return this; } - public paged(offset: number, limit: number): this { + public paged(offset: Option, limit: Option): this { this.offset = offset; this.limit = limit; return this; } - protected findBy(props: Partial, operator: ScopeOperator): this { + protected findByInternal(props: Partial, operator: ScopeOperator): this { const query: QBFilterQuery = { [operator]: Object.keys(props) .filter((key: string) => props[key] !== undefined)