From 9d9ba97fb77d42a4c93c6724889fd6b2166603c3 Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Tue, 20 Feb 2024 14:17:41 +0000 Subject: [PATCH] feat: REST endpoints for metadata API (#3912) * parse metadata path * metadata rest api * add queryAction condition and return object singular/plural * handle GET endpoint for metadata * FindOne and FindMany query for metadata endpoint * Request all objects and nest fields in object request --------- Co-authored-by: martmull --- .../utils/parse-path.utils.ts | 56 +++ .../src/core/api-rest/api-rest.module.ts | 6 +- .../core/api-rest/metadata-rest.controller.ts | 31 ++ .../core/api-rest/metadata-rest.service.ts | 323 ++++++++++++++++++ 4 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-server/src/core/api-rest/metadata-rest.controller.ts create mode 100644 packages/twenty-server/src/core/api-rest/metadata-rest.service.ts diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts index a2e114369..b6a307e1d 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/parse-path.utils.ts @@ -19,3 +19,59 @@ export const parsePath = ( return { object: queryAction[0], id: queryAction[1] }; }; + +export const parseMetadataPath = ( + request: Request, +): { objectNameSingular: string; objectNamePlural: string; id?: string } => { + const queryAction = request.path.replace('/rest/metadata/', '').split('/'); + + if (queryAction.length > 3 || queryAction.length === 0) { + throw new BadRequestException( + `Query path '${request.path}' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id`, + ); + } + + if (!['fields', 'objects', 'relations'].includes(queryAction[0])) { + throw new BadRequestException( + `Query path '${request.path}' invalid. Metadata path "${queryAction[0]}" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects or /rest/metadata/relations`, + ); + } + + const objectName = queryAction[0]; + + if (queryAction.length === 2) { + switch (objectName) { + case 'fields': + return { + objectNameSingular: 'field', + objectNamePlural: objectName, + id: queryAction[1], + }; + case 'objects': + return { + objectNameSingular: 'object', + objectNamePlural: objectName, + id: queryAction[1], + }; + case 'relations': + return { + objectNameSingular: 'relation', + objectNamePlural: objectName, + id: queryAction[1], + }; + default: + return { objectNameSingular: '', objectNamePlural: '', id: '' }; + } + } else { + switch (objectName) { + case 'fields': + return { objectNameSingular: 'field', objectNamePlural: objectName }; + case 'objects': + return { objectNameSingular: 'object', objectNamePlural: objectName }; + case 'relations': + return { objectNameSingular: 'relation', objectNamePlural: objectName }; + default: + return { objectNameSingular: '', objectNamePlural: '' }; + } + } +}; diff --git a/packages/twenty-server/src/core/api-rest/api-rest.module.ts b/packages/twenty-server/src/core/api-rest/api-rest.module.ts index ac1f51040..bfa91e0eb 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest.module.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest.module.ts @@ -5,10 +5,12 @@ import { ApiRestController } from 'src/core/api-rest/api-rest.controller'; import { ApiRestService } from 'src/core/api-rest/api-rest.service'; import { ApiRestQueryBuilderModule } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.module'; import { AuthModule } from 'src/core/auth/auth.module'; +import { ApiRestMetadataController } from 'src/core/api-rest/metadata-rest.controller'; +import { ApiRestMetadataService } from 'src/core/api-rest/metadata-rest.service'; @Module({ imports: [ApiRestQueryBuilderModule, AuthModule, HttpModule], - controllers: [ApiRestController], - providers: [ApiRestService], + controllers: [ApiRestMetadataController, ApiRestController], + providers: [ApiRestMetadataService, ApiRestService], }) export class ApiRestModule {} diff --git a/packages/twenty-server/src/core/api-rest/metadata-rest.controller.ts b/packages/twenty-server/src/core/api-rest/metadata-rest.controller.ts new file mode 100644 index 000000000..2573a6b3a --- /dev/null +++ b/packages/twenty-server/src/core/api-rest/metadata-rest.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Delete, Post, Put, Req, Res } from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { handleResult } from 'src/core/api-rest/api-rest.controller.utils'; +import { ApiRestMetadataService } from 'src/core/api-rest/metadata-rest.service'; + +@Controller('rest/metadata/*') +export class ApiRestMetadataController { + constructor(private readonly apiRestService: ApiRestMetadataService) {} + + @Get() + async handleApiGet(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.get(request)); + } + + @Delete() + async handleApiDelete(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.delete(request)); + } + + @Post() + async handleApiPost(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.create(request)); + } + + @Put() + async handleApiPut(@Req() request: Request, @Res() res: Response) { + handleResult(res, await this.apiRestService.update(request)); + } +} diff --git a/packages/twenty-server/src/core/api-rest/metadata-rest.service.ts b/packages/twenty-server/src/core/api-rest/metadata-rest.service.ts new file mode 100644 index 000000000..340889e61 --- /dev/null +++ b/packages/twenty-server/src/core/api-rest/metadata-rest.service.ts @@ -0,0 +1,323 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; + +import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { ApiRestQueryBuilderFactory } from 'src/core/api-rest/api-rest-query-builder/api-rest-query-builder.factory'; +import { ApiRestQuery } from 'src/core/api-rest/types/api-rest-query.type'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { parseMetadataPath } from 'src/core/api-rest/api-rest-query-builder/utils/parse-path.utils'; +import { capitalize } from 'src/utils/capitalize'; + +@Injectable() +export class ApiRestMetadataService { + constructor( + private readonly tokenService: TokenService, + private readonly environmentService: EnvironmentService, + private readonly apiRestQueryBuilderFactory: ApiRestQueryBuilderFactory, + private readonly httpService: HttpService, + ) {} + + async callMetadata(request, data: ApiRestQuery) { + const baseUrl = + this.environmentService.getServerUrl() || + `${request.protocol}://${request.get('host')}`; + + try { + return await this.httpService.axiosRef.post(`${baseUrl}/metadata`, data, { + headers: { + 'Content-Type': 'application/json', + Authorization: request.headers.authorization, + }, + }); + } catch (err) { + return { + data: { + error: `${err}. Please check your query.`, + status: err.response.status, + }, + }; + } + } + + async fetchMetadataInputFields(request, fieldName: string) { + const query = ` + query { + __type(name: "${fieldName}") { + inputFields { name } + } + } + `; + const data: ApiRestQuery = { + query, + variables: {}, + }; + + const { data: response } = await this.callMetadata(request, data); + const fields = response.data.__type.inputFields.map((field) => field.name); + + return fields; + } + + fetchMetadataFields(objectNamePlural: string) { + const fields = ` + type + name + label + description + icon + isCustom + isActive + isSystem + isNullable + createdAt + updatedAt + fromRelationMetadata { + id + relationType + toObjectMetadata { + id + dataSourceId + nameSingular + namePlural + isSystem + } + toFieldMetadataId + } + toRelationMetadata { + id + relationType + fromObjectMetadata { + id + dataSourceId + nameSingular + namePlural + isSystem + } + fromFieldMetadataId + } + defaultValue + options + `; + + switch (objectNamePlural) { + case 'objects': + return ` + dataSourceId + nameSingular + namePlural + labelSingular + labelPlural + description + icon + isCustom + isActive + isSystem + createdAt + updatedAt + labelIdentifierFieldMetadataId + imageIdentifierFieldMetadataId + fields(paging: { first: 1000 }) { + edges { + node { + id + ${fields} + } + } + } + `; + case 'fields': + return fields; + case 'relations': + return ` + relationType + fromObjectMetadata { + id + dataSourceId + nameSingular + namePlural + isSystem + } + fromObjectMetadataId + toObjectMetadata { + id + dataSourceId + nameSingular + namePlural + isSystem + } + toObjectMetadataId + fromFieldMetadataId + toFieldMetadataId + `; + } + } + + generateFindManyQuery(objectNameSingular: string, objectNamePlural: string) { + const fields = this.fetchMetadataFields(objectNamePlural); + + return ` + query FindMany${capitalize(objectNamePlural)}( + $filter: ${objectNameSingular}Filter, + ) { + ${objectNamePlural}( + filter: $filter, + paging: { first: 1000 } + ) { + edges { + node { + id + ${fields} + } + } + } + } + `; + } + + generateFindOneQuery(objectNameSingular: string, objectNamePlural: string) { + const fields = this.fetchMetadataFields(objectNamePlural); + + return ` + query FindOne${capitalize(objectNameSingular)}( + $id: ID!, + ) { + ${objectNameSingular}(id: $id) { + id + ${fields} + } + } + `; + } + + async get(request) { + try { + await this.tokenService.validateToken(request); + + const { objectNameSingular, objectNamePlural, id } = + parseMetadataPath(request); + + const query = id + ? this.generateFindOneQuery(objectNameSingular, objectNamePlural) + : this.generateFindManyQuery(objectNameSingular, objectNamePlural); + + const data: ApiRestQuery = { + query, + variables: id ? { id } : request.body, + }; + + return await this.callMetadata(request, data); + } catch (err) { + return { data: { error: err, status: err.status } }; + } + } + + async create(request) { + try { + await this.tokenService.validateToken(request); + + const { objectNameSingular: objectName } = parseMetadataPath(request); + const objectNameCapitalized = capitalize(objectName); + + const fieldName = `Create${objectNameCapitalized}Input`; + const fields = await this.fetchMetadataInputFields(request, fieldName); + + const query = ` + mutation Create${objectNameCapitalized}($input: CreateOne${objectNameCapitalized}Input!) { + createOne${objectNameCapitalized}(input: $input) { + id + ${fields.map((field) => field).join('\n')} + } + } + `; + + const data: ApiRestQuery = { + query, + variables: { + input: { + [objectName]: request.body, + }, + }, + }; + + return await this.callMetadata(request, data); + } catch (err) { + return { data: { error: err, status: err.status } }; + } + } + + async update(request) { + try { + await this.tokenService.validateToken(request); + + const { objectNameSingular: objectName, id } = parseMetadataPath(request); + const objectNameCapitalized = capitalize(objectName); + + if (!id) { + throw new BadRequestException( + `update ${objectName} query invalid. Id missing. eg: /rest/metadata/${objectName}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, + ); + } + const fieldName = `Update${objectNameCapitalized}Input`; + const fields = await this.fetchMetadataInputFields(request, fieldName); + + const query = ` + mutation Update${objectNameCapitalized}($input: UpdateOne${objectNameCapitalized}Input!) { + updateOne${objectNameCapitalized}(input: $input) { + id + ${fields.map((field) => field).join('\n')} + } + } + `; + + const data: ApiRestQuery = { + query, + variables: { + input: { + update: request.body, + id, + }, + }, + }; + + return await this.callMetadata(request, data); + } catch (err) { + return { data: { error: err, status: err.status } }; + } + } + + async delete(request) { + try { + await this.tokenService.validateToken(request); + + const { objectNameSingular: objectName, id } = parseMetadataPath(request); + const objectNameCapitalized = capitalize(objectName); + + if (!id) { + throw new BadRequestException( + `delete ${objectName} query invalid. Id missing. eg: /rest/metadata/${objectName}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, + ); + } + + const query = ` + mutation Delete${objectNameCapitalized}($input: DeleteOne${objectNameCapitalized}Input!) { + deleteOne${objectNameCapitalized}(input: $input) { + id + } + } + `; + + const data: ApiRestQuery = { + query, + variables: { + input: { + id, + }, + }, + }; + + return await this.callMetadata(request, data); + } catch (err) { + return { data: { error: err, status: err.status } }; + } + } +}