From ef418e12882fe36e276ab28d342e1991d3c3eab6 Mon Sep 17 00:00:00 2001 From: Maximiliano Delgado Date: Sat, 27 Jan 2024 12:50:28 -0300 Subject: [PATCH] feat(core): add params decorator to pagination, sorting and filter --- .eslintrc | 3 +- package.json | 6 +- packages/archetype/tsconfig.json | 6 +- packages/camunda/tsconfig.json | 6 +- packages/commons/tsconfig.json | 6 +- packages/config/jest-mochawesome-reporter.js | 2 +- packages/config/tsconfig.json | 4 +- packages/core/README.md | 143 ++++++++ packages/core/package.json | 18 +- .../src/__test__/filtering.decorator.spec.ts | 319 ++++++++++++++++++ .../core/src/__test__/filtering.dto.spec.ts | 90 +++++ .../src/__test__/pagination.decorator.spec.ts | 145 ++++++++ .../core/src/__test__/pagination.dto.spec.ts | 51 +++ .../src/__test__/sorting.decorator.spec.ts | 147 ++++++++ .../core/src/__test__/sorting.dto.spec.ts | 92 +++++ .../decorators/dtos/filtering-criteria.dto.ts | 27 ++ .../core/src/decorators/dtos/filtering.dto.ts | 12 + packages/core/src/decorators/dtos/index.ts | 5 + .../src/decorators/dtos/pagination.dto.ts | 31 ++ .../src/decorators/dtos/sort-criteria.dto.ts | 16 + .../core/src/decorators/dtos/sorting.dto.ts | 15 + .../src/decorators/filtering.decorator.ts | 160 +++++++++ packages/core/src/decorators/index.ts | 4 + .../src/decorators/pagination.decorator.ts | 83 +++++ .../core/src/decorators/sorting.decorator.ts | 96 ++++++ packages/core/src/index.ts | 12 +- .../core/src/validations/app.config.schema.ts | 2 +- packages/core/tsconfig.json | 6 +- packages/dynamoose/tsconfig.json | 6 +- packages/elk/tsconfig.json | 6 +- packages/filters/tsconfig.json | 6 +- packages/health/tsconfig.json | 6 +- packages/http-client/tsconfig.json | 6 +- packages/mailer/tsconfig.json | 6 +- packages/paas/tsconfig.json | 6 +- packages/qrcode/tsconfig.json | 6 +- packages/redis/tsconfig.json | 6 +- packages/response-parser/tsconfig.json | 6 +- packages/test-utils/tsconfig.json | 6 +- packages/tracing/tsconfig.json | 6 +- packages/typeorm/tsconfig.json | 6 +- packages/utils/tsconfig.json | 6 +- 42 files changed, 1514 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/__test__/filtering.decorator.spec.ts create mode 100644 packages/core/src/__test__/filtering.dto.spec.ts create mode 100644 packages/core/src/__test__/pagination.decorator.spec.ts create mode 100644 packages/core/src/__test__/pagination.dto.spec.ts create mode 100644 packages/core/src/__test__/sorting.decorator.spec.ts create mode 100644 packages/core/src/__test__/sorting.dto.spec.ts create mode 100644 packages/core/src/decorators/dtos/filtering-criteria.dto.ts create mode 100644 packages/core/src/decorators/dtos/filtering.dto.ts create mode 100644 packages/core/src/decorators/dtos/index.ts create mode 100644 packages/core/src/decorators/dtos/pagination.dto.ts create mode 100644 packages/core/src/decorators/dtos/sort-criteria.dto.ts create mode 100644 packages/core/src/decorators/dtos/sorting.dto.ts create mode 100644 packages/core/src/decorators/filtering.decorator.ts create mode 100644 packages/core/src/decorators/pagination.decorator.ts create mode 100644 packages/core/src/decorators/sorting.decorator.ts diff --git a/.eslintrc b/.eslintrc index 4947b4ea..853bb3c2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -24,8 +24,9 @@ "jest": true }, "rules": { - "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/no-namespace": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-explicit-any": "off", diff --git a/package.json b/package.json index 3a738b40..c3f940de 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.490.0", "@nestjs/axios": "^3.0.0", + "@nestjs/class-transformer": "^0.4.0", "@nestjs/class-validator": "^0.13.4", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", @@ -74,6 +75,7 @@ "axios": "^1.6.5", "axios-retry": "^4.0.0", "camunda-external-task-client-js": "^2.3.1", + "class-transformer": "^0.5.1", "dynamoose": "^4.0.0", "fast-redact": "^3.3.0", "lodash": "^4.17.21", @@ -91,12 +93,14 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@automock/jest": "^2.1.0", "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/plugin-proposal-class-properties": "^7.16.7", - "@babel/plugin-proposal-decorators": "^7.23.7", + "@babel/plugin-proposal-decorators": "^7.23.9", "@babel/plugin-proposal-object-rest-spread": "^7.20.7", "@babel/plugin-proposal-optional-chaining": "^7.20.7", + "@babel/plugin-syntax-decorators": "^7.16.7", "@babel/preset-typescript": "^7.23.3", "@babel/traverse": "^7.23.7", "@commitlint/cli": "^17.8.1", diff --git a/packages/archetype/tsconfig.json b/packages/archetype/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/archetype/tsconfig.json +++ b/packages/archetype/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/camunda/tsconfig.json b/packages/camunda/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/camunda/tsconfig.json +++ b/packages/camunda/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/commons/tsconfig.json b/packages/commons/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/commons/tsconfig.json +++ b/packages/commons/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/config/jest-mochawesome-reporter.js b/packages/config/jest-mochawesome-reporter.js index 61bf37ac..cd9592fd 100644 --- a/packages/config/jest-mochawesome-reporter.js +++ b/packages/config/jest-mochawesome-reporter.js @@ -151,7 +151,7 @@ const createSuite = (_isRoot = false) => { const findTestCase = (_testCaseName, _suitTitle, _testFileContent) => { const ast = parser.parse(_testFileContent, { sourceType: 'module', - plugins: ['jsx', 'typescript'], + plugins: ['jsx', 'typescript', ['decorators', { decoratorsBeforeExport: true, legacy: true }]], }); let testCaseCode = ''; let hasDescribe = false; diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json index a45573cb..77defe55 100644 --- a/packages/config/tsconfig.json +++ b/packages/config/tsconfig.json @@ -22,6 +22,6 @@ "esModuleInterop": true, "strict": false, "skipLibCheck": true, - "types": ["tresdoce-nestjs-toolkit", "jest", "node"] - } + "types": ["tresdoce-nestjs-toolkit", "jest", "node"], + }, } diff --git a/packages/core/README.md b/packages/core/README.md index 33aa94fd..c77cd84b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -178,6 +178,149 @@ export class AppController { } ``` +## Param Decorators + +### Pagination + +Decorador para manejar la paginación. + +```typescript +// ./src/users/controllers/users.controller.ts +import { Controller, Get } from '@nestjs/common'; +import { Pagination, PaginationParamsDto } from '@tresdoce-nestjs-toolkit/core'; + +@Controller() +export class UsersController { + //... + + @Get() + findAll(@Pagination() pagination: PaginationParamsDto) { + const { page, size } = pagination; + console.log('Current page: ', page); + console.log('Items per page: ', size); + //... + } +} +``` + +
+💬 Para ver en detalle todas las propiedades de la configuración, hace clic acá. + +`page`: El número de la página actual, proporciona un punto de referencia claro para el usuario. + +- Type: `number` +- Default: `1` +- Example: `3` + +`size`: El número de elementos por página, este puede ser un valor predeterminado o especificado por el usuario con un +valor máximo de 100 items por repuesta. + +- Type: `number` +- Default: `10` +- Example: `25` + +
+ +#### URL Example + +- Schema: `://<:port>//?page=&size=` +- Example: `http://localhost:8080/v1/users?page=2&size=20` + +### Sorting + +Decorador para manejar el ordenamiento de los resultados. + +```typescript +// ./src/users/controllers/users.controller.ts +import { Controller, Get } from '@nestjs/common'; +import { Sorting, SortingParamsDto } from '@tresdoce-nestjs-toolkit/core'; + +@Controller() +export class UsersController { + //... + + @Get() + findAll(@Sorting(['id', 'email']) sorting: SortingParamsDto) { + const { fields } = sorting; + console.log('Sorting Fields: ', fields); + //... + } +} +``` + +
+💬 Para ver en detalle todas las propiedades de la configuración, hace clic acá. + +`field`: El nombre del campo por el cual se ordenará. + +- Type: `string` +- Example: `'id', 'email'` + +`order`: La dirección del ordenamiento, puede ser ascendente (`asc`) o descendente (`desc`). + +- Type: `asc | desc` +- Default: `asc` +- Example: `desc` + +
+ +#### URL Example + +- Schema: `://<:port>//?sort=:,:,...` +- Example: `http://localhost:8080/v1/items?sort=id:asc,email:desc` + +### Filtering + +Decorador para manejar el filtrado de los resultados basado en varios criterios. + +```typescript +// ./src/users/controllers/users.controller.ts +import { Controller, Get } from '@nestjs/common'; +import { FilteringParams, FilteringParamsDto } from '@tresdoce-nestjs-toolkit/core'; + +@Controller() +export class UsersController { + //... + + @Get() + findAll(@FilteringParams(['firstName', 'email', 'id']) filters: FilteringParamsDto) { + filters.forEach((filter) => { + console.log('Filter Property: ', filter.property); + console.log('Filter Rule: ', filter.rule); + console.log('Filter Values: ', filter.values); + }); + //... + } +} +``` + +
+💬 Para ver en detalle todas las propiedades de la configuración, hace clic acá. + +`property`: El nombre de la propiedad por la cual se filtra. + +- Type: `string` +- Example: `'firstName', 'id'` + +`rule`: La regla utilizada para filtrar, determina cómo se compara el valor de la propiedad. + +- Type: `FilterRule` +- Enum: `eq | neq | gt | gte | lt | lte | like | nlike | in | nin | isnull | isnotnull` +- Example: `gte` + +`values`: Los valores utilizados para el filtro según la regla. + +- Type: `string[] | number[] | boolean[]` +- Example ('gt', 'gte', 'lt', 'lte'): `[30]` +- Example ('in', 'nin'): `['John', 'Doe']` + +
+ +#### URL Example + +- Schema: `://<:port>//?filter=::,::,,...` +- Example: `http://localhost:8080/v1/users?filter=age:gte:30,name:like:John,status:in:active,inactive` + ## 📄 Changelog Todos los cambios notables de este paquete se documentarán en el archivo [Changelog](./CHANGELOG.md). diff --git a/packages/core/package.json b/packages/core/package.json index 76f65e49..869c4214 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,14 +36,20 @@ "directory": "../../dist/core" }, "peerDependencies": { + "@nestjs/class-transformer": "^0.4.0", + "@nestjs/class-validator": "^0.13.4", "@nestjs/common": "^10.3.0", "@nestjs/core": "^10.3.0", "@nestjs/platform-express": "^10.3.0", + "class-transformer": "^0.5.1", "joi": "^17.11.1", "reflect-metadata": "^0.2.1", "rxjs": "^7.5.5" }, "dependencies": { + "@nestjs/class-transformer": "^0.4.0", + "@nestjs/class-validator": "^0.13.4", + "class-transformer": "^0.5.1", "joi": "^17.11.1" }, "devDependencies": { @@ -60,7 +66,7 @@ "@pika/pack": { "pipeline": [ [ - "@pika/plugin-standard-pkg", + "@pika/plugin-ts-standard-pkg", { "exclude": [ "**/__test__/*", @@ -78,16 +84,6 @@ "**/__test__/**/*.key" ] } - ], - [ - "@pika/plugin-build-types", - { - "exclude": [ - "**/__test__/*", - "**/__test__/**/*.crt", - "**/__test__/**/*.key" - ] - } ] ] } diff --git a/packages/core/src/__test__/filtering.decorator.spec.ts b/packages/core/src/__test__/filtering.decorator.spec.ts new file mode 100644 index 00000000..3ca1605a --- /dev/null +++ b/packages/core/src/__test__/filtering.decorator.spec.ts @@ -0,0 +1,319 @@ +import { FilteringParams, Filtering, FilterRule } from '../decorators'; +import { BadRequestException } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +describe('Filtering Decorator', () => { + function getParamDecoratorFactory(decorator: Function) { + class Test { + @decorator() + public test(filteringParams: Filtering[]) {} + //public test(@decorator() filteringParams: Filtering[]) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test'); + return args[Object.keys(args)[0]].factory; + } + + it('should return empty array if no filter query params are provided', () => { + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: {}, + }), + }), + }; + const result = factory(['age', 'name'], mockFiltering); + expect(result).toBeDefined(); + expect(result).toEqual([]); + }); + + it('should return the correct fields, rules, and values from query params', () => { + const filter = 'age:gte:30,name:like:John'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + filter, + }, + }), + }), + }; + const result = factory(['age', 'name'], mockFiltering); + expect(result).toEqual([ + { property: 'age', rule: FilterRule.GREATER_THAN_OR_EQUALS, values: [30] }, + { property: 'name', rule: FilterRule.LIKE, values: ['John'] }, + ]); + }); + + it('should properly handle the filter rule EQUALS', () => { + const filter = 'property:eq:value'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([{ property: 'property', rule: FilterRule.EQUALS, values: ['value'] }]); + }); + + it('should properly handle the filter rule NOT_EQUALS', () => { + const filter = 'property:neq:value'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.NOT_EQUALS, values: ['value'] }, + ]); + }); + + it('should properly handle the filter rule GREATER_THAN', () => { + const filter = 'property:gt:100'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.GREATER_THAN, values: [100] }, + ]); + }); + + it('should properly handle the filter rule GREATER_THAN_OR_EQUALS', () => { + const filter = 'property:gte:100'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.GREATER_THAN_OR_EQUALS, values: [100] }, + ]); + }); + + it('should properly handle the filter rule LESS_THAN', () => { + const filter = 'property:lt:50'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([{ property: 'property', rule: FilterRule.LESS_THAN, values: [50] }]); + }); + + it('should properly handle the filter rule LESS_THAN_OR_EQUALS', () => { + const filter = 'property:lte:50'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.LESS_THAN_OR_EQUALS, values: [50] }, + ]); + }); + + it('should properly handle the filter rule LIKE', () => { + const filter = 'property:like:value'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([{ property: 'property', rule: FilterRule.LIKE, values: ['value'] }]); + }); + + it('should properly handle the filter rule NOT_LIKE', () => { + const filter = 'property:nlike:value'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.NOT_LIKE, values: ['value'] }, + ]); + }); + + it('should properly handle the filter rule IN', () => { + const filter = 'property:in:value1,value2'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.IN, values: ['value1', 'value2'] }, + ]); + }); + + it('should properly handle the filter rule NOT_IN', () => { + const filter = 'property:nin:value1,value2'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([ + { property: 'property', rule: FilterRule.NOT_IN, values: ['value1', 'value2'] }, + ]); + }); + + it('should properly handle the filter rule IS_NULL', () => { + const filter = 'property:isnull'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([{ property: 'property', rule: FilterRule.IS_NULL, values: [] }]); + }); + + it('should properly handle the filter rule IS_NOT_NULL', () => { + const filter = 'property:isnotnull'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { filter }, + }), + }), + }; + const result = factory(['property'], mockFiltering); + expect(result).toEqual([{ property: 'property', rule: FilterRule.IS_NOT_NULL, values: [] }]); + }); + + it('should throw BadRequestException if filter parameter format is invalid', () => { + const filter = 'invalid_format'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + filter, + }, + }), + }), + }; + expect(() => { + factory(['age', 'name'], mockFiltering as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if property is not included in validProperties', () => { + const filter = 'invalid_property:eq:value'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + filter, + }, + }), + }), + }; + expect(() => { + factory(['age', 'name'], mockFiltering as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if validProperties is not an array', () => { + const filter = 'age:eq:30'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + filter, + }, + }), + }), + }; + expect(() => { + factory('not_an_array', mockFiltering as any); + }).toThrow(BadRequestException); + expect(() => { + factory('not_an_array', mockFiltering as any); + }).toThrowError('Invalid filter configuration'); + }); + + it('should throw BadRequestException if filter parameter is in incorrect format', () => { + const filter = 'ageeq30'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + filter, + }, + }), + }), + }; + expect(() => { + factory(['age', 'name'], mockFiltering as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if filter rule is not a valid FilterRule', () => { + const filter = 'age:invalid_rule:value'; + const factory = getParamDecoratorFactory(FilteringParams); + const mockFiltering = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + filter, + }, + }), + }), + }; + expect(() => { + factory(['age', 'name'], mockFiltering as any); + }).toThrow(BadRequestException); + }); +}); diff --git a/packages/core/src/__test__/filtering.dto.spec.ts b/packages/core/src/__test__/filtering.dto.spec.ts new file mode 100644 index 00000000..f4b492a3 --- /dev/null +++ b/packages/core/src/__test__/filtering.dto.spec.ts @@ -0,0 +1,90 @@ +import { FilteringParamsDto, FilteringCriteriaDto } from '../decorators'; +import { validate } from '@nestjs/class-validator'; +import { plainToClass } from '@nestjs/class-transformer'; + +describe('Filtering Dto', () => { + describe('FilteringCriteriaDto', () => { + it('should validate a valid FilteringCriteriaDto', async () => { + const dto = plainToClass(FilteringCriteriaDto, { + property: 'age', + rule: 'gte', + values: ['30'], + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should not validate an invalid FilteringCriteriaDto with invalid rule', async () => { + const dto = plainToClass(FilteringCriteriaDto, { + property: 'age', + rule: 'invalid_rule', + values: ['30'], + }); + + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints).toHaveProperty('isEnum', 'rule must be a valid enum value'); + }); + + it('should validate a FilteringCriteriaDto with optional values for IS_NULL rule', async () => { + const dto = plainToClass(FilteringCriteriaDto, { + property: 'age', + rule: 'isnull', + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('FilteringParamsDto', () => { + it('should validate a valid FilteringParamsDto', async () => { + const dto = plainToClass(FilteringParamsDto, { + filters: [ + { property: 'age', rule: 'gte', values: ['30'] }, + { property: 'name', rule: 'like', values: ['John'] }, + ], + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should not validate an invalid FilteringParamsDto with invalid rule', async () => { + const dto = plainToClass(FilteringParamsDto, { + filters: [ + { property: 'age', rule: 'gte', values: ['30'] }, + { property: 'name', rule: 'invalid_rule', values: ['John'] }, + ], + }); + + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + + const ruleError = errors + .flatMap((error) => error.children) + .flatMap((child) => child.children) + .find( + (child) => child.property === 'rule' && child.constraints && child.constraints.isEnum, + ); + + if (ruleError && ruleError.constraints) { + expect(ruleError.constraints.isEnum).toContain('rule must be a valid enum value'); + } else { + throw new Error('Expected to find a rule constraint error'); + } + }); + + it('should validate an empty filters array', async () => { + const dto = plainToClass(FilteringParamsDto, { + filters: [], + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/packages/core/src/__test__/pagination.decorator.spec.ts b/packages/core/src/__test__/pagination.decorator.spec.ts new file mode 100644 index 00000000..06cd3e5c --- /dev/null +++ b/packages/core/src/__test__/pagination.decorator.spec.ts @@ -0,0 +1,145 @@ +import { Pagination, PaginationParams } from '../decorators'; +import { BadRequestException } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +describe('Pagination Decorator', () => { + function getParamDecoratorFactory(decorator: Function) { + class Test { + @decorator() + public test(paginationParams: PaginationParams) {} + //public test(@decorator() paginationParams: PaginationParams) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test'); + return args[Object.keys(args)[0]].factory; + } + + it('should return default page and size if no query params are provided', () => { + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: {}, // No se proporcionan parámetros + }), + }), + }; + const result = factory(null, mockPagination); + expect(result).toBeDefined(); + expect(result.page).toBe(1); // Verificar el valor por defecto de page + expect(result.size).toBe(10); // Verificar el valor por defecto de size + }); + + it('should return the correct page and size from query params', () => { + const page: number = 2; + const size: number = 20; + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + page, + size, + }, + }), + }), + }; + const result = factory(null, mockPagination); + expect(result.page).toBe(page); + expect(result.size).toBe(size); + }); + + it('should throw BadRequestException if page is not a positive integer', () => { + const page: number = -2; + const size: number = 10; + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + page, + size, + }, + }), + }), + }; + expect(() => { + factory(null, mockPagination as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if size is not a positive integer', () => { + const page: number = 2; + const size: number = -10; + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + page, + size, + }, + }), + }), + }; + expect(() => { + factory(null, mockPagination as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if page is a string', () => { + const page: string = 'invalid'; + const size: number = 10; + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + page, + size, + }, + }), + }), + }; + expect(() => { + factory(null, mockPagination as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if size is a string', () => { + const page: number = 2; + const size: string = 'invalid'; + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + page, + size, + }, + }), + }), + }; + expect(() => { + factory(null, mockPagination as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if size exceeds the maximum value', () => { + const page: number = 2; + const size: number = 200; + const factory = getParamDecoratorFactory(Pagination); + const mockPagination = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + page, + size, + }, + }), + }), + }; + expect(() => { + factory(null, mockPagination as any); + }).toThrow(BadRequestException); + }); +}); diff --git a/packages/core/src/__test__/pagination.dto.spec.ts b/packages/core/src/__test__/pagination.dto.spec.ts new file mode 100644 index 00000000..2178fc78 --- /dev/null +++ b/packages/core/src/__test__/pagination.dto.spec.ts @@ -0,0 +1,51 @@ +import { PaginationParamsDto } from '../decorators'; +import { validate } from '@nestjs/class-validator'; +import { plainToClass } from '@nestjs/class-transformer'; + +describe('PaginationParamsDto', () => { + it('should validate a valid DTO', async () => { + const dto = plainToClass(PaginationParamsDto, { + page: 2, + size: 20, + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should set default values for missing fields', async () => { + const dto = plainToClass(PaginationParamsDto, {}); + + // Check if default values are set + expect(dto.page).toBe(1); + expect(dto.size).toBe(10); + + const errors = await validate(dto); + + // Check if there are no validation errors + expect(errors.length).toBe(0); + }); + + it('should not validate an invalid DTO', async () => { + const dto = plainToClass(PaginationParamsDto, { + page: -2, + size: 150, + }); + + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints).toHaveProperty('isPositive', 'Page must be a positive integer'); + expect(errors[1].constraints).toHaveProperty('max', 'Size must not exceed 100'); + }); + + it('should not validate when page is undefined', async () => { + const dto = plainToClass(PaginationParamsDto, { + size: 20, + }); + + const errors = await validate(dto); + + expect(errors.length).toBe(0); + }); +}); diff --git a/packages/core/src/__test__/sorting.decorator.spec.ts b/packages/core/src/__test__/sorting.decorator.spec.ts new file mode 100644 index 00000000..f293317e --- /dev/null +++ b/packages/core/src/__test__/sorting.decorator.spec.ts @@ -0,0 +1,147 @@ +import { Sorting, SortingParams } from '../decorators'; +import { BadRequestException } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +describe('Sorting Decorator', () => { + function getParamDecoratorFactory(decorator: Function) { + class Test { + @decorator() + public test(sortingParams: SortingParams) {} + //public test(@decorator() sortingParams: SortingParams) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test'); + return args[Object.keys(args)[0]].factory; + } + + it('should return empty array if no sort query params are provided', () => { + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: {}, + }), + }), + }; + const result = factory(['user_id', 'first_name'], mockSorting); + expect(result).toBeDefined(); + expect(result.fields).toEqual([]); + }); + + it('should return the correct fields and order from query params', () => { + const sort = 'user_id:asc,first_name:desc'; + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + sort, + }, + }), + }), + }; + const result = factory(['user_id', 'first_name'], mockSorting); + expect(result.fields).toEqual([ + { field: 'user_id', order: 'asc' }, + { field: 'first_name', order: 'desc' }, + ]); + }); + + it('should throw BadRequestException if sort parameter format is invalid', () => { + const sort = 'invalid_format'; + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + sort, + }, + }), + }), + }; + expect(() => { + factory(['user_id', 'first_name'], mockSorting as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if field is not included in validParams', () => { + const sort = 'invalid_field:asc'; + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + sort, + }, + }), + }), + }; + expect(() => { + factory(['user_id', 'first_name'], mockSorting as any); + }).toThrow(BadRequestException); + }); + + it('should throw BadRequestException if validParams is not an array', () => { + const sort = 'user_id:asc'; + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + sort, + }, + }), + }), + }; + expect(() => { + factory('not_an_array', mockSorting as any); + }).toThrow(BadRequestException); + expect(() => { + factory('not_an_array', mockSorting as any); + }).toThrowError('Invalid configuration for sorting parameters'); + }); + + it('should throw BadRequestException if sort parameter is in incorrect format', () => { + const sort = 'user_idasc'; + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + sort, + }, + }), + }), + }; + expect(() => { + factory(['user_id', 'first_name'], mockSorting as any); + }).toThrow(BadRequestException); + /*expect(() => { + factory(['user_id', 'first_name'], mockSorting as any); + }).toThrowError( + `Invalid sort parameter: "user_idasc". It must be in the format 'field_name[:direction]' where direction is 'asc' or 'desc' and is optional.`, + );*/ + }); + + it('should throw BadRequestException if sort direction is not "asc" or "desc"', () => { + const sort = 'user_id:ascending'; + const factory = getParamDecoratorFactory(Sorting); + const mockSorting = { + switchToHttp: () => ({ + getRequest: () => ({ + query: { + sort, + }, + }), + }), + }; + expect(() => { + factory(['user_id', 'first_name'], mockSorting as any); + }).toThrow(BadRequestException); + expect(() => { + factory(['user_id', 'first_name'], mockSorting as any); + }).toThrowError( + `Invalid sort parameter: "user_id:ascending". It must be in the format 'field_name[:direction]' where direction is 'asc' or 'desc' and is optional.`, + ); + }); +}); diff --git a/packages/core/src/__test__/sorting.dto.spec.ts b/packages/core/src/__test__/sorting.dto.spec.ts new file mode 100644 index 00000000..adf27995 --- /dev/null +++ b/packages/core/src/__test__/sorting.dto.spec.ts @@ -0,0 +1,92 @@ +import { SortingParamsDto, SortCriteriaDto } from '../decorators'; +import { validate } from '@nestjs/class-validator'; +import { plainToClass } from '@nestjs/class-transformer'; + +describe('Sorting Dto', () => { + describe('SortCriteriaDto', () => { + it('should validate a valid SortCriteriaDto', async () => { + const dto = plainToClass(SortCriteriaDto, { + field: 'user_id', + order: 'asc', + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should not validate an invalid SortCriteriaDto', async () => { + const dto = plainToClass(SortCriteriaDto, { + field: 'user_id', + order: 'ascending', + }); + + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints).toHaveProperty( + 'isIn', + 'order must be one of the following values: asc, desc', + ); + }); + + it('should set default order value if missing', async () => { + const dto = plainToClass(SortCriteriaDto, { + field: 'user_id', + }); + + expect(dto.order).toBe('asc'); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('SortingParamsDto', () => { + it('should validate a valid SortingParamsDto', async () => { + const dto = plainToClass(SortingParamsDto, { + fields: [ + { field: 'user_id', order: 'asc' }, + { field: 'first_name', order: 'desc' }, + ], + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should not validate an invalid SortingParamsDto', async () => { + const dto = plainToClass(SortingParamsDto, { + fields: [ + { field: 'user_id', order: 'asc' }, + { field: 'first_name', order: 'ascending' }, + ], + }); + + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + + const orderError = errors + .flatMap((error) => error.children) + .flatMap((child) => child.children) + .find((child) => child.property === 'order' && child.constraints && child.constraints.isIn); + + if (orderError && orderError.constraints) { + expect(orderError.constraints.isIn).toContain( + 'order must be one of the following values: asc, desc', + ); + } else { + throw new Error('Expected to find an order constraint error'); + } + }); + + it('should validate an empty fields array', async () => { + const dto = plainToClass(SortingParamsDto, { + fields: [], + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/packages/core/src/decorators/dtos/filtering-criteria.dto.ts b/packages/core/src/decorators/dtos/filtering-criteria.dto.ts new file mode 100644 index 00000000..3588ae94 --- /dev/null +++ b/packages/core/src/decorators/dtos/filtering-criteria.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString, IsArray, IsOptional } from '@nestjs/class-validator'; +import { FilterRule } from '../filtering.decorator'; + +export class FilteringCriteriaDto { + @ApiProperty({ example: 'age', description: 'The property to filter by.' }) + @IsString() + property: string; + + @ApiProperty({ + example: FilterRule.GREATER_THAN, + enum: FilterRule, + description: 'The filtering rule to apply.', + }) + @IsEnum(FilterRule) + rule: FilterRule; + + @ApiProperty({ + example: ['30'], + description: 'The value(s) for the filtering criterion.', + type: [String], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + values: string[]; +} diff --git a/packages/core/src/decorators/dtos/filtering.dto.ts b/packages/core/src/decorators/dtos/filtering.dto.ts new file mode 100644 index 00000000..d0a4833c --- /dev/null +++ b/packages/core/src/decorators/dtos/filtering.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ValidateNested, IsArray } from '@nestjs/class-validator'; +import { Type } from '@nestjs/class-transformer'; +import { FilteringCriteriaDto } from './filtering-criteria.dto'; + +export class FilteringParamsDto { + @ApiProperty({ type: [FilteringCriteriaDto], description: 'List of filtering criteria' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FilteringCriteriaDto) + filters: FilteringCriteriaDto[]; +} diff --git a/packages/core/src/decorators/dtos/index.ts b/packages/core/src/decorators/dtos/index.ts new file mode 100644 index 00000000..4f35fb57 --- /dev/null +++ b/packages/core/src/decorators/dtos/index.ts @@ -0,0 +1,5 @@ +export * from './pagination.dto'; +export * from './sort-criteria.dto'; +export * from './sorting.dto'; +export * from './filtering-criteria.dto'; +export * from './filtering.dto'; diff --git a/packages/core/src/decorators/dtos/pagination.dto.ts b/packages/core/src/decorators/dtos/pagination.dto.ts new file mode 100644 index 00000000..be34fcfa --- /dev/null +++ b/packages/core/src/decorators/dtos/pagination.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from '@nestjs/class-transformer'; +import { IsOptional, IsPositive, Max, IsInt } from '@nestjs/class-validator'; + +export class PaginationParamsDto { + @ApiProperty({ + description: 'The current page number', + default: 1, + example: 3, + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'Page must be a positive integer' }) + @IsPositive({ message: 'Page must be a positive integer' }) + page: number = 1; + + @ApiProperty({ + description: 'The number of items per page', + maximum: 100, + default: 10, + example: 20, + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'Size must be a positive integer' }) + @IsPositive({ message: 'Size must be a positive integer' }) + @Max(100, { message: 'Size must not exceed 100' }) + size: number = 10; +} diff --git a/packages/core/src/decorators/dtos/sort-criteria.dto.ts b/packages/core/src/decorators/dtos/sort-criteria.dto.ts new file mode 100644 index 00000000..12894e6b --- /dev/null +++ b/packages/core/src/decorators/dtos/sort-criteria.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsIn } from '@nestjs/class-validator'; + +export class SortCriteriaDto { + @ApiProperty({ description: 'The field name to sort by.' }) + @IsString() + field: string; + + @ApiProperty({ + description: `The order of sorting, either 'asc' or 'desc'.`, + enum: ['asc', 'desc'], + default: 'asc', + }) + @IsIn(['asc', 'desc']) + order: 'asc' | 'desc' = 'asc'; +} diff --git a/packages/core/src/decorators/dtos/sorting.dto.ts b/packages/core/src/decorators/dtos/sorting.dto.ts new file mode 100644 index 00000000..a07de590 --- /dev/null +++ b/packages/core/src/decorators/dtos/sorting.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from '@nestjs/class-transformer'; +import { IsArray, ValidateNested } from '@nestjs/class-validator'; +import { SortCriteriaDto } from './sort-criteria.dto'; + +export class SortingParamsDto { + @ApiProperty({ + description: 'An array of sorting criteria.', + type: [SortCriteriaDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SortCriteriaDto) + fields: SortCriteriaDto[]; +} diff --git a/packages/core/src/decorators/filtering.decorator.ts b/packages/core/src/decorators/filtering.decorator.ts new file mode 100644 index 00000000..4b93f2d7 --- /dev/null +++ b/packages/core/src/decorators/filtering.decorator.ts @@ -0,0 +1,160 @@ +import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { Request } from 'express'; + +/** + * Enum representing valid filter rules for query parameters. + */ +export enum FilterRule { + EQUALS = 'eq', + NOT_EQUALS = 'neq', + GREATER_THAN = 'gt', + GREATER_THAN_OR_EQUALS = 'gte', + LESS_THAN = 'lt', + LESS_THAN_OR_EQUALS = 'lte', + LIKE = 'like', + NOT_LIKE = 'nlike', + IN = 'in', + NOT_IN = 'nin', + IS_NULL = 'isnull', + IS_NOT_NULL = 'isnotnull', +} + +/** + * Interface representing a single filtering criterion. + * @template T - The type of the value(s) for the filtering criterion. + */ +export interface Filtering { + property: string; + rule: FilterRule; + values: T[]; +} + +/** + * Converts a filter value string to the appropriate type based on the filter rule. + * @param rule - The filter rule. + * @param value - The string value to convert. + * @returns The value converted to the appropriate type, or undefined for IS_NULL and IS_NOT_NULL rules. + */ +const convertFilterValue = (rule: FilterRule, value: string): any => { + /* istanbul ignore next */ + switch (rule) { + case FilterRule.IN: + case FilterRule.NOT_IN: + return value; + case FilterRule.IS_NULL: + case FilterRule.IS_NOT_NULL: + /* istanbul ignore next */ + return; + case FilterRule.GREATER_THAN: + case FilterRule.GREATER_THAN_OR_EQUALS: + case FilterRule.LESS_THAN: + case FilterRule.LESS_THAN_OR_EQUALS: + return Number(value); + case FilterRule.EQUALS: + case FilterRule.NOT_EQUALS: + return isNaN(Number(value)) ? value : Number(value); + default: + return value; + } +}; + +/** + * Divide una cadena de parámetros de filtro en filtros individuales, teniendo en cuenta las reglas IN y NOT_IN. + * Las reglas IN y NOT_IN pueden contener comas en sus valores, por lo que esta función asegura que dichas comas no se usen para dividir la cadena. + * + * @param {string} filterParam - La cadena de parámetros de filtro completa de la solicitud. + * @returns {string[]} Un array de cadenas, cada una representando un filtro individual. + */ +const parseFilters = (filterParam: string): string[] => { + const filters: string[] = []; + let buffer: string = ''; + let isInValue: boolean = false; + + for (const char of filterParam) { + if ((char === ':' && buffer.endsWith('in')) || buffer.endsWith('nin')) { + isInValue = true; + } else if (char === ',' && !isInValue) { + filters.push(buffer); + buffer = ''; + continue; + } else if (char === ',' && isInValue) { + isInValue = false; + } + + buffer += char; + } + + if (buffer) { + filters.push(buffer); + } + + return filters; +}; + +/** + * Decorator that extracts and validates filtering parameters from the request query. + * It parses the 'filter' parameter which can be a comma-separated list of property:rule[:value] entries. + * @param validProperties - Array of valid property names that can be used for filtering. + * @param ctx - The execution context, providing access to the incoming request. + * @returns An array of Filtering objects, each representing a filter criterion. + * @throws BadRequestException if the filter parameter format is invalid or a property/rule is not in the list of valid options. + * + * @example + * // Use within a controller to get filtering parameters. + * @Get() + * findAll(@FilteringParams(['age', 'name']) filters: Filtering[]) { + * filters.forEach(filter => { + * console.log(filter.property); // property name + * console.log(filter.rule); // filter rule + * console.log(filter.values); // array of values + * }); + * // Additional logic to handle data fetching based on filter parameters. + * } + * + * @example + * // Query with a single filter parameter + * GET /items?filter=age:gt:30 + * + * @example + * // Query with multiple filter parameters + * GET /items?filter=age:gt:30,name:like:John,status:in:active,inactive + */ +export const FilteringParams = createParamDecorator( + (validProperties: string[], ctx: ExecutionContext): Filtering[] => { + const request: Request = ctx.switchToHttp().getRequest(); + const filterParam = request.query.filter as string; + if (!filterParam) return []; + + if (!Array.isArray(validProperties)) { + throw new BadRequestException('Invalid filter configuration'); + } + + const filters = parseFilters(filterParam); + + return filters.map((filter) => { + const regex = + /^([a-zA-Z0-9_]+):(eq|neq|gt|gte|lt|lte|like|nlike|in|nin|isnull|isnotnull)(?::((?:[^,:]+(?:,[^,:]+)*)?))?$/i; + const match = filter.match(regex); + if (!match) { + throw new BadRequestException(`Invalid filter format: ${filter}`); + } + + const [, property, rule, valueString] = match; + if (!validProperties.includes(property)) { + throw new BadRequestException(`Invalid filter property: ${property}`); + } + /* istanbul ignore next */ + if (!Object.values(FilterRule).includes(rule as FilterRule)) { + /* istanbul ignore next */ + throw new BadRequestException(`Invalid filter rule: ${rule}`); + } + + const values = valueString + ? rule === FilterRule.IN || rule === FilterRule.NOT_IN + ? valueString.split(',').map((value) => convertFilterValue(rule as FilterRule, value)) + : [convertFilterValue(rule as FilterRule, valueString)] + : []; + return { property, rule: rule as FilterRule, values: values.filter((v) => v !== undefined) }; + }); + }, +); diff --git a/packages/core/src/decorators/index.ts b/packages/core/src/decorators/index.ts index d8d6bfff..d885721d 100644 --- a/packages/core/src/decorators/index.ts +++ b/packages/core/src/decorators/index.ts @@ -1,3 +1,7 @@ export { Public, IS_PUBLIC_KEY } from './public.decorator'; export { Roles, ROLES_KEY } from './roles.decorator'; export { ExcludeFilter, EXCLUDE_FILTER_KEY } from './exclude.filter.decorator'; +export * from './dtos/index'; +export * from './pagination.decorator'; +export * from './sorting.decorator'; +export * from './filtering.decorator'; diff --git a/packages/core/src/decorators/pagination.decorator.ts b/packages/core/src/decorators/pagination.decorator.ts new file mode 100644 index 00000000..380687d7 --- /dev/null +++ b/packages/core/src/decorators/pagination.decorator.ts @@ -0,0 +1,83 @@ +import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { Request } from 'express'; + +/** + * Interface representing pagination parameters. + * @typedef {Object} PaginationParams + * @property {number} page - The current page number. + * @property {number} size - The number of items per page. + */ +export interface PaginationParams { + page: number; + size: number; +} + +/** + * Custom decorator that extracts and validates pagination parameters from the request query. + * It ensures the parameters 'page' and 'size' are within specified limits and are valid integers. + * The 'page' parameter defaults to 1 if not provided or if invalid. + * The 'size' parameter defaults to 10 if not provided, if invalid, or if it exceeds the maximum allowed value (MAX_SIZE). + * + * @param {unknown} data - The data passed to the decorator. Not used in this case. + * @param {ExecutionContext} ctx - The execution context, providing access to the incoming request. + * @returns {PaginationParams} An object containing the 'page' and 'size' after validation. + * + * @throws {BadRequestException} If 'page' or 'size' are provided but are not positive integers, + * or if 'size' is provided and exceeds the maximum allowed value (MAX_SIZE). + * + * @example + * // Use within a controller to get pagination parameters. + * @Get() + * findAll(@Pagination() pagination: PaginationParams) { + * console.log(pagination.page); // Current page number, defaults to 1 if not provided + * console.log(pagination.size); // Number of items per page, defaults to 10 if not provided or invalid + * // Additional logic to handle the data fetching based on pagination parameters + * } + * + * @example + * // Query with valid page and size parameters + * GET /items?page=2&size=20 + * // Query with 'size' exceeding the MAX_SIZE limit + * GET /items?page=1&size=150 // Will throw BadRequestException + * // Query without page and size parameters + * GET /items // 'page' defaults to 1 and 'size' defaults to 10 + * // Query with invalid page + * GET /items?page=invalid&size=10 // 'page' defaults to 1 + * // Query with invalid size + * GET /items?page=2&size=invalid // 'size' defaults to 10 + */ +export const Pagination = createParamDecorator( + (data: unknown, ctx: ExecutionContext): PaginationParams => { + const request: Request = ctx.switchToHttp().getRequest(); + + const DEFAULT_PAGE: number = 1; + const DEFAULT_SIZE: number = 10; + const MAX_SIZE: number = 100; + + let page: number = Number(request.query.page); + let size: number = Number(request.query.size); + + if (request.query.page !== undefined && (!Number.isInteger(page) || page < 1)) { + throw new BadRequestException( + 'The page parameter is invalid. It must be a positive integer.', + ); + } + + if ( + request.query.size !== undefined && + (!Number.isInteger(size) || size < 1 || size > MAX_SIZE) + ) { + throw new BadRequestException( + `The size parameter is invalid. It must be a positive integer and cannot be more than ${MAX_SIZE}.`, + ); + } + + page = page >= 1 ? page : DEFAULT_PAGE; + size = size >= 1 && size <= MAX_SIZE ? size : DEFAULT_SIZE; + + return { + page, + size, + }; + }, +); diff --git a/packages/core/src/decorators/sorting.decorator.ts b/packages/core/src/decorators/sorting.decorator.ts new file mode 100644 index 00000000..0b766d29 --- /dev/null +++ b/packages/core/src/decorators/sorting.decorator.ts @@ -0,0 +1,96 @@ +import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { Request } from 'express'; + +/** + * Type representing the order of sorting. + * It can either be 'asc' for ascending order or 'desc' for descending order. + * @typedef {'asc' | 'desc'} SortOrder + */ +export type SortOrder = 'asc' | 'desc'; + +/** + * Interface representing individual sorting criteria. + * @typedef {Object} SortCriteria + * @property {string} field - The field name to sort by. + * @property {SortOrder} order - The order of sorting, either 'asc' or 'desc'. + */ +export interface SortCriteria { + field: string; + order: SortOrder; +} + +/** + * Interface representing sorting parameters. + * @typedef {Object} SortingParams + * @property {SortCriteria[]} fields - An array of sorting criteria. + */ +export interface SortingParams { + fields: SortCriteria[]; +} + +/** + * Custom decorator that extracts and validates sorting parameters from the request query. + * It parses the 'sort' parameter, which can be a comma-separated list of fields with optional + * sorting direction ('asc' or 'desc') specified using a colon. The default sorting direction is + * defined by DEFAULT_SORT_DIRECTION if not provided. + * + * @param {string[]} data - A list of valid field names that can be used for sorting. + * @param {ExecutionContext} ctx - The execution context, providing access to the incoming request. + * @returns {SortingParams} An object containing the sorting parameters. + * + * @throws {BadRequestException} If the 'sort' parameter format is invalid, if a field is not in the list of validParams, + * or if the configuration for sorting parameters is invalid. + * + * @example + * // Use within a controller to get sorting parameters. + * @Get() + * findAll(@Sorting(['user_id', 'first_name']) sorting: SortingParams) { + * console.log(sorting.fields); // Array of sorting fields and directions + * // Additional logic to handle the data fetching based on sorting parameters + * } + * + * @example + * // Query with sorting parameters + * GET /items?sort=user_id:asc,first_name:desc + * // Query with default sorting direction + * GET /items?sort=user_id + * // Query with an invalid field (not included in validParams) + * GET /items?sort=invalid_field:asc // Will throw BadRequestException + */ +export const Sorting = createParamDecorator( + (data: string[], ctx: ExecutionContext): SortingParams => { + const request: Request = ctx.switchToHttp().getRequest(); + + const DEFAULT_SORT_DIRECTION: SortOrder = 'asc'; + + const sortParam: string = request.query.sort as string; + + if (!sortParam) { + return { fields: [] }; + } + + if (!Array.isArray(data)) { + throw new BadRequestException('Invalid configuration for sorting parameters'); + } + + const sortFields: SortCriteria[] = sortParam.split(',').map((sortField) => { + const sortPattern = /^([a-zA-Z0-9_]+)(?::(asc|desc))?$/; + if (!sortField.match(sortPattern)) { + throw new BadRequestException( + `Invalid sort parameter: "${sortField}". It must be in the format 'field_name[:direction]' where direction is 'asc' or 'desc' and is optional.`, + ); + } + + const [field, order = DEFAULT_SORT_DIRECTION] = sortField.split(':'); + if (!data.includes(field)) { + throw new BadRequestException(`Invalid sort property: ${field}`); + } + + return { field, order: order as SortOrder }; + }); + + return { + fields: sortFields, + }; + }, +); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6eb77609..5ce3118e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ -export * from './commons'; -export * from './decorators'; -export * from './https-certificate'; -export * from './validations'; -export * from './utils'; -export * as Typings from './typings'; +export * as Typings from './typings/index'; +export * from './commons/index'; +export * from './decorators/index'; +export * from './https-certificate/index'; +export * from './validations/index'; +export * from './utils/index'; diff --git a/packages/core/src/validations/app.config.schema.ts b/packages/core/src/validations/app.config.schema.ts index 900df50e..1ed33e59 100644 --- a/packages/core/src/validations/app.config.schema.ts +++ b/packages/core/src/validations/app.config.schema.ts @@ -1,5 +1,5 @@ import Joi from 'joi'; -import { EAppStage, ESkipHealthChecks } from '../typings'; +import { EAppStage, ESkipHealthChecks } from '../typings/index'; export const validateSchema = (validationSchema: object, input: any) => { const result = Joi.object(validationSchema).validate(input); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/dynamoose/tsconfig.json b/packages/dynamoose/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/dynamoose/tsconfig.json +++ b/packages/dynamoose/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/elk/tsconfig.json b/packages/elk/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/elk/tsconfig.json +++ b/packages/elk/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/filters/tsconfig.json b/packages/filters/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/filters/tsconfig.json +++ b/packages/filters/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/health/tsconfig.json b/packages/health/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/health/tsconfig.json +++ b/packages/health/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/http-client/tsconfig.json b/packages/http-client/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/http-client/tsconfig.json +++ b/packages/http-client/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/mailer/tsconfig.json b/packages/mailer/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/mailer/tsconfig.json +++ b/packages/mailer/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/paas/tsconfig.json b/packages/paas/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/paas/tsconfig.json +++ b/packages/paas/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/qrcode/tsconfig.json b/packages/qrcode/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/qrcode/tsconfig.json +++ b/packages/qrcode/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/redis/tsconfig.json b/packages/redis/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/redis/tsconfig.json +++ b/packages/redis/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/response-parser/tsconfig.json b/packages/response-parser/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/response-parser/tsconfig.json +++ b/packages/response-parser/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/test-utils/tsconfig.json +++ b/packages/test-utils/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/tracing/tsconfig.json b/packages/tracing/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/tracing/tsconfig.json +++ b/packages/tracing/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/typeorm/tsconfig.json b/packages/typeorm/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/typeorm/tsconfig.json +++ b/packages/typeorm/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], } diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 6ae507e5..c0505a5e 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@tresdoce-nestjs-toolkit/config", "compilerOptions": { - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], "exclude": [ @@ -15,6 +15,6 @@ "**/*test.ts", "**/*e2e.ts", "**/*e2e-spec.ts", - "coverage" - ] + "coverage", + ], }