From b72d6a9d9dc60d0ef2e442a197bbe1b4e34638b8 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 6 Dec 2023 16:55:42 +0100 Subject: [PATCH] Rest api updates (#2844) * Fix typo * Fix ':' parsing * Add '' for strings * Add 'in', 'is', 'neq', 'like', 'ilike', 'startWith' comparators * Fix test * Move mapFieldMetadataToGraphqlQuery to util * Move filter utils to utils * Split code into factories * Fix order by parser * Reorganize files * Add tests for limit parser * Add tests for last_cursor parser * Add tests for last_filter parser * Move filter utils to factory * Update filter parser tests * Code review returns * Fix tests * Remove LOCAL_SERVER_URL * Simplify and fix filter string parser * Rename parser to input * Add new lines for more readability * Use unary plus * Use nextjs errors * Use destructuring * Remove useless else * Use FieldMetadata types * Rename enums * Move methods to utils * Lint project * Use singular name if id provided * Handle typing * Handle typing * Minor update * Simplify order by parser * Lint * handle missing conjunction * filter parser update --- server/.env.example | 1 - server/.eslintrc.js | 4 +- .../api-rest-query-builder.factory.spec.ts | 44 ++ .../api-rest-query-builder.factory.ts | 143 ++++ .../api-rest-query-builder.module.ts | 13 + .../factories/create-query.factory.ts | 30 + .../factories/create-variables.factory.ts | 12 + .../factories/delete-query.factory.ts | 16 + .../factories/delete-variables.factory.ts | 12 + .../factories/factories.ts | 29 + .../factories/find-many-query.factory.ts | 47 ++ .../factories/find-one-query.factory.ts | 32 + .../factories/get-variables.factory.ts | 36 + .../__tests__/filter-input.factory.spec.ts | 120 ++++ .../last-cursor-input.factory.spec.ts | 33 + .../__tests__/limit-input.factory.spec.ts | 49 ++ .../__tests__/order-by-input.factory.spec.ts | 119 ++++ .../input-factories/filter-input.factory.ts | 25 + .../add-default-conjunction.utils.spec.ts | 15 + .../check-filter-query.utils.spec.ts | 33 + .../format-field-values.utils.spec.ts | 56 ++ .../__tests__/parse-base-filter.utils.spec.ts | 49 ++ .../parse-filter-content.utils.spec.ts | 54 ++ .../__tests__/parse-filter.utils.spec.ts | 92 +++ .../add-default-conjunction.utils.ts | 7 + .../filter-utils/check-filter-query.utils.ts | 22 + .../filter-utils/format-field-values.utils.ts | 41 ++ .../filter-utils/parse-base-filter.utils.ts | 67 ++ .../parse-filter-content.utils.ts | 45 ++ .../filter-utils/parse-filter.utils.ts | 64 ++ .../last-cursor-input.factory.ts | 16 + .../input-factories/limit-input.factory.ts | 21 + .../input-factories/order-by-input.factory.ts | 75 ++ .../factories/update-query.factory.ts | 30 + .../factories/update-variables.factory.ts | 13 + .../__tests__/compute-depth.utils.spec.ts | 26 + .../utils/__tests__/fields.utils.spec.ts | 30 + ...ld-metadata-to-graphql-query.utils.spec.ts | 35 + .../utils/__tests__/parse-path.utils.spec.ts | 21 + .../utils/__tests__/utils.ts | 33 + .../utils/compute-depth.utils.ts | 25 + .../utils/fields.utils.ts | 35 + ...p-field-metadata-to-graphql-query.utils.ts | 107 +++ .../utils/parse-path.utils.ts | 21 + .../src/core/api-rest/api-rest.controller.ts | 37 +- server/src/core/api-rest/api-rest.module.ts | 5 +- .../core/api-rest/api-rest.service.spec.ts | 128 +--- server/src/core/api-rest/api-rest.service.ts | 671 +----------------- .../types/api-rest-field-value.type.ts | 6 + .../types/api-rest-query-variables.type.ts | 8 + .../api-rest/types/api-rest-query.type.ts | 4 + .../api-rest/types/api-rest-response.type.ts | 1 + server/src/core/auth/auth.module.ts | 2 +- .../src/core/auth/services/token.service.ts | 16 + .../environment/environment.service.ts | 4 - .../environment/environment.validation.ts | 4 - .../interfaces/record.interface.ts | 2 +- 57 files changed, 1881 insertions(+), 805 deletions(-) create mode 100644 server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.module.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/create-query.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/create-variables.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/delete-query.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/factories.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/get-variables.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/last-cursor-input.factory.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/limit-input.factory.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/add-default-conjunction.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/check-filter-query.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/format-field-values.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-base-filter.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter-content.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/check-filter-query.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter-content.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/update-query.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/factories/update-variables.factory.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/__tests__/compute-depth.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/__tests__/parse-path.utils.spec.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/compute-depth.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/fields.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts create mode 100644 server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts create mode 100644 server/src/core/api-rest/types/api-rest-field-value.type.ts create mode 100644 server/src/core/api-rest/types/api-rest-query-variables.type.ts create mode 100644 server/src/core/api-rest/types/api-rest-query.type.ts create mode 100644 server/src/core/api-rest/types/api-rest-response.type.ts diff --git a/server/.env.example b/server/.env.example index a239259a8..e28d5c9b4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -4,7 +4,6 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default # PG_DATABASE_URL=postgres://twenty:twenty@postgres:5432/default?connection_limit=1 FRONT_BASE_URL=http://localhost:3001 -LOCAL_SERVER_URL=http://localhost:3000 ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh diff --git a/server/.eslintrc.js b/server/.eslintrc.js index bcf38f34a..46d638c58 100644 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -79,8 +79,8 @@ module.exports = { "@typescript-eslint/consistent-type-imports": ["error", { "prefer": "no-type-imports" }], "@stylistic/linebreak-style": ["error", "unix"], "@stylistic/lines-between-class-members": ["error", { "enforce": [ - { blankLine: "always", prev: "method", next: "method" } - ]}], + { blankLine: "always", prev: "method", next: "method" } + ]}], "@stylistic/padding-line-between-statements": [ "error", { blankLine: "always", prev: "*", next: "return" }, diff --git a/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.spec.ts b/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.spec.ts new file mode 100644 index 000000000..47393b37f --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.spec.ts @@ -0,0 +1,44 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ApiRestQueryBuilderFactory } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory'; +import { DeleteQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/delete-query.factory'; +import { CreateQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/create-query.factory'; +import { UpdateQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/update-query.factory'; +import { FindOneQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory'; +import { FindManyQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory'; +import { DeleteVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory'; +import { CreateVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/create-variables.factory'; +import { UpdateVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/update-variables.factory'; +import { GetVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/get-variables.factory'; +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; +import { TokenService } from 'src/core/auth/services/token.service'; + +describe('ApiRestQueryBuilderFactory', () => { + let service: ApiRestQueryBuilderFactory; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiRestQueryBuilderFactory, + { provide: DeleteQueryFactory, useValue: {} }, + { provide: CreateQueryFactory, useValue: {} }, + { provide: UpdateQueryFactory, useValue: {} }, + { provide: FindOneQueryFactory, useValue: {} }, + { provide: FindManyQueryFactory, useValue: {} }, + { provide: DeleteVariablesFactory, useValue: {} }, + { provide: CreateVariablesFactory, useValue: {} }, + { provide: UpdateVariablesFactory, useValue: {} }, + { provide: GetVariablesFactory, useValue: {} }, + { provide: ObjectMetadataService, useValue: {} }, + { provide: TokenService, useValue: {} }, + ], + }).compile(); + + service = module.get( + ApiRestQueryBuilderFactory, + ); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts b/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts new file mode 100644 index 000000000..3e959fc1c --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory.ts @@ -0,0 +1,143 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +import { DeleteQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/delete-query.factory'; +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { CreateQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/create-query.factory'; +import { UpdateQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/update-query.factory'; +import { FindOneQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory'; +import { FindManyQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory'; +import { DeleteVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory'; +import { CreateVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/create-variables.factory'; +import { UpdateVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/update-variables.factory'; +import { GetVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/get-variables.factory'; +import { parsePath } from 'src/core/api-rest/api-rest-query-builder/utils/parse-path.utils'; +import { computeDepth } from 'src/core/api-rest/api-rest-query-builder/utils/compute-depth.utils'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { ApiRestQuery } from 'src/core/api-rest/types/api-rest-query.type'; + +@Injectable() +export class ApiRestQueryBuilderFactory { + constructor( + private readonly deleteQueryFactory: DeleteQueryFactory, + private readonly createQueryFactory: CreateQueryFactory, + private readonly updateQueryFactory: UpdateQueryFactory, + private readonly findOneQueryFactory: FindOneQueryFactory, + private readonly findManyQueryFactory: FindManyQueryFactory, + private readonly deleteVariablesFactory: DeleteVariablesFactory, + private readonly createVariablesFactory: CreateVariablesFactory, + private readonly updateVariablesFactory: UpdateVariablesFactory, + private readonly getVariablesFactory: GetVariablesFactory, + private readonly objectMetadataService: ObjectMetadataService, + private readonly tokenService: TokenService, + ) {} + + async getObjectMetadata(request: Request): Promise<{ + objectMetadataItems: ObjectMetadataEntity[]; + objectMetadataItem: ObjectMetadataEntity; + }> { + const workspaceId = await this.tokenService.verifyApiKeyToken(request); + + const objectMetadataItems = + await this.objectMetadataService.findManyWithinWorkspace(workspaceId); + + const { id, object: parsedObject } = parsePath(request); + + let objectNameKey = 'namePlural'; + let wrongObjectNameKey = 'nameSingular'; + + if (id) { + objectNameKey = 'nameSingular'; + wrongObjectNameKey = 'namePlural'; + } + + const [objectMetadata] = objectMetadataItems.filter( + (object) => object[objectNameKey] === parsedObject, + ); + + if (!objectMetadata) { + const [wrongObjectMetadata] = objectMetadataItems.filter( + (object) => object[wrongObjectNameKey] === parsedObject, + ); + + let hint = 'eg: companies'; + + if (wrongObjectMetadata) { + hint = `Did you mean '${wrongObjectMetadata[objectNameKey]}'?`; + } + + throw new BadRequestException( + `object '${parsedObject}' not found. ${hint}`, + ); + } + + return { + objectMetadataItems, + objectMetadataItem: objectMetadata, + }; + } + + async delete(request: Request): Promise { + const objectMetadata = await this.getObjectMetadata(request); + + const { id } = parsePath(request); + + if (!id) { + throw new BadRequestException( + `delete ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, + ); + } + + return { + query: this.deleteQueryFactory.create(objectMetadata.objectMetadataItem), + variables: this.deleteVariablesFactory.create(id), + }; + } + + async create(request): Promise { + const objectMetadata = await this.getObjectMetadata(request); + + const depth = computeDepth(request); + + return { + query: this.createQueryFactory.create(objectMetadata, depth), + variables: this.createVariablesFactory.create(request), + }; + } + + async update(request): Promise { + const objectMetadata = await this.getObjectMetadata(request); + + const depth = computeDepth(request); + + const { id } = parsePath(request); + + if (!id) { + throw new BadRequestException( + `update ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, + ); + } + + return { + query: this.updateQueryFactory.create(objectMetadata, depth), + variables: this.updateVariablesFactory.create(id, request), + }; + } + + async get(request): Promise { + const objectMetadata = await this.getObjectMetadata(request); + + const depth = computeDepth(request); + + const { id } = parsePath(request); + + return { + query: id + ? this.findOneQueryFactory.create(objectMetadata, depth) + : this.findManyQueryFactory.create(objectMetadata, depth), + variables: this.getVariablesFactory.create(id, request, objectMetadata), + }; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.module.ts b/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.module.ts new file mode 100644 index 000000000..1c73c4838 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/api-rest-query-builder.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { ApiRestQueryBuilderFactory } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory'; +import { apiRestQueryBuilderFactories } from 'src/core/api-rest/api-rest-query-builder/factories/factories'; +import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module'; +import { AuthModule } from 'src/core/auth/auth.module'; + +@Module({ + imports: [ObjectMetadataModule, AuthModule], + providers: [...apiRestQueryBuilderFactories, ApiRestQueryBuilderFactory], + exports: [ApiRestQueryBuilderFactory], +}) +export class ApiRestQueryBuilderModule {} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/create-query.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/create-query.factory.ts new file mode 100644 index 000000000..f84da4c91 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/create-query.factory.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import { capitalize } from 'src/utils/capitalize'; +import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; + +@Injectable() +export class CreateQueryFactory { + create(objectMetadata, depth?: number): string { + return ` + mutation Create${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}($data: CompanyCreateInput!) { + create${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}(data: $data) { + id + ${objectMetadata.objectMetadataItem.fields + .map((field) => + mapFieldMetadataToGraphqlQuery( + objectMetadata.objectMetadataItems, + field, + depth, + ), + ) + .join('\n')} + } + } + `; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/create-variables.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/create-variables.factory.ts new file mode 100644 index 000000000..981e1df2a --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/create-variables.factory.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { ApiRestQueryVariables } from 'src/core/api-rest/types/api-rest-query-variables.type'; + +@Injectable() +export class CreateVariablesFactory { + create(request: Request): ApiRestQueryVariables { + return { + data: request.body, + }; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/delete-query.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/delete-query.factory.ts new file mode 100644 index 000000000..829552a64 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/delete-query.factory.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +import { capitalize } from 'src/utils/capitalize'; + +@Injectable() +export class DeleteQueryFactory { + create(objectMetadataItem): string { + return ` + mutation Delete${capitalize(objectMetadataItem.nameSingular)}($id: ID!) { + delete${capitalize(objectMetadataItem.nameSingular)}(id: $id) { + id + } + } + `; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory.ts new file mode 100644 index 000000000..eef62953f --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { ApiRestQueryVariables } from 'src/core/api-rest/types/api-rest-query-variables.type'; + +@Injectable() +export class DeleteVariablesFactory { + create(id: string): ApiRestQueryVariables { + return { + id: id, + }; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/factories.ts b/server/src/core/api-rest/api-rest-query-builder/factories/factories.ts new file mode 100644 index 000000000..3f948058d --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/factories.ts @@ -0,0 +1,29 @@ +import { DeleteQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/delete-query.factory'; +import { CreateQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/create-query.factory'; +import { UpdateQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/update-query.factory'; +import { FindOneQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory'; +import { FindManyQueryFactory } from 'src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory'; +import { DeleteVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/delete-variables.factory'; +import { CreateVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/create-variables.factory'; +import { UpdateVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/update-variables.factory'; +import { GetVariablesFactory } from 'src/core/api-rest/api-rest-query-builder/factories/get-variables.factory'; +import { LastCursorInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory'; +import { LimitInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory'; +import { OrderByInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory'; +import { FilterInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory'; + +export const apiRestQueryBuilderFactories = [ + DeleteQueryFactory, + CreateQueryFactory, + UpdateQueryFactory, + FindOneQueryFactory, + FindManyQueryFactory, + DeleteVariablesFactory, + CreateVariablesFactory, + UpdateVariablesFactory, + GetVariablesFactory, + LastCursorInputFactory, + LimitInputFactory, + OrderByInputFactory, + FilterInputFactory, +]; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory.ts new file mode 100644 index 000000000..56071504b --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/find-many-query.factory.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; + +import { capitalize } from 'src/utils/capitalize'; +import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; + +@Injectable() +export class FindManyQueryFactory { + create(objectMetadata, depth?: number): string { + return ` + query FindMany${capitalize(objectMetadata.objectMetadataItem.namePlural)}( + $filter: ${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}FilterInput, + $orderBy: ${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}OrderByInput, + $lastCursor: String, + $limit: Float = 60 + ) { + ${objectMetadata.objectMetadataItem.namePlural}( + filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor + ) { + edges { + node { + id + ${objectMetadata.objectMetadataItem.fields + .map((field) => + mapFieldMetadataToGraphqlQuery( + objectMetadata.objectMetadataItems, + field, + depth, + ), + ) + .join('\n')} + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + } + } + `; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory.ts new file mode 100644 index 000000000..856137f94 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/find-one-query.factory.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; + +import { capitalize } from 'src/utils/capitalize'; +import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; + +@Injectable() +export class FindOneQueryFactory { + create(objectMetadata, depth?: number): string { + return ` + query FindOne${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}( + $filter: ${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}FilterInput!, + ) { + ${objectMetadata.objectMetadataItem.nameSingular}(filter: $filter) { + id + ${objectMetadata.objectMetadataItem.fields + .map((field) => + mapFieldMetadataToGraphqlQuery( + objectMetadata.objectMetadataItems, + field, + depth, + ), + ) + .join('\n')} + } + } + `; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/get-variables.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/get-variables.factory.ts new file mode 100644 index 000000000..12dd51741 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/get-variables.factory.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +import { LastCursorInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory'; +import { LimitInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory'; +import { OrderByInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory'; +import { FilterInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory'; +import { ApiRestQueryVariables } from 'src/core/api-rest/types/api-rest-query-variables.type'; + +@Injectable() +export class GetVariablesFactory { + constructor( + private readonly lastCursorInputFactory: LastCursorInputFactory, + private readonly limitInputFactory: LimitInputFactory, + private readonly orderByInputFactory: OrderByInputFactory, + private readonly filterInputFactory: FilterInputFactory, + ) {} + + create( + id: string | undefined, + request: Request, + objectMetadata, + ): ApiRestQueryVariables { + if (id) { + return { filter: { id: { eq: id } } }; + } + + return { + filter: this.filterInputFactory.create(request, objectMetadata), + orderBy: this.orderByInputFactory.create(request, objectMetadata), + limit: this.limitInputFactory.create(request), + lastCursor: this.lastCursorInputFactory.create(request), + }; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts new file mode 100644 index 000000000..148025153 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts @@ -0,0 +1,120 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; +import { FilterInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory'; + +describe('FilterInputFactory', () => { + const objectMetadata = { objectMetadataItem: objectMetadataItem }; + + let service: FilterInputFactory; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FilterInputFactory], + }).compile(); + + service = module.get(FilterInputFactory); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should return default if filter missing', () => { + const request: any = { query: {} }; + + expect(service.create(request, objectMetadata)).toEqual({}); + }); + + it('should throw when wrong field provided', () => { + const request: any = { + query: { + filter: 'wrongField[eq]:1', + }, + }; + + expect(() => service.create(request, objectMetadata)).toThrow( + "field 'wrongField' does not exist in 'testingObject' object", + ); + }); + + it('should throw when wrong comparator provided', () => { + const request: any = { + query: { + filter: 'fieldNumber[wrongComparator]:1', + }, + }; + + expect(() => service.create(request, objectMetadata)).toThrow( + "'filter' invalid for 'fieldNumber[wrongComparator]:1', comparator wrongComparator not in eq,neq,in,is,gt,gte,lt,lte,startsWith,like,ilike", + ); + }); + + it('should throw when wrong filter provided', () => { + const request: any = { + query: { + filter: 'fieldNumber[wrongComparator:1', + }, + }; + + expect(() => service.create(request, objectMetadata)).toThrow( + "'filter' invalid for 'fieldNumber[wrongComparator:1'. eg: price[gte]:10", + ); + }); + + it('should throw when parenthesis are not closed', () => { + const request: any = { + query: { + filter: 'and(fieldNumber[eq]:1,not(fieldNumber[neq]:1)', + }, + }; + + expect(() => service.create(request, objectMetadata)).toThrow( + "'filter' invalid. 1 close bracket is missing in the query", + ); + }); + + it('should create filter parser properly', () => { + const request: any = { + query: { + filter: 'fieldNumber[eq]:1,fieldString[eq]:"Test"', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + and: [{ fieldNumber: { eq: 1 } }, { fieldString: { eq: 'Test' } }], + }); + }); + + it('should create complex filter parser properly', () => { + const request: any = { + query: { + filter: + 'and(fieldNumber[eq]:1,fieldString[gte]:"Test",not(fieldString[ilike]:"%val%"),or(not(and(fieldString[startsWith]:"test",fieldNumber[in]:[2,4,5])),fieldCurrency.amountMicros[gt]:1))', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + and: [ + { fieldNumber: { eq: 1 } }, + { fieldString: { gte: 'Test' } }, + { not: { fieldString: { ilike: '%val%' } } }, + { + or: [ + { + not: { + and: [ + { fieldString: { startsWith: 'test' } }, + { fieldNumber: { in: [2, 4, 5] } }, + ], + }, + }, + { fieldCurrency: { amountMicros: { gt: '1' } } }, + ], + }, + ], + }); + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/last-cursor-input.factory.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/last-cursor-input.factory.spec.ts new file mode 100644 index 000000000..6d0c7281e --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/last-cursor-input.factory.spec.ts @@ -0,0 +1,33 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { LastCursorInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory'; + +describe('LastCursorInputFactory', () => { + let service: LastCursorInputFactory; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [LastCursorInputFactory], + }).compile(); + + service = module.get(LastCursorInputFactory); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should return default if last_cursor missing', () => { + const request: any = { query: {} }; + + expect(service.create(request)).toEqual(undefined); + }); + + it('should return last_cursor', () => { + const request: any = { query: { last_cursor: 'uuid' } }; + + expect(service.create(request)).toEqual('uuid'); + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/limit-input.factory.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/limit-input.factory.spec.ts new file mode 100644 index 000000000..5851a878f --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/limit-input.factory.spec.ts @@ -0,0 +1,49 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { LimitInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory'; + +describe('LimitInputFactory', () => { + let service: LimitInputFactory; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [LimitInputFactory], + }).compile(); + + service = module.get(LimitInputFactory); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should return default if limit missing', () => { + const request: any = { query: {} }; + + expect(service.create(request)).toEqual(60); + }); + + it('should return limit', () => { + const request: any = { query: { limit: '10' } }; + + expect(service.create(request)).toEqual(10); + }); + + it('should throw if not integer', () => { + const request: any = { query: { limit: 'aaa' } }; + + expect(() => service.create(request)).toThrow( + "limit 'aaa' is invalid. Should be an integer", + ); + }); + + it('should throw if limit negative', () => { + const request: any = { query: { limit: -1 } }; + + expect(() => service.create(request)).toThrow( + "limit '-1' is invalid. Should be an integer", + ); + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts new file mode 100644 index 000000000..e97237ca4 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { OrderByDirection } from 'src/workspace/workspace-query-builder/interfaces/record.interface'; + +import { OrderByInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory'; +import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; + +describe('OrderByInputFactory', () => { + const objectMetadata = { objectMetadataItem: objectMetadataItem }; + + let service: OrderByInputFactory; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OrderByInputFactory], + }).compile(); + + service = module.get(OrderByInputFactory); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should return default if order by missing', () => { + const request: any = { query: {} }; + + expect(service.create(request, objectMetadata)).toEqual({}); + }); + + it('should create order by parser properly', () => { + const request: any = { + query: { + order_by: 'fieldNumber[AscNullsFirst],fieldString[DescNullsLast]', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + fieldNumber: OrderByDirection.AscNullsFirst, + fieldString: OrderByDirection.DescNullsLast, + }); + }); + + it('should choose default direction if missing', () => { + const request: any = { + query: { + order_by: 'fieldNumber', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + fieldNumber: OrderByDirection.AscNullsFirst, + }); + }); + + it('should handler complex fields', () => { + const request: any = { + query: { + order_by: 'fieldCurrency.amountMicros', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + fieldCurrency: { amountMicros: OrderByDirection.AscNullsFirst }, + }); + }); + + it('should handler complex fields with direction', () => { + const request: any = { + query: { + order_by: 'fieldCurrency.amountMicros[DescNullsLast]', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast }, + }); + }); + + it('should handler multiple complex fields with direction', () => { + const request: any = { + query: { + order_by: + 'fieldCurrency.amountMicros[DescNullsLast],fieldLink.label[AscNullsLast]', + }, + }; + + expect(service.create(request, objectMetadata)).toEqual({ + fieldCurrency: { amountMicros: OrderByDirection.DescNullsLast }, + fieldLink: { label: OrderByDirection.AscNullsLast }, + }); + }); + + it('should throw if direction invalid', () => { + const request: any = { + query: { + order_by: 'fieldString[invalid]', + }, + }; + + expect(() => service.create(request, objectMetadata)).toThrow( + "'order_by' direction 'invalid' invalid. Allowed values are 'AscNullsFirst', 'AscNullsLast', 'DescNullsFirst', 'DescNullsLast'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3", + ); + }); + + it('should throw if field invalid', () => { + const request: any = { + query: { + order_by: 'wrongField[DescNullsLast]', + }, + }; + + expect(() => service.create(request, objectMetadata)).toThrow( + "field 'wrongField' does not exist in 'testingObject' object", + ); + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory.ts new file mode 100644 index 000000000..3a6499ffd --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +import { addDefaultConjunctionIfMissing } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils'; +import { checkFilterQuery } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/check-filter-query.utils'; +import { parseFilter } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils'; +import { FieldValue } from 'src/core/api-rest/types/api-rest-field-value.type'; + +@Injectable() +export class FilterInputFactory { + create(request: Request, objectMetadata): Record { + let filterQuery = request.query.filter; + + if (typeof filterQuery !== 'string') { + return {}; + } + + checkFilterQuery(filterQuery); + + filterQuery = addDefaultConjunctionIfMissing(filterQuery); + + return parseFilter(filterQuery, objectMetadata.objectMetadataItem); + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/add-default-conjunction.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/add-default-conjunction.utils.spec.ts new file mode 100644 index 000000000..0b3f0caa8 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/add-default-conjunction.utils.spec.ts @@ -0,0 +1,15 @@ +import { addDefaultConjunctionIfMissing } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils'; + +describe('addDefaultConjunctionIfMissing', () => { + it('should add default conjunction if missing', () => { + expect(addDefaultConjunctionIfMissing('field[eq]:1')).toEqual( + 'and(field[eq]:1)', + ); + }); + + it('should not add default conjunction if not missing', () => { + expect(addDefaultConjunctionIfMissing('and(field[eq]:1)')).toEqual( + 'and(field[eq]:1)', + ); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/check-filter-query.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/check-filter-query.utils.spec.ts new file mode 100644 index 000000000..1471f66d3 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/check-filter-query.utils.spec.ts @@ -0,0 +1,33 @@ +import { checkFilterQuery } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/check-filter-query.utils'; + +describe('checkFilterQuery', () => { + it('should check filter query', () => { + expect(() => checkFilterQuery('(')).toThrow( + "'filter' invalid. 1 close bracket is missing in the query", + ); + + expect(() => checkFilterQuery(')')).toThrow( + "'filter' invalid. 1 open bracket is missing in the query", + ); + + expect(() => checkFilterQuery('(()')).toThrow( + "'filter' invalid. 1 close bracket is missing in the query", + ); + + expect(() => checkFilterQuery('()))')).toThrow( + "'filter' invalid. 2 open brackets are missing in the query", + ); + + expect(() => + checkFilterQuery( + 'and(or(fieldNumber[eq]:1,fieldNumber[eq]:2)),fieldNumber[eq]:3)', + ), + ).toThrow("'filter' invalid. 1 open bracket is missing in the query"); + + expect(() => + checkFilterQuery( + 'and(or(fieldNumber[eq]:1,fieldNumber[eq]:2),fieldNumber[eq]:3)', + ), + ).not.toThrow(); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/format-field-values.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/format-field-values.utils.spec.ts new file mode 100644 index 000000000..8bbf87d1e --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/format-field-values.utils.spec.ts @@ -0,0 +1,56 @@ +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { formatFieldValue } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils'; + +describe('formatFieldValue', () => { + it('should format fieldNumber value', () => { + expect(formatFieldValue('1', FieldMetadataType.NUMBER)).toEqual(1); + + expect(formatFieldValue('a', FieldMetadataType.NUMBER)).toEqual(NaN); + + expect(formatFieldValue('true', FieldMetadataType.BOOLEAN)).toEqual(true); + + expect(formatFieldValue('True', FieldMetadataType.BOOLEAN)).toEqual(true); + + expect(formatFieldValue('false', FieldMetadataType.BOOLEAN)).toEqual(false); + + expect(formatFieldValue('value', FieldMetadataType.TEXT)).toEqual('value'); + + expect(formatFieldValue('"value"', FieldMetadataType.TEXT)).toEqual( + 'value', + ); + + expect(formatFieldValue("'value'", FieldMetadataType.TEXT)).toEqual( + 'value', + ); + + expect(formatFieldValue('value', FieldMetadataType.DATE_TIME)).toEqual( + 'value', + ); + + expect(formatFieldValue('"value"', FieldMetadataType.DATE_TIME)).toEqual( + 'value', + ); + + expect(formatFieldValue("'value'", FieldMetadataType.DATE_TIME)).toEqual( + 'value', + ); + + expect( + formatFieldValue( + '["2023-12-01T14:23:23.914Z","2024-12-01T14:23:23.914Z"]', + undefined, + 'in', + ), + ).toEqual(['2023-12-01T14:23:23.914Z', '2024-12-01T14:23:23.914Z']); + + expect(formatFieldValue('[1,2]', FieldMetadataType.NUMBER, 'in')).toEqual([ + 1, 2, + ]); + + expect(() => + formatFieldValue('2024-12-01T14:23:23.914Z', undefined, 'in'), + ).toThrow( + "'filter' invalid for 'in' operator. Received '2024-12-01T14:23:23.914Z' but array value expected eg: 'field[in]:[value_1,value_2]'", + ); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-base-filter.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-base-filter.utils.spec.ts new file mode 100644 index 000000000..aed61bb6f --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-base-filter.utils.spec.ts @@ -0,0 +1,49 @@ +import { parseBaseFilter } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils'; + +describe('parseBaseFilter', () => { + it('should parse simple filter string test 1', () => { + expect(parseBaseFilter('price[lte]:100')).toEqual({ + fields: ['price'], + comparator: 'lte', + value: '100', + }); + }); + + it('should parse simple filter string test 2', () => { + expect(parseBaseFilter('date[gt]:2023-12-01T14:23:23.914Z')).toEqual({ + fields: ['date'], + comparator: 'gt', + value: '2023-12-01T14:23:23.914Z', + }); + }); + + it('should parse simple filter string test 3', () => { + expect(parseBaseFilter('fieldNumber[gt]:valStart]:[valEnd')).toEqual({ + fields: ['fieldNumber'], + comparator: 'gt', + value: 'valStart]:[valEnd', + }); + }); + + it('should parse simple filter string test 4', () => { + expect( + parseBaseFilter('person.createdAt[gt]:"2023-12-01T14:23:23.914Z"'), + ).toEqual({ + fields: ['person', 'createdAt'], + comparator: 'gt', + value: '"2023-12-01T14:23:23.914Z"', + }); + }); + + it('should parse simple filter string test 5', () => { + expect( + parseBaseFilter( + 'person.createdAt[in]:["2023-12-01T14:23:23.914Z","2024-12-01T14:23:23.914Z"]', + ), + ).toEqual({ + fields: ['person', 'createdAt'], + comparator: 'in', + value: '["2023-12-01T14:23:23.914Z","2024-12-01T14:23:23.914Z"]', + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter-content.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter-content.utils.spec.ts new file mode 100644 index 000000000..1d09464ee --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter-content.utils.spec.ts @@ -0,0 +1,54 @@ +import { parseFilterContent } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter-content.utils'; + +describe('parseFilterContent', () => { + it('should parse query filter test 1', () => { + expect(parseFilterContent('and(fieldNumber[eq]:1)')).toEqual([ + 'fieldNumber[eq]:1', + ]); + }); + + it('should parse query filter test 2', () => { + expect( + parseFilterContent('and(fieldNumber[eq]:1,fieldNumber[eq]:2)'), + ).toEqual(['fieldNumber[eq]:1', 'fieldNumber[eq]:2']); + }); + + it('should parse query filter test 3', () => { + expect( + parseFilterContent( + 'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3))', + ), + ).toEqual(['fieldNumber[eq]:1', 'or(fieldNumber[eq]:2,fieldNumber[eq]:3)']); + }); + + it('should parse query filter test 4', () => { + expect( + parseFilterContent( + 'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,not(fieldNumber[eq]:3)),fieldNumber[eq]:4,not(fieldNumber[eq]:5))', + ), + ).toEqual([ + 'fieldNumber[eq]:1', + 'or(fieldNumber[eq]:2,not(fieldNumber[eq]:3))', + 'fieldNumber[eq]:4', + 'not(fieldNumber[eq]:5)', + ]); + }); + + it('should parse query filter test 5', () => { + expect( + parseFilterContent('and(fieldNumber[in]:[1,2],fieldNumber[eq]:4)'), + ).toEqual(['fieldNumber[in]:[1,2]', 'fieldNumber[eq]:4']); + }); + + it('should parse query filter with comma in value ', () => { + expect(parseFilterContent('and(fieldString[eq]:"val,ue")')).toEqual([ + 'fieldString[eq]:"val,ue"', + ]); + }); + + it('should parse query filter with comma in value ', () => { + expect(parseFilterContent("and(fieldString[eq]:'val,ue')")).toEqual([ + "fieldString[eq]:'val,ue'", + ]); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts new file mode 100644 index 000000000..a0d8d2737 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts @@ -0,0 +1,92 @@ +import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; +import { parseFilter } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils'; + +describe('parseFilter', () => { + it('should parse string filter test 1', () => { + expect( + parseFilter( + 'and(fieldNumber[eq]:1,fieldNumber[eq]:2)', + objectMetadataItem, + ), + ).toEqual({ + and: [{ fieldNumber: { eq: 1 } }, { fieldNumber: { eq: 2 } }], + }); + }); + + it('should parse string filter test 2', () => { + expect( + parseFilter( + 'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3))', + objectMetadataItem, + ), + ).toEqual({ + and: [ + { fieldNumber: { eq: 1 } }, + { or: [{ fieldNumber: { eq: 2 } }, { fieldNumber: { eq: 3 } }] }, + ], + }); + }); + + it('should parse string filter test 3', () => { + expect( + parseFilter( + 'and(fieldNumber[eq]:1,or(fieldNumber[eq]:2,fieldNumber[eq]:3,and(fieldNumber[eq]:6,fieldNumber[eq]:7)),or(fieldNumber[eq]:4,fieldNumber[eq]:5))', + objectMetadataItem, + ), + ).toEqual({ + and: [ + { fieldNumber: { eq: 1 } }, + { + or: [ + { fieldNumber: { eq: 2 } }, + { fieldNumber: { eq: 3 } }, + { and: [{ fieldNumber: { eq: 6 } }, { fieldNumber: { eq: 7 } }] }, + ], + }, + { or: [{ fieldNumber: { eq: 4 } }, { fieldNumber: { eq: 5 } }] }, + ], + }); + }); + + it('should parse string filter test 4', () => { + expect( + parseFilter( + 'and(fieldString[gt]:"val,ue",or(fieldNumber[is]:NOT_NULL,not(fieldString[startsWith]:"val"),and(fieldNumber[eq]:6,fieldString[ilike]:"%val%")),or(fieldNumber[eq]:4,fieldString[is]:NULL))', + objectMetadataItem, + ), + ).toEqual({ + and: [ + { fieldString: { gt: 'val,ue' } }, + { + or: [ + { fieldNumber: { is: 'NOT_NULL' } }, + { not: { fieldString: { startsWith: 'val' } } }, + { + and: [ + { fieldNumber: { eq: 6 } }, + { fieldString: { ilike: '%val%' } }, + ], + }, + ], + }, + { or: [{ fieldNumber: { eq: 4 } }, { fieldString: { is: 'NULL' } }] }, + ], + }); + }); + + it('should handler not', () => { + expect( + parseFilter( + 'and(fieldNumber[eq]:1,not(fieldNumber[eq]:2))', + objectMetadataItem, + ), + ).toEqual({ + and: [ + { fieldNumber: { eq: 1 } }, + { + not: { fieldNumber: { eq: 2 } }, + }, + ], + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts new file mode 100644 index 000000000..af2591719 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts @@ -0,0 +1,7 @@ +export const addDefaultConjunctionIfMissing = (filterQuery: string): string => { + if (!(filterQuery.includes('(') && filterQuery.includes(')'))) { + return `and(${filterQuery})`; + } + + return filterQuery; +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/check-filter-query.utils.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/check-filter-query.utils.ts new file mode 100644 index 000000000..5789467df --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/check-filter-query.utils.ts @@ -0,0 +1,22 @@ +import { BadRequestException } from '@nestjs/common'; + +export const checkFilterQuery = (filterQuery: string): void => { + const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length; + const countClosedBrackets = (filterQuery.match(/\)/g) || []).length; + const diff = countOpenedBrackets - countClosedBrackets; + + if (diff !== 0) { + const hint = + diff > 0 + ? `${diff} close bracket${diff > 1 ? 's are' : ' is'}` + : `${Math.abs(diff)} open bracket${ + Math.abs(diff) > 1 ? 's are' : ' is' + }`; + + throw new BadRequestException( + `'filter' invalid. ${hint} missing in the query`, + ); + } + + return; +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils.ts new file mode 100644 index 000000000..2c7a4bdd7 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils.ts @@ -0,0 +1,41 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { FieldValue } from 'src/core/api-rest/types/api-rest-field-value.type'; + +export const formatFieldValue = ( + value: string, + fieldType?: FieldMetadataType, + comparator?: string, +): FieldValue => { + if (comparator === 'in') { + if (value[0] !== '[' || value[value.length - 1] !== ']') { + throw new BadRequestException( + `'filter' invalid for 'in' operator. Received '${value}' but array value expected eg: 'field[in]:[value_1,value_2]'`, + ); + } + const stringValues = value.substring(1, value.length - 1); + + return stringValues + .split(',') + .map((value) => formatFieldValue(value, fieldType)); + } + if (comparator === 'is') { + return value; + } + if (fieldType === FieldMetadataType.NUMBER) { + return parseInt(value); + } + if (fieldType === FieldMetadataType.BOOLEAN) { + return value.toLowerCase() === 'true'; + } + if ( + (value[0] === '"' || value[0] === "'") && + (value.charAt(value.length - 1) === '"' || + value.charAt(value.length - 1) === "'") + ) { + return value.substring(1, value.length - 1); + } + + return value; +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts new file mode 100644 index 000000000..db4fb4a22 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts @@ -0,0 +1,67 @@ +import { BadRequestException } from '@nestjs/common'; + +enum FilterComparators { + eq = 'eq', + neq = 'neq', + in = 'in', + is = 'is', + gt = 'gt', + gte = 'gte', + lt = 'lt', + lte = 'lte', + startsWith = 'startsWith', + like = 'like', + ilike = 'ilike', + + // Not handled rigth now + // regex = 'regex', + // iregex = 'iregex', +} + +export const parseBaseFilter = ( + baseFilter: string, +): { + fields: string[]; + comparator: string; + value: string; +} => { + if (!baseFilter.match(`^(.+)\\[(.+)\\]:(.+)$`)) { + throw new BadRequestException( + `'filter' invalid for '${baseFilter}'. eg: price[gte]:10`, + ); + } + let fields = ''; + let comparator = ''; + let value = ''; + let fillFields = true; + let fillComparator = false; + let fillValue = false; + + // baseFilter = field_1.subfield[in]:["2023-00-00 OO:OO:OO","2024-00-00 OO:OO:OO"] + for (const c of baseFilter) { + if (fillValue) value += c; + + if (c === ']' && !fillValue) fillComparator = false; + if (c === ':' && !fillComparator) fillValue = true; + + if (fillComparator) comparator += c; + + if (c === '[' && fillFields) { + fillFields = false; + fillComparator = true; + } + + if (fillFields) fields += c; + } + // field = field_1.subfield ; comparator = in ; value = ["2023-00-00 OO:OO:OO","2024-00-00 OO:OO:OO"] + + if (!Object.keys(FilterComparators).includes(comparator)) { + throw new BadRequestException( + `'filter' invalid for '${baseFilter}', comparator ${comparator} not in ${Object.keys( + FilterComparators, + ).join(',')}`, + ); + } + + return { fields: fields.split('.'), comparator, value }; +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter-content.utils.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter-content.utils.ts new file mode 100644 index 000000000..572c28c64 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter-content.utils.ts @@ -0,0 +1,45 @@ +export const parseFilterContent = (filterQuery: string): string[] => { + let isWithinBrackets = false; + let isWithinDoubleQuotes = false; + let isWithinSingleQuotes = false; + let parenthesisCounter = 0; + const predicates: string[] = []; + let currentPredicates = ''; + + for (const c of filterQuery) { + let shouldPersistCharacter = parenthesisCounter >= 1; + + if (c === '(') { + parenthesisCounter++; + } + + if (c === ')') { + parenthesisCounter--; + shouldPersistCharacter = parenthesisCounter >= 1; + } + + if (['[', ']'].includes(c)) isWithinBrackets = !isWithinBrackets; + + if (c === '"') isWithinDoubleQuotes = !isWithinDoubleQuotes; + + if (c === "'") isWithinSingleQuotes = !isWithinSingleQuotes; + + if ( + c === ',' && + parenthesisCounter === 1 && + !isWithinBrackets && + !isWithinDoubleQuotes && + !isWithinSingleQuotes + ) { + predicates.push(currentPredicates); + currentPredicates = ''; + shouldPersistCharacter = false; + } + + if (shouldPersistCharacter) currentPredicates += c; + } + + predicates.push(currentPredicates); + + return predicates; +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts new file mode 100644 index 000000000..6b3b45e55 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts @@ -0,0 +1,64 @@ +import { BadRequestException } from '@nestjs/common'; + +import { parseFilterContent } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter-content.utils'; +import { parseBaseFilter } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils'; +import { + checkFields, + getFieldType, +} from 'src/core/api-rest/api-rest-query-builder/utils/fields.utils'; +import { formatFieldValue } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils'; +import { FieldValue } from 'src/core/api-rest/types/api-rest-field-value.type'; + +enum Conjunctions { + or = 'or', + and = 'and', + not = 'not', +} + +export const parseFilter = ( + filterQuery: string, + objectMetadataItem, +): Record => { + const result = {}; + const match = filterQuery.match( + `^(${Object.values(Conjunctions).join('|')})((.+))$`, + ); + + if (match) { + const conjunction = match?.[1]; + + if (!conjunction) { + throw new BadRequestException( + 'Error while matching filter query. Conjunction not found', + ); + } + const subResult = parseFilterContent(filterQuery).map((elem) => + parseFilter(elem, objectMetadataItem), + ); + + if (conjunction === Conjunctions.not) { + if (subResult.length > 1) { + throw new BadRequestException( + `'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`, + ); + } + result[conjunction] = subResult[0]; + } else { + result[conjunction] = subResult; + } + + return result; + } + const { fields, comparator, value } = parseBaseFilter(filterQuery); + + checkFields(objectMetadataItem, fields); + const fieldType = getFieldType(objectMetadataItem, fields[0]); + const formattedValue = formatFieldValue(value, fieldType, comparator); + + return fields.reverse().reduce( + (acc, currentValue) => { + return { [currentValue]: acc }; + }, + { [comparator]: formattedValue }, + ); +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory.ts new file mode 100644 index 000000000..57ffae171 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/last-cursor-input.factory.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +@Injectable() +export class LastCursorInputFactory { + create(request: Request): string | undefined { + const cursorQuery = request.query.last_cursor; + + if (typeof cursorQuery !== 'string') { + return undefined; + } + + return cursorQuery; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory.ts new file mode 100644 index 000000000..26d958d3a --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/limit-input.factory.ts @@ -0,0 +1,21 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +@Injectable() +export class LimitInputFactory { + create(request: Request): number { + if (!request.query.limit) { + return 60; + } + const limit = +request.query.limit; + + if (isNaN(limit) || limit < 0) { + throw new BadRequestException( + `limit '${request.query.limit}' is invalid. Should be an integer`, + ); + } + + return limit; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts new file mode 100644 index 000000000..893bfb612 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts @@ -0,0 +1,75 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; + +import { + OrderByDirection, + RecordOrderBy, +} from 'src/workspace/workspace-query-builder/interfaces/record.interface'; + +import { checkFields } from 'src/core/api-rest/api-rest-query-builder/utils/fields.utils'; + +const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst; + +@Injectable() +export class OrderByInputFactory { + create(request: Request, objectMetadata): RecordOrderBy { + const orderByQuery = request.query.order_by; + + if (typeof orderByQuery !== 'string') { + return {}; + } + + //orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3 + const orderByItems = orderByQuery.split(','); + let result = {}; + let itemDirection = ''; + let itemFields = ''; + + for (const orderByItem of orderByItems) { + // orderByItem -> field_1[AscNullsFirst] + if (orderByItem.includes('[') && orderByItem.includes(']')) { + const [fieldsString, direction] = orderByItem + .replace(']', '') + .split('['); + + // fields -> [field_1] ; direction -> AscNullsFirst + if (!(direction in OrderByDirection)) { + throw new BadRequestException( + `'order_by' direction '${direction}' invalid. Allowed values are '${Object.values( + OrderByDirection, + ).join( + "', '", + )}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`, + ); + } + + itemDirection = direction; + itemFields = fieldsString; + } else { + // orderByItem -> field_3 + itemDirection = DEFAULT_ORDER_DIRECTION; + itemFields = orderByItem; + } + + let fieldResult = {}; + + itemFields + .split('.') + .reverse() + .forEach((field) => { + if (Object.keys(fieldResult).length) { + fieldResult = { [field]: fieldResult }; + } else { + fieldResult[field] = itemDirection; + } + }, itemDirection); + + result = { ...result, ...fieldResult }; + } + + checkFields(objectMetadata.objectMetadataItem, Object.keys(result)); + + return result; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/update-query.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/update-query.factory.ts new file mode 100644 index 000000000..3b9665e31 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/update-query.factory.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import { capitalize } from 'src/utils/capitalize'; +import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; + +@Injectable() +export class UpdateQueryFactory { + create(objectMetadata, depth?: number): string { + return ` + mutation Update${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}($id: ID!, $data: CompanyUpdateInput!) { + update${capitalize( + objectMetadata.objectMetadataItem.nameSingular, + )}(id: $id, data: $data) { + id + ${objectMetadata.objectMetadataItem.fields + .map((field) => + mapFieldMetadataToGraphqlQuery( + objectMetadata.objectMetadataItems, + field, + depth, + ), + ) + .join('\n')} + } + } + `; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/factories/update-variables.factory.ts b/server/src/core/api-rest/api-rest-query-builder/factories/update-variables.factory.ts new file mode 100644 index 000000000..298bdc9ee --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/factories/update-variables.factory.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; + +import { ApiRestQueryVariables } from 'src/core/api-rest/types/api-rest-query-variables.type'; + +@Injectable() +export class UpdateVariablesFactory { + create(id: string, request: Request): ApiRestQueryVariables { + return { + id, + data: request.body, + }; + } +} diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/compute-depth.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/compute-depth.utils.spec.ts new file mode 100644 index 000000000..b257618fb --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/compute-depth.utils.spec.ts @@ -0,0 +1,26 @@ +import { computeDepth } from 'src/core/api-rest/api-rest-query-builder/utils/compute-depth.utils'; + +describe('computeDepth', () => { + it('should compute depth from query', () => { + const request: any = { + query: { depth: '1' }, + }; + + expect(computeDepth(request)).toEqual(1); + }); + + it('should return default depth if missing', () => { + const request: any = { query: {} }; + + expect(computeDepth(request)).toEqual(undefined); + }); + it('should raise if wrong depth', () => { + const request: any = { query: { depth: '100' } }; + + expect(() => computeDepth(request)).toThrow(); + + request.query.depth = '0'; + + expect(() => computeDepth(request)).toThrow(); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts new file mode 100644 index 000000000..27ac2718d --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts @@ -0,0 +1,30 @@ +import { + checkFields, + getFieldType, +} from 'src/core/api-rest/api-rest-query-builder/utils/fields.utils'; +import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; + +describe('FieldUtils', () => { + describe('getFieldType', () => { + it('should get field type', () => { + expect(getFieldType(objectMetadataItem, 'fieldNumber')).toEqual( + FieldMetadataType.NUMBER, + ); + }); + }); + + describe('checkFields', () => { + it('should check field types', () => { + expect(() => + checkFields(objectMetadataItem, ['fieldNumber']), + ).not.toThrow(); + + expect(() => checkFields(objectMetadataItem, ['wrongField'])).toThrow(); + + expect(() => + checkFields(objectMetadataItem, ['fieldNumber', 'wrongField']), + ).toThrow(); + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts new file mode 100644 index 000000000..ae1e2de46 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts @@ -0,0 +1,35 @@ +import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; +import { + fieldCurrency, + fieldLink, + fieldNumber, + fieldString, + objectMetadataItem, +} from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; + +describe('mapFieldMetadataToGraphqlQuery', () => { + it('should map properly', () => { + expect( + mapFieldMetadataToGraphqlQuery(objectMetadataItem, fieldNumber), + ).toEqual('fieldNumber'); + expect( + mapFieldMetadataToGraphqlQuery(objectMetadataItem, fieldString), + ).toEqual('fieldString'); + expect(mapFieldMetadataToGraphqlQuery(objectMetadataItem, fieldLink)) + .toEqual(` + fieldLink + { + label + url + } + `); + expect(mapFieldMetadataToGraphqlQuery(objectMetadataItem, fieldCurrency)) + .toEqual(` + fieldCurrency + { + amountMicros + currencyCode + } + `); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/parse-path.utils.spec.ts b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/parse-path.utils.spec.ts new file mode 100644 index 000000000..bd2cd22c6 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/parse-path.utils.spec.ts @@ -0,0 +1,21 @@ +import { parsePath } from 'src/core/api-rest/api-rest-query-builder/utils/parse-path.utils'; + +describe('parsePath', () => { + it('should parse object from request path', () => { + const request: any = { path: '/rest/companies/uuid' }; + + expect(parsePath(request)).toEqual({ + object: 'companies', + id: 'uuid', + }); + }); + + it('should parse object from request path', () => { + const request: any = { path: '/rest/companies' }; + + expect(parsePath(request)).toEqual({ + object: 'companies', + id: undefined, + }); + }); +}); diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts new file mode 100644 index 000000000..a61fb9d1d --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts @@ -0,0 +1,33 @@ +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; + +export const fieldNumber = { + name: 'fieldNumber', + type: FieldMetadataType.NUMBER, + targetColumnMap: { value: 'fieldNumber' }, +}; + +export const fieldString = { + name: 'fieldString', + type: FieldMetadataType.TEXT, + targetColumnMap: { value: 'fieldString' }, +}; + +export const fieldLink = { + name: 'fieldLink', + type: FieldMetadataType.LINK, + targetColumnMap: { label: 'fieldLinkLabel', url: 'fieldLinkUrl' }, +}; + +export const fieldCurrency = { + name: 'fieldCurrency', + type: FieldMetadataType.CURRENCY, + targetColumnMap: { + amountMicros: 'fieldCurrencyAmountMicros', + currencyCode: 'fieldCurrencyCurrencyCode', + }, +}; + +export const objectMetadataItem = { + targetTableName: 'testingObject', + fields: [fieldNumber, fieldString, fieldLink, fieldCurrency], +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/compute-depth.utils.ts b/server/src/core/api-rest/api-rest-query-builder/utils/compute-depth.utils.ts new file mode 100644 index 000000000..6fdee1b30 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/compute-depth.utils.ts @@ -0,0 +1,25 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Request } from 'express'; + +const ALLOWED_DEPTH_VALUES = [1, 2]; + +export const computeDepth = (request: Request): number | undefined => { + if (!request.query.depth) { + return undefined; + } + + const depth = +request.query.depth; + + if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) { + throw new BadRequestException( + `'depth=${ + request.query.depth + }' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join( + ', ', + )}`, + ); + } + + return depth; +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/fields.utils.ts b/server/src/core/api-rest/api-rest-query-builder/utils/fields.utils.ts new file mode 100644 index 000000000..62c2726e5 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/fields.utils.ts @@ -0,0 +1,35 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; + +export const getFieldType = ( + objectMetadataItem, + fieldName, +): FieldMetadataType | undefined => { + for (const itemField of objectMetadataItem.fields) { + if (fieldName === itemField.name) { + return itemField.type; + } + } +}; + +export const checkFields = (objectMetadataItem, fieldNames): void => { + for (const fieldName of fieldNames) { + if ( + !objectMetadataItem.fields + .reduce( + (acc, itemField) => [ + ...acc, + itemField.name, + ...Object.keys(itemField.targetColumnMap), + ], + [], + ) + .includes(fieldName) + ) { + throw new BadRequestException( + `field '${fieldName}' does not exist in '${objectMetadataItem.targetTableName}' object`, + ); + } + } +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/server/src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts new file mode 100644 index 000000000..c5a0ba803 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -0,0 +1,107 @@ +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity'; + +const DEFAULT_DEPTH_VALUE = 2; + +export const mapFieldMetadataToGraphqlQuery = ( + objectMetadataItems, + field, + maxDepthForRelations = DEFAULT_DEPTH_VALUE, +): string | undefined => { + if (maxDepthForRelations <= 0) { + return ''; + } + + const fieldType = field.type; + + const fieldIsSimpleValue = [ + FieldMetadataType.UUID, + FieldMetadataType.TEXT, + FieldMetadataType.PHONE, + FieldMetadataType.DATE_TIME, + FieldMetadataType.EMAIL, + FieldMetadataType.NUMBER, + FieldMetadataType.BOOLEAN, + ].includes(fieldType); + + if (fieldIsSimpleValue) { + return field.name; + } else if ( + fieldType === FieldMetadataType.RELATION && + field.toRelationMetadata?.relationType === RelationMetadataType.ONE_TO_MANY + ) { + const relationMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + (field.toRelationMetadata as any)?.fromObjectMetadata?.id, + ); + + return `${field.name} + { + id + ${(relationMetadataItem?.fields ?? []) + .filter((field) => field.type !== FieldMetadataType.RELATION) + .map((field) => + mapFieldMetadataToGraphqlQuery( + objectMetadataItems, + field, + maxDepthForRelations - 1, + ), + ) + .join('\n')} + }`; + } else if ( + fieldType === FieldMetadataType.RELATION && + field.fromRelationMetadata?.relationType === + RelationMetadataType.ONE_TO_MANY + ) { + const relationMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + (field.fromRelationMetadata as any)?.toObjectMetadata?.id, + ); + + return `${field.name} + { + edges { + node { + id + ${(relationMetadataItem?.fields ?? []) + .filter((field) => field.type !== FieldMetadataType.RELATION) + .map((field) => + mapFieldMetadataToGraphqlQuery( + objectMetadataItems, + field, + maxDepthForRelations - 1, + ), + ) + .join('\n')} + } + } + }`; + } else if (fieldType === FieldMetadataType.LINK) { + return ` + ${field.name} + { + label + url + } + `; + } else if (fieldType === FieldMetadataType.CURRENCY) { + return ` + ${field.name} + { + amountMicros + currencyCode + } + `; + } else if (fieldType === FieldMetadataType.FULL_NAME) { + return ` + ${field.name} + { + firstName + lastName + } + `; + } +}; diff --git a/server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts b/server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts new file mode 100644 index 000000000..a2e114369 --- /dev/null +++ b/server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Request } from 'express'; + +export const parsePath = ( + request: Request, +): { object: string; id?: string } => { + const queryAction = request.path.replace('/rest/', '').split('/'); + + if (queryAction.length > 2) { + throw new BadRequestException( + `Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies`, + ); + } + + if (queryAction.length === 1) { + return { object: queryAction[0] }; + } + + return { object: queryAction[0], id: queryAction[1] }; +}; diff --git a/server/src/core/api-rest/api-rest.controller.ts b/server/src/core/api-rest/api-rest.controller.ts index e5f43f9da..7fb3e69f7 100644 --- a/server/src/core/api-rest/api-rest.controller.ts +++ b/server/src/core/api-rest/api-rest.controller.ts @@ -1,38 +1,39 @@ -import { Controller, Delete, Get, Post, Put, Req } from '@nestjs/common'; +import { Controller, Delete, Get, Post, Put, Req, Res } from '@nestjs/common'; -import { Request } from 'express'; +import { Request, Response } from 'express'; import { ApiRestService } from 'src/core/api-rest/api-rest.service'; +import { ApiRestResponse } from 'src/core/api-rest/types/api-rest-response.type'; + +const handleResult = (res: Response, result: ApiRestResponse) => { + if (result.data.error) { + res.status(400).send(result.data); + } else { + res.send(result.data); + } +}; @Controller('rest/*') export class ApiRestController { constructor(private readonly apiRestService: ApiRestService) {} @Get() - async handleApiGet(@Req() request: Request): Promise { - const result = await this.apiRestService.get(request); - - return result.data; + async handleApiGet(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.get(request)); } @Delete() - async handleApiDelete(@Req() request: Request): Promise { - const result = await this.apiRestService.delete(request); - - return result.data; + async handleApiDelete(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.delete(request)); } @Post() - async handleApiPost(@Req() request: Request): Promise { - const result = await this.apiRestService.create(request); - - return result.data; + async handleApiPost(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.create(request)); } @Put() - async handleApiPut(@Req() request: Request): Promise { - const result = await this.apiRestService.update(request); - - return result.data; + async handleApiPut(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.update(request)); } } diff --git a/server/src/core/api-rest/api-rest.module.ts b/server/src/core/api-rest/api-rest.module.ts index 108726c05..453dd2af5 100644 --- a/server/src/core/api-rest/api-rest.module.ts +++ b/server/src/core/api-rest/api-rest.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { ApiRestController } from 'src/core/api-rest/api-rest.controller'; import { ApiRestService } from 'src/core/api-rest/api-rest.service'; -import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module'; +import { ApiRestQueryBuilderModule } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.module'; +import { AuthModule } from 'src/core/auth/auth.module'; @Module({ - imports: [ObjectMetadataModule], + imports: [ApiRestQueryBuilderModule, AuthModule], controllers: [ApiRestController], providers: [ApiRestService], }) diff --git a/server/src/core/api-rest/api-rest.service.spec.ts b/server/src/core/api-rest/api-rest.service.spec.ts index 8cb35d188..e1832225a 100644 --- a/server/src/core/api-rest/api-rest.service.spec.ts +++ b/server/src/core/api-rest/api-rest.service.spec.ts @@ -1,25 +1,29 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiRestService } from 'src/core/api-rest/api-rest.service'; -import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { ApiRestQueryBuilderFactory } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory'; describe('ApiRestService', () => { let service: ApiRestService; - const objectMetadataItem = { fields: [{ name: 'field', type: 'NUMBER' }] }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ApiRestService, { - provide: ObjectMetadataService, + provide: ApiRestQueryBuilderFactory, useValue: {}, }, { provide: EnvironmentService, useValue: {}, }, + { + provide: TokenService, + useValue: {}, + }, ], }).compile(); @@ -28,122 +32,4 @@ describe('ApiRestService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); - describe('checkFilterQuery', () => { - it('should check filter query', () => { - expect(() => service.checkFilterQuery('(')).toThrow(); - expect(() => service.checkFilterQuery(')')).toThrow(); - expect(() => service.checkFilterQuery('(()')).toThrow(); - expect(() => service.checkFilterQuery('())')).toThrow(); - expect(() => - service.checkFilterQuery( - 'and(or(field[eq]:1,field[eq]:2)),field[eq]:3)', - ), - ).toThrow(); - expect(() => - service.checkFilterQuery( - 'and(or(field[eq]:1,field[eq]:2),field[eq]:3)', - ), - ).not.toThrow(); - }); - }); - describe('formatFieldValue', () => { - it('should format field value', () => { - expect(service.formatFieldValue('1', 'NUMBER')).toEqual(1); - expect(service.formatFieldValue(1, 'NUMBER')).toEqual(1); - expect(service.formatFieldValue('a', 'NUMBER')).toEqual(NaN); - expect(service.formatFieldValue('true', 'BOOLEAN')).toEqual(true); - expect(service.formatFieldValue('True', 'BOOLEAN')).toEqual(true); - expect(service.formatFieldValue('false', 'BOOLEAN')).toEqual(false); - expect(service.formatFieldValue('1', 'TEXT')).toEqual('1'); - }); - }); - describe('parseFilterQueryContent', () => { - it('should parse query filter test 1', () => { - expect(service.parseFilterQueryContent('and(field[eq]:1)')).toEqual([ - 'field[eq]:1', - ]); - }); - it('should parse query filter test 2', () => { - expect( - service.parseFilterQueryContent('and(field[eq]:1,field[eq]:2)'), - ).toEqual(['field[eq]:1', 'field[eq]:2']); - }); - it('should parse query filter test 3', () => { - expect( - service.parseFilterQueryContent( - 'and(field[eq]:1,or(field[eq]:2,field[eq]:3))', - ), - ).toEqual(['field[eq]:1', 'or(field[eq]:2,field[eq]:3)']); - }); - it('should parse query filter test 4', () => { - expect( - service.parseFilterQueryContent( - 'and(field[eq]:1,or(field[eq]:2,not(field[eq]:3)),field[eq]:4,not(field[eq]:5))', - ), - ).toEqual([ - 'field[eq]:1', - 'or(field[eq]:2,not(field[eq]:3))', - 'field[eq]:4', - 'not(field[eq]:5)', - ]); - }); - }); - describe('parseStringFilter', () => { - it('should parse string filter test 1', () => { - expect( - service.parseStringFilter( - 'and(field[eq]:1,field[eq]:2)', - objectMetadataItem, - ), - ).toEqual({ and: [{ field: { eq: 1 } }, { field: { eq: 2 } }] }); - }); - it('should parse string filter test 2', () => { - expect( - service.parseStringFilter( - 'and(field[eq]:1,or(field[eq]:2,field[eq]:3))', - objectMetadataItem, - ), - ).toEqual({ - and: [ - { field: { eq: 1 } }, - { or: [{ field: { eq: 2 } }, { field: { eq: 3 } }] }, - ], - }); - }); - it('should parse string filter test 3', () => { - expect( - service.parseStringFilter( - 'and(field[eq]:1,or(field[eq]:2,field[eq]:3,and(field[eq]:6,field[eq]:7)),or(field[eq]:4,field[eq]:5))', - objectMetadataItem, - ), - ).toEqual({ - and: [ - { field: { eq: 1 } }, - { - or: [ - { field: { eq: 2 } }, - { field: { eq: 3 } }, - { and: [{ field: { eq: 6 } }, { field: { eq: 7 } }] }, - ], - }, - { or: [{ field: { eq: 4 } }, { field: { eq: 5 } }] }, - ], - }); - }); - it('should handler not', () => { - expect( - service.parseStringFilter( - 'and(field[eq]:1,not(field[eq]:2))', - objectMetadataItem, - ), - ).toEqual({ - and: [ - { field: { eq: 1 } }, - { - not: { field: { eq: 2 } }, - }, - ], - }); - }); - }); }); diff --git a/server/src/core/api-rest/api-rest.service.ts b/server/src/core/api-rest/api-rest.service.ts index b9fdf4b17..5d1136dc6 100644 --- a/server/src/core/api-rest/api-rest.service.ts +++ b/server/src/core/api-rest/api-rest.service.ts @@ -2,623 +2,39 @@ import { Injectable } from '@nestjs/common'; import axios from 'axios'; import { Request } from 'express'; -import { verify } from 'jsonwebtoken'; -import { ExtractJwt } from 'passport-jwt'; - -import { - OrderByDirection, - RecordOrderBy, -} from 'src/workspace/workspace-query-builder/interfaces/record.interface'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; -import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; -import { capitalize } from 'src/utils/capitalize'; - -enum FILTER_COMPARATORS { - eq = 'eq', - gt = 'gt', - gte = 'gte', - lt = 'lt', - lte = 'lte', -} -const ALLOWED_DEPTH_VALUES = [1, 2]; -const DEFAULT_DEPTH_VALUE = 2; -const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst; - -enum CONJUNCTIONS { - or = 'or', - and = 'and', - not = 'not', -} -const DEFAULT_FILTER_CONJUNCTION = CONJUNCTIONS.and; +import { ApiRestQueryBuilderFactory } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { ApiRestResponse } from 'src/core/api-rest/types/api-rest-response.type'; +import { ApiRestQuery } from 'src/core/api-rest/types/api-rest-query.type'; @Injectable() export class ApiRestService { constructor( - private readonly objectMetadataService: ObjectMetadataService, + private readonly tokenService: TokenService, private readonly environmentService: EnvironmentService, + private readonly apiRestQueryBuilderFactory: ApiRestQueryBuilderFactory, ) {} - mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - maxDepthForRelations = DEFAULT_DEPTH_VALUE, - ): any { - if (maxDepthForRelations <= 0) { - return ''; - } - - const fieldType = field.type; - - const fieldIsSimpleValue = [ - 'UUID', - 'TEXT', - 'PHONE', - 'DATE_TIME', - 'EMAIL', - 'NUMBER', - 'BOOLEAN', - ].includes(fieldType); - - if (fieldIsSimpleValue) { - return field.name; - } else if ( - fieldType === 'RELATION' && - field.toRelationMetadata?.relationType === 'ONE_TO_MANY' - ) { - const relationMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === - (field.toRelationMetadata as any)?.fromObjectMetadata?.id, - ); - - return `${field.name} - { - id - ${(relationMetadataItem?.fields ?? []) - .filter((field) => field.type !== 'RELATION') - .map((field) => - this.mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - maxDepthForRelations - 1, - ), - ) - .join('\n')} - }`; - } else if ( - fieldType === 'RELATION' && - field.fromRelationMetadata?.relationType === 'ONE_TO_MANY' - ) { - const relationMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === - (field.fromRelationMetadata as any)?.toObjectMetadata?.id, - ); - - return `${field.name} - { - edges { - node { - id - ${(relationMetadataItem?.fields ?? []) - .filter((field) => field.type !== 'RELATION') - .map((field) => - this.mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - maxDepthForRelations - 1, - ), - ) - .join('\n')} - } - } - }`; - } else if (fieldType === 'LINK') { - return ` - ${field.name} - { - label - url - } - `; - } else if (fieldType === 'CURRENCY') { - return ` - ${field.name} - { - amountMicros - currencyCode - } - `; - } else if (fieldType === 'FULL_NAME') { - return ` - ${field.name} - { - firstName - lastName - } - `; - } - } - - computeDeleteQuery(objectMetadataItem) { - return ` - mutation Delete${capitalize(objectMetadataItem.nameSingular)}($id: ID!) { - delete${capitalize(objectMetadataItem.nameSingular)}(id: $id) { - id - } - } - `; - } - - computeCreateQuery( - objectMetadataItems, - objectMetadataItem, - depth = DEFAULT_DEPTH_VALUE, - ) { - return ` - mutation Create${capitalize( - objectMetadataItem.nameSingular, - )}($data: CompanyCreateInput!) { - create${capitalize(objectMetadataItem.nameSingular)}(data: $data) { - id - ${objectMetadataItem.fields - .map((field) => - this.mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - depth, - ), - ) - .join('\n')} - } - } - `; - } - - computeUpdateQuery( - objectMetadataItems, - objectMetadataItem, - depth = DEFAULT_DEPTH_VALUE, - ) { - return ` - mutation Update${capitalize( - objectMetadataItem.nameSingular, - )}($id: ID!, $data: CompanyUpdateInput!) { - update${capitalize( - objectMetadataItem.nameSingular, - )}(id: $id, data: $data) { - id - ${objectMetadataItem.fields - .map((field) => - this.mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - depth, - ), - ) - .join('\n')} - } - } - `; - } - - computeFindOneQuery( - objectMetadataItems, - objectMetadataItem, - depth = DEFAULT_DEPTH_VALUE, - ) { - return ` - query FindOne${capitalize(objectMetadataItem.nameSingular)}( - $filter: ${capitalize(objectMetadataItem.nameSingular)}FilterInput!, - ) { - ${objectMetadataItem.nameSingular}(filter: $filter) { - id - ${objectMetadataItem.fields - .map((field) => - this.mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - depth, - ), - ) - .join('\n')} - } - } - `; - } - - computeFindManyQuery( - objectMetadataItems, - objectMetadataItem, - depth = DEFAULT_DEPTH_VALUE, - ): string { - return ` - query FindMany${capitalize(objectMetadataItem.namePlural)}( - $filter: ${capitalize(objectMetadataItem.nameSingular)}FilterInput, - $orderBy: ${capitalize(objectMetadataItem.nameSingular)}OrderByInput, - $lastCursor: String, - $limit: Float = 60 - ) { - ${objectMetadataItem.namePlural}( - filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor - ) { - edges { - node { - id - ${objectMetadataItem.fields - .map((field) => - this.mapFieldMetadataToGraphQLQuery( - objectMetadataItems, - field, - depth, - ), - ) - .join('\n')} - } - cursor - } - pageInfo { - hasNextPage - startCursor - endCursor - } - } - } - `; - } - - async getObjectMetadata(request: Request) { - const workspaceId = this.extractWorkspaceId(request); - const objectMetadataItems = - await this.objectMetadataService.findManyWithinWorkspace(workspaceId); - const parsedObject = this.parseObject(request)[0]; - const [objectMetadata] = objectMetadataItems.filter( - (object) => object.namePlural === parsedObject, - ); - - if (!objectMetadata) { - const [wrongObjectMetadata] = objectMetadataItems.filter( - (object) => object.nameSingular === parsedObject, - ); - let hint = 'eg: companies'; - - if (wrongObjectMetadata) { - hint = `Did you mean '${wrongObjectMetadata.namePlural}'?`; - } - throw Error(`object '${parsedObject}' not found. ${hint}`); - } - - return { - objectMetadataItems, - objectMetadataItem: objectMetadata, - }; - } - - addDefaultConjunctionIfMissing(filterQuery) { - if (!(filterQuery.includes('(') && filterQuery.includes(')'))) { - return `${DEFAULT_FILTER_CONJUNCTION}(${filterQuery})`; - } - - return filterQuery; - } - - checkFilterQuery(filterQuery) { - const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length; - const countClosedBrackets = (filterQuery.match(/\)/g) || []).length; - const diff = countOpenedBrackets - countClosedBrackets; - - if (diff !== 0) { - const hint = - diff > 0 - ? `${diff} open bracket${diff > 1 ? 's are' : ' is'}` - : `${Math.abs(diff)} close bracket${ - Math.abs(diff) > 1 ? 's are' : ' is' - }`; - - throw Error(`'filter' invalid. ${hint} missing in the query`); - } - - return; - } - - parseFilterQueryContent(filterQuery) { - let parenthesisCounter = 0; - const predicates: string[] = []; - let currentPredicates = ''; - - for (const c of filterQuery) { - if (c === '(') { - parenthesisCounter++; - if (parenthesisCounter === 1) continue; - } - if (c === ')') { - parenthesisCounter--; - if (parenthesisCounter === 0) continue; - } - if (c === ',' && parenthesisCounter === 1) { - predicates.push(currentPredicates); - currentPredicates = ''; - continue; - } - if (parenthesisCounter >= 1) currentPredicates += c; - } - if (currentPredicates.length) { - predicates.push(currentPredicates); - } - - return predicates; - } - - parseStringFilter(filterQuery, objectMetadataItem) { - const result = {}; - const match = filterQuery.match( - `^(${Object.values(CONJUNCTIONS).join('|')})((.+))$`, - ); - - if (match) { - const conjunction = match[1]; - const subResult = this.parseFilterQueryContent(filterQuery).map((elem) => - this.parseStringFilter(elem, objectMetadataItem), - ); - - if (conjunction === CONJUNCTIONS.not) { - if (subResult.length > 1) { - throw Error( - `'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`, - ); - } - result[conjunction] = subResult[0]; - } else { - result[conjunction] = subResult; - } - - return result; - } - - return this.parseSimpleFilter(filterQuery, objectMetadataItem); - } - - parseSimpleFilter(filterString: string, objectMetadataItem) { - // price[lte]:100 - if ( - !filterString.match( - `^(.+)\\[(${Object.keys(FILTER_COMPARATORS).join('|')})\\]:(.+)$`, - ) - ) { - throw Error(`'filter' invalid for '${filterString}'. eg: price[gte]:10`); - } - const [fieldAndComparator, value] = filterString.split(':'); - const [field, comparator] = fieldAndComparator.replace(']', '').split('['); - - if (!Object.keys(FILTER_COMPARATORS).includes(comparator)) { - throw Error( - `'filter' invalid for '${filterString}', comparator ${comparator} not in ${Object.keys( - FILTER_COMPARATORS, - ).join(',')}`, - ); - } - const fields = field.split('.'); - - this.checkFields(objectMetadataItem, fields, 'filter'); - const fieldType = this.getFieldType(objectMetadataItem, fields[0]); - const formattedValue = this.formatFieldValue(value, fieldType); - - return fields.reverse().reduce( - (acc, currentValue) => { - return { [currentValue]: acc }; - }, - { [comparator]: formattedValue }, - ); - } - - formatFieldValue(value, fieldType?) { - if (fieldType === 'NUMBER') { - return parseInt(value); - } - if (fieldType === 'BOOLEAN') { - return value.toLowerCase() === 'true'; - } - - return value; - } - - parseFilter(request, objectMetadataItem) { - const parsedObjectId = this.parseObject(request)[1]; - - if (parsedObjectId) { - return { id: { eq: parsedObjectId } }; - } - const rawFilterQuery = request.query.filter; - - if (typeof rawFilterQuery !== 'string') { - return {}; - } - this.checkFilterQuery(rawFilterQuery); - const filterQuery = this.addDefaultConjunctionIfMissing(rawFilterQuery); - - return this.parseStringFilter(filterQuery, objectMetadataItem); - } - - parseOrderBy(request, objectMetadataItem) { - //?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3 - const orderByQuery = request.query.order_by; - - if (typeof orderByQuery !== 'string') { - return {}; - } - const orderByItems = orderByQuery.split(','); - const result = {}; - - for (const orderByItem of orderByItems) { - // orderByItem -> field_1[AscNullsFirst] - if (orderByItem.includes('[') && orderByItem.includes(']')) { - const [field, direction] = orderByItem.replace(']', '').split('['); - - // field -> field_1 ; direction -> AscNullsFirst - if (!(direction in OrderByDirection)) { - throw Error( - `'order_by' direction '${direction}' invalid. Allowed values are '${Object.values( - OrderByDirection, - ).join( - "', '", - )}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`, - ); - } - result[field] = direction; - } else { - // orderByItem -> field_3 - result[orderByItem] = DEFAULT_ORDER_DIRECTION; - } - } - this.checkFields(objectMetadataItem, Object.keys(result), 'order_by'); - - return result; - } - - checkFields(objectMetadataItem, fieldNames, queryParam) { - for (const fieldName of fieldNames) { - if ( - !objectMetadataItem.fields - .reduce( - (acc, itemField) => [ - ...acc, - itemField.name, - ...Object.keys(itemField.targetColumnMap), - ], - [], - ) - .includes(fieldName) - ) { - throw Error( - `'${queryParam}' field '${fieldName}' does not exist in '${objectMetadataItem.targetTableName}' object`, - ); - } - } - } - - getFieldType(objectMetadataItem, fieldName) { - for (const itemField of objectMetadataItem.fields) { - if (fieldName === itemField.name) { - return itemField.type; - } - } - } - - parseLimit(request) { - const limitQuery = request.query.limit; - - if (typeof limitQuery !== 'string') { - return 60; - } - const limitParsed = parseInt(limitQuery); - - if (!Number.isInteger(limitParsed)) { - throw Error(`limit '${limitQuery}' is invalid. Should be an integer`); - } - - return limitParsed; - } - - parseCursor(request) { - const cursorQuery = request.query.last_cursor; - - if (typeof cursorQuery !== 'string') { - return undefined; - } - - return cursorQuery; - } - - computeQueryVariables(request: Request, objectMetadataItem) { - return { - filter: this.parseFilter(request, objectMetadataItem), - orderBy: this.parseOrderBy(request, objectMetadataItem), - limit: this.parseLimit(request), - lastCursor: this.parseCursor(request), - }; - } - - parseObject(request) { - const queryAction = request.path.replace('/rest/', '').split('/'); - - if (queryAction.length > 2) { - throw Error( - `Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies`, - ); - } - if (queryAction.length === 1) { - return [queryAction[0], undefined]; - } - - return queryAction; - } - - extractWorkspaceId(request: Request) { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - if (!token) { - throw Error('missing authentication token'); - } - - return verify(token, this.environmentService.getAccessTokenSecret())[ - 'workspaceId' - ]; - } - - computeDepth(request): number { - const depth = - typeof request.query.depth === 'string' - ? parseInt(request.query.depth) - : DEFAULT_DEPTH_VALUE; - - if (!ALLOWED_DEPTH_VALUES.includes(depth)) { - throw Error( - `'depth=${depth}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join( - ', ', - )}`, - ); - } - - return depth; - } - - async callGraphql(request: Request, data) { + async callGraphql( + request: Request, + data: ApiRestQuery, + ): Promise { return await axios.post( - `${this.environmentService.getLocalServerUrl()}/graphql`, + `${request.protocol}://${request.get('host')}/graphql`, data, { headers: { - authorization: request.headers.authorization, + Authorization: request.headers.authorization, }, }, ); } - async get(request: Request) { + async get(request: Request): Promise { try { - const objectMetadata = await this.getObjectMetadata(request); - const id = this.parseObject(request)[1]; - const depth = this.computeDepth(request); - const data = { - query: id - ? this.computeFindOneQuery( - objectMetadata.objectMetadataItems, - objectMetadata.objectMetadataItem, - depth, - ) - : this.computeFindManyQuery( - objectMetadata.objectMetadataItems, - objectMetadata.objectMetadataItem, - depth, - ), - variables: id - ? { filter: { id: { eq: id } } } - : this.computeQueryVariables( - request, - objectMetadata.objectMetadataItem, - ), - }; + const data = await this.apiRestQueryBuilderFactory.get(request); return await this.callGraphql(request, data); } catch (err) { @@ -626,24 +42,9 @@ export class ApiRestService { } } - async delete(request: Request) { + async delete(request: Request): Promise { try { - const objectMetadata = await this.getObjectMetadata(request); - const id = this.parseObject(request)[1]; - - if (!id) { - return { - data: { - error: `delete ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, - }, - }; - } - const data = { - query: this.computeDeleteQuery(objectMetadata.objectMetadataItem), - variables: { - id: this.parseObject(request)[1], - }, - }; + const data = await this.apiRestQueryBuilderFactory.delete(request); return await this.callGraphql(request, data); } catch (err) { @@ -651,20 +52,9 @@ export class ApiRestService { } } - async create(request: Request) { + async create(request: Request): Promise { try { - const objectMetadata = await this.getObjectMetadata(request); - const depth = this.computeDepth(request); - const data = { - query: this.computeCreateQuery( - objectMetadata.objectMetadataItems, - objectMetadata.objectMetadataItem, - depth, - ), - variables: { - data: request.body, - }, - }; + const data = await this.apiRestQueryBuilderFactory.create(request); return await this.callGraphql(request, data); } catch (err) { @@ -672,30 +62,9 @@ export class ApiRestService { } } - async update(request: Request) { + async update(request: Request): Promise { try { - const objectMetadata = await this.getObjectMetadata(request); - const depth = this.computeDepth(request); - const id = this.parseObject(request)[1]; - - if (!id) { - return { - data: { - error: `update ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, - }, - }; - } - const data = { - query: this.computeUpdateQuery( - objectMetadata.objectMetadataItems, - objectMetadata.objectMetadataItem, - depth, - ), - variables: { - id: this.parseObject(request)[1], - data: request.body, - }, - }; + const data = await this.apiRestQueryBuilderFactory.update(request); return await this.callGraphql(request, data); } catch (err) { diff --git a/server/src/core/api-rest/types/api-rest-field-value.type.ts b/server/src/core/api-rest/types/api-rest-field-value.type.ts new file mode 100644 index 000000000..b92064773 --- /dev/null +++ b/server/src/core/api-rest/types/api-rest-field-value.type.ts @@ -0,0 +1,6 @@ +export type FieldValue = + | string + | boolean + | number + | FieldValue[] + | { [key: string]: FieldValue }; diff --git a/server/src/core/api-rest/types/api-rest-query-variables.type.ts b/server/src/core/api-rest/types/api-rest-query-variables.type.ts new file mode 100644 index 000000000..c4e6b0525 --- /dev/null +++ b/server/src/core/api-rest/types/api-rest-query-variables.type.ts @@ -0,0 +1,8 @@ +export type ApiRestQueryVariables = { + id?: string; + data?: object | null; + filter?: object; + orderBy?: object; + limit?: number; + lastCursor?: string; +}; diff --git a/server/src/core/api-rest/types/api-rest-query.type.ts b/server/src/core/api-rest/types/api-rest-query.type.ts new file mode 100644 index 000000000..ea3b2cfa6 --- /dev/null +++ b/server/src/core/api-rest/types/api-rest-query.type.ts @@ -0,0 +1,4 @@ +export type ApiRestQuery = { + query: string; + variables: object; +}; diff --git a/server/src/core/api-rest/types/api-rest-response.type.ts b/server/src/core/api-rest/types/api-rest-response.type.ts new file mode 100644 index 000000000..6e2d0dec1 --- /dev/null +++ b/server/src/core/api-rest/types/api-rest-response.type.ts @@ -0,0 +1 @@ +export type ApiRestResponse = { data: { error?: string } }; diff --git a/server/src/core/auth/auth.module.ts b/server/src/core/auth/auth.module.ts index 9dce7de38..c32c44b2e 100644 --- a/server/src/core/auth/auth.module.ts +++ b/server/src/core/auth/auth.module.ts @@ -45,6 +45,6 @@ const jwtModule = JwtModule.registerAsync({ ], controllers: [GoogleAuthController, VerifyAuthController], providers: [AuthService, TokenService, JwtAuthStrategy, AuthResolver], - exports: [jwtModule], + exports: [jwtModule, TokenService], }) export class AuthModule {} diff --git a/server/src/core/auth/services/token.service.ts b/server/src/core/auth/services/token.service.ts index 072ec5221..7746c84bf 100644 --- a/server/src/core/auth/services/token.service.ts +++ b/server/src/core/auth/services/token.service.ts @@ -13,6 +13,8 @@ import { addMilliseconds } from 'date-fns'; import ms from 'ms'; import { TokenExpiredError } from 'jsonwebtoken'; import { Repository } from 'typeorm'; +import { Request } from 'express'; +import { ExtractJwt } from 'passport-jwt'; import { JwtPayload } from 'src/core/auth/strategies/jwt.auth.strategy'; import { assert } from 'src/utils/assert'; @@ -142,6 +144,20 @@ export class TokenService { return { token }; } + async verifyApiKeyToken(request: Request) { + const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + + if (!token) { + throw new UnauthorizedException('missing authentication token'); + } + const payload = await this.verifyJwt( + token, + this.environmentService.getAccessTokenSecret(), + ); + + return payload.workspaceId; + } + async verifyLoginToken(loginToken: string): Promise { const loginTokenSecret = this.environmentService.getLoginTokenSecret(); diff --git a/server/src/integrations/environment/environment.service.ts b/server/src/integrations/environment/environment.service.ts index bd7f159f9..b19d9697f 100644 --- a/server/src/integrations/environment/environment.service.ts +++ b/server/src/integrations/environment/environment.service.ts @@ -50,10 +50,6 @@ export class EnvironmentService { return this.configService.get('FRONT_BASE_URL')!; } - getLocalServerUrl(): string { - return this.configService.get('LOCAL_SERVER_URL')!; - } - getAccessTokenSecret(): string { return this.configService.get('ACCESS_TOKEN_SECRET')!; } diff --git a/server/src/integrations/environment/environment.validation.ts b/server/src/integrations/environment/environment.validation.ts index a7113ac3d..0361a9099 100644 --- a/server/src/integrations/environment/environment.validation.ts +++ b/server/src/integrations/environment/environment.validation.ts @@ -60,10 +60,6 @@ export class EnvironmentVariables { @IsUrl({ require_tld: false }) FRONT_BASE_URL: string; - // Server internal URL - @IsUrl({ require_tld: false }) - LOCAL_SERVER_URL: string; - // Json Web Token @IsString() ACCESS_TOKEN_SECRET: string; diff --git a/server/src/workspace/workspace-query-builder/interfaces/record.interface.ts b/server/src/workspace/workspace-query-builder/interfaces/record.interface.ts index ac52140da..d152f9449 100644 --- a/server/src/workspace/workspace-query-builder/interfaces/record.interface.ts +++ b/server/src/workspace/workspace-query-builder/interfaces/record.interface.ts @@ -17,5 +17,5 @@ export enum OrderByDirection { } export type RecordOrderBy = { - [Property in keyof Record]: OrderByDirection; + [Property in keyof Record]?: OrderByDirection; };