diff --git a/server/.env.example b/server/.env.example index 1aecc9b6b..1bffca98f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -4,6 +4,7 @@ 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 @@ -28,4 +29,4 @@ SIGN_IN_PREFILLED=true # LOG_LEVEL=error,warn # MESSAGE_QUEUE_TYPE=pg-boss # REDIS_HOST=127.0.0.1 -# REDIS_PORT=6379 \ No newline at end of file +# REDIS_PORT=6379 diff --git a/server/src/core/api-rest/api-rest.controller.ts b/server/src/core/api-rest/api-rest.controller.ts new file mode 100644 index 000000000..019c993ff --- /dev/null +++ b/server/src/core/api-rest/api-rest.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Delete, Get, Post, Put, Req } from '@nestjs/common'; + +import { Request } from 'express'; + +import { ApiRestService } from 'src/core/api-rest/api-rest.service'; + +@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; + } + + @Delete() + async handleApiDelete(@Req() request: Request): Promise { + const result = await this.apiRestService.delete(request); + return result.data; + } + + @Post() + async handleApiPost(@Req() request: Request): Promise { + const result = await this.apiRestService.create(request); + return result.data; + } + + @Put() + async handleApiPut(@Req() request: Request): Promise { + const result = await this.apiRestService.update(request); + return result.data; + } +} diff --git a/server/src/core/api-rest/api-rest.module.ts b/server/src/core/api-rest/api-rest.module.ts new file mode 100644 index 000000000..108726c05 --- /dev/null +++ b/server/src/core/api-rest/api-rest.module.ts @@ -0,0 +1,12 @@ +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'; + +@Module({ + imports: [ObjectMetadataModule], + controllers: [ApiRestController], + providers: [ApiRestService], +}) +export class ApiRestModule {} diff --git a/server/src/core/api-rest/api-rest.service.spec.ts b/server/src/core/api-rest/api-rest.service.spec.ts new file mode 100644 index 000000000..013977d12 --- /dev/null +++ b/server/src/core/api-rest/api-rest.service.spec.ts @@ -0,0 +1,148 @@ +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'; + +describe('ApiRestService', () => { + let service: ApiRestService; + const objectMetadataItem = { fields: [{ name: 'field', type: 'NUMBER' }] }; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiRestService, + { + provide: ObjectMetadataService, + useValue: {}, + }, + { + provide: EnvironmentService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(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 new file mode 100644 index 000000000..5f3da6311 --- /dev/null +++ b/server/src/core/api-rest/api-rest.service.ts @@ -0,0 +1,663 @@ +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; + +@Injectable() +export class ApiRestService { + constructor( + private readonly objectMetadataService: ObjectMetadataService, + private readonly environmentService: EnvironmentService, + ) {} + + 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) { + return await axios.post( + `${this.environmentService.getLocalServerUrl()}/graphql`, + data, + { + headers: { + authorization: request.headers.authorization, + }, + }, + ); + } + + async get(request: Request) { + 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, + ), + }; + return await this.callGraphql(request, data); + } catch (err) { + return { data: { error: `${err}` } }; + } + } + + async delete(request: Request) { + 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], + }, + }; + return await this.callGraphql(request, data); + } catch (err) { + return { data: { error: `${err}` } }; + } + } + + async create(request: Request) { + 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, + }, + }; + return await this.callGraphql(request, data); + } catch (err) { + return { data: { error: `${err}` } }; + } + } + + async update(request: Request) { + 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, + }, + }; + return await this.callGraphql(request, data); + } catch (err) { + return { data: { error: `${err}` } }; + } + } +} diff --git a/server/src/core/core.module.ts b/server/src/core/core.module.ts index 2d4ab9d36..5e82543bb 100644 --- a/server/src/core/core.module.ts +++ b/server/src/core/core.module.ts @@ -4,6 +4,7 @@ import { WorkspaceModule } from 'src/core/workspace/workspace.module'; import { UserModule } from 'src/core/user/user.module'; import { RefreshTokenModule } from 'src/core/refresh-token/refresh-token.module'; import { AuthModule } from 'src/core/auth/auth.module'; +import { ApiRestModule } from 'src/core/api-rest/api-rest.module'; import { FeatureFlagModule } from 'src/core/feature-flag/feature-flag.module'; import { AnalyticsModule } from './analytics/analytics.module'; @@ -19,6 +20,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; AnalyticsModule, FileModule, ClientConfigModule, + ApiRestModule, FeatureFlagModule, ], exports: [ diff --git a/server/src/integrations/environment/environment.service.ts b/server/src/integrations/environment/environment.service.ts index 03dde282f..127eeed4e 100644 --- a/server/src/integrations/environment/environment.service.ts +++ b/server/src/integrations/environment/environment.service.ts @@ -50,6 +50,10 @@ 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 331b5c7a7..18ca753b9 100644 --- a/server/src/integrations/environment/environment.validation.ts +++ b/server/src/integrations/environment/environment.validation.ts @@ -59,6 +59,10 @@ 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/utils/capitalize.ts b/server/src/utils/capitalize.ts new file mode 100644 index 000000000..2b68174ee --- /dev/null +++ b/server/src/utils/capitalize.ts @@ -0,0 +1,3 @@ +export const capitalize = (string: string) => { + return string.charAt(0).toUpperCase() + string.slice(1); +};