diff --git a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core-batch.controller.ts b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core-batch.controller.ts deleted file mode 100644 index 1ffe60789..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core-batch.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common'; - -import { Request, Response } from 'express'; - -import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service'; -import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils'; -import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; -import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; - -@Controller('rest/batch/*') -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) -export class RestApiCoreBatchController { - constructor(private readonly restApiCoreService: RestApiCoreService) {} - - @Post() - async handleApiPost(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiCoreService.createMany(request); - - res.status(201).send(cleanGraphQLResponse(result.data)); - } -} diff --git a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts index 85215e2b1..6bf4d1270 100644 --- a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts +++ b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts @@ -20,7 +20,7 @@ import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-re import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -@Controller('rest/*') +@Controller('rest') @UseGuards(JwtAuthGuard, WorkspaceAuthGuard) @UseFilters(RestApiExceptionFilter) export class RestApiCoreController { @@ -29,37 +29,42 @@ export class RestApiCoreController { private readonly restApiCoreServiceV2: RestApiCoreServiceV2, ) {} + @Post('batch/*') + async handleApiPostBatch(@Req() request: Request, @Res() res: Response) { + const result = await this.restApiCoreServiceV2.createMany(request); + + res.status(201).send(result); + } + @Post('duplicates') - @UseFilters(RestApiExceptionFilter) async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreService.findDuplicates(request); res.status(200).send(cleanGraphQLResponse(result.data.data)); } - @Get() - async handleApiGet(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiCoreServiceV2.get(request); - - res.status(200).send(result); - } - - @Delete() - async handleApiDelete(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiCoreServiceV2.delete(request); - - res.status(200).send(result); - } - - @Post() + @Post('*') async handleApiPost(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.createOne(request); res.status(201).send(result); } - @Patch() - @UseFilters(RestApiExceptionFilter) + @Get('*') + async handleApiGet(@Req() request: Request, @Res() res: Response) { + const result = await this.restApiCoreServiceV2.get(request); + + res.status(200).send(result); + } + + @Delete('*') + async handleApiDelete(@Req() request: Request, @Res() res: Response) { + const result = await this.restApiCoreServiceV2.delete(request); + + res.status(200).send(result); + } + + @Patch('*') async handleApiPatch(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.update(request); @@ -67,9 +72,9 @@ export class RestApiCoreController { } // This endpoint is not documented in the OpenAPI schema. - // We keep it to avoid a breaking change since it initially used PUT instead of PATCH, - // and because the PUT verb is often used as a PATCH. - @Put() + // We keep it to avoid a breaking change since it initially used PUT instead + // of PATCH, and because the PUT verb is often used as a PATCH. + @Put('*') async handleApiPut(@Req() request: Request, @Res() res: Response) { const result = await this.restApiCoreServiceV2.update(request); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts new file mode 100644 index 000000000..9ce9671a9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts @@ -0,0 +1,74 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +@Injectable() +export class RestApiCreateManyHandler extends RestApiBaseHandler { + async handle(request: Request) { + const { objectMetadataNamePlural, objectMetadata, repository } = + await this.getRepositoryAndMetadataOrFail(request); + + const body = request.body; + + if (!Array.isArray(body)) { + throw new BadRequestException('Body must be an array'); + } + + if (body.length === 0) { + throw new BadRequestException('Input must not be empty'); + } + + const overriddenRecordsToCreate: Record[] = []; + + for (const recordToCreate of body) { + const overriddenBody = await this.recordInputTransformerService.process({ + recordInput: recordToCreate, + objectMetadataMapItem: objectMetadata.objectMetadataMapItem, + }); + + const recordExists = + isDefined(overriddenBody.id) && + (await repository.exists({ + where: { + id: overriddenBody.id, + }, + })); + + if (recordExists) { + throw new BadRequestException('Record already exists'); + } + + overriddenRecordsToCreate.push(overriddenBody); + } + + const createdRecords = await repository.save(overriddenRecordsToCreate); + + this.apiEventEmitterService.emitCreateEvents( + createdRecords, + this.getAuthContextFromRequest(request), + objectMetadata.objectMetadataMapItem, + ); + + const records = await this.getRecord({ + recordIds: createdRecords.map((record) => record.id), + repository, + objectMetadata, + depth: this.depthInputFactory.create(request), + }); + + if (records.length !== body.length) { + throw new Error( + `Error when creating records. ${body.length - records.length} records are missing after creation.`, + ); + } + + return this.formatResult({ + operation: 'create', + objectNamePlural: objectMetadataNamePlural, + data: records, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts index 941abbb7d..34cd315e9 100644 --- a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts @@ -228,12 +228,19 @@ export abstract class RestApiBaseHandler { }: FormatResultParams) { let prefix: string; + if (isDefined(objectNameSingular) && isDefined(objectNamePlural)) { + throw new Error( + 'Cannot define both objectNameSingular and objectNamePlural', + ); + } + if (operation === 'findOne') { prefix = objectNameSingular || ''; } else if (operation === 'findMany') { prefix = objectNamePlural || ''; } else { - prefix = operation + capitalize(objectNameSingular || ''); + prefix = + operation + capitalize(objectNameSingular || objectNamePlural || ''); } return { diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/__tests__/parse-core-path.utils.spec.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/__tests__/parse-core-path.utils.spec.ts index c5c943416..9d6313beb 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/__tests__/parse-core-path.utils.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/__tests__/parse-core-path.utils.spec.ts @@ -23,7 +23,24 @@ describe('parseCorePath', () => { const request: any = { path: '/rest/companies/uuid/toto' }; expect(() => parseCorePath(request)).toThrow( - "Query path '/rest/companies/uuid/toto' invalid. Valid examples: /rest/companies/id or /rest/companies", + "Query path '/rest/companies/uuid/toto' invalid. Valid examples: /rest/companies/id or /rest/companies or /rest/batch/companies", + ); + }); + + it('should parse object from batch request', () => { + const request: any = { path: '/rest/batch/companies' }; + + expect(parseCorePath(request)).toEqual({ + object: 'companies', + id: undefined, + }); + }); + + it('should throw for wrong batch request', () => { + const request: any = { path: '/rest/batch/companies/uuid' }; + + expect(() => parseCorePath(request)).toThrow( + "Query path '/rest/batch/companies/uuid' invalid. Valid examples: /rest/companies/id or /rest/companies or /rest/batch/companies", ); }); }); diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts index ff0f7b01a..e93cb0d15 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts @@ -9,10 +9,14 @@ export const parseCorePath = ( if (queryAction.length > 2) { throw new BadRequestException( - `Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies`, + `Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies or /rest/batch/companies`, ); } + if (queryAction.length === 2 && queryAction[0] === 'batch') { + return { object: queryAction[1] }; + } + if (queryAction.length === 1) { return { object: queryAction[0] }; } diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts index 403197775..80feb32ba 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core-v2.service.ts @@ -9,12 +9,14 @@ import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest- import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; import { RestApiGetOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-one.handler'; import { RestApiGetManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-many.handler'; +import { RestApiCreateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-many.handler'; @Injectable() export class RestApiCoreServiceV2 { constructor( private readonly restApiDeleteOneHandler: RestApiDeleteOneHandler, private readonly restApiCreateOneHandler: RestApiCreateOneHandler, + private readonly restApiCreateManyHandler: RestApiCreateManyHandler, private readonly restApiUpdateOneHandler: RestApiUpdateOneHandler, private readonly restApiGetOneHandler: RestApiGetOneHandler, private readonly restApiGetManyHandler: RestApiGetManyHandler, @@ -28,6 +30,10 @@ export class RestApiCoreServiceV2 { return await this.restApiCreateOneHandler.handle(request); } + async createMany(request: Request) { + return await this.restApiCreateManyHandler.handle(request); + } + async update(request: Request) { return await this.restApiUpdateOneHandler.handle(request); } diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts index 262858f52..d079ec972 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts @@ -13,16 +13,17 @@ import { RecordTransformerModule } from 'src/engine/core-modules/record-transfor import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller'; import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; -import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller'; import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service'; import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service'; import { RestApiService } from 'src/engine/api/rest/rest-api.service'; import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { RestApiCreateManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-many.handler'; const restApiCoreResolvers = [ RestApiDeleteOneHandler, RestApiCreateOneHandler, + RestApiCreateManyHandler, RestApiUpdateOneHandler, RestApiGetOneHandler, RestApiGetManyHandler, @@ -38,7 +39,7 @@ const restApiCoreResolvers = [ RecordTransformerModule, WorkspacePermissionsCacheModule, ], - controllers: [RestApiCoreController, RestApiCoreBatchController], + controllers: [RestApiCoreController], providers: [ RestApiService, RestApiCoreService, diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-many.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-many.integration-spec.ts new file mode 100644 index 000000000..f257bc9ca --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-create-many.integration-spec.ts @@ -0,0 +1,169 @@ +import { + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, +} from 'test/integration/constants/test-person-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; +import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; + +describe('Core REST API Create Many endpoint', () => { + beforeEach(async () => { + await deleteAllRecords('person'); + await makeRestAPIRequest({ + method: 'post', + path: '/companies', + body: { + id: TEST_COMPANY_1_ID, + domainName: { + primaryLinkUrl: TEST_PRIMARY_LINK_URL, + }, + }, + }); + }); + + it('should create many person', async () => { + const requestBody = [ + { + id: TEST_PERSON_1_ID, + }, + { + id: TEST_PERSON_2_ID, + }, + ]; + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const createdPeople = res.body.data.createPeople; + + expect(createdPeople.length).toBe(2); + expect(createdPeople[0].id).toBe(TEST_PERSON_1_ID); + expect(createdPeople[1].id).toBe(TEST_PERSON_2_ID); + }); + }); + + it('should support depth 0 parameter', async () => { + const requestBody = [ + { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + { + id: TEST_PERSON_2_ID, + companyId: TEST_COMPANY_1_ID, + }, + ]; + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people?depth=0`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const [createdPerson1, createdPerson2] = res.body.data.createPeople; + + expect(createdPerson1.companyId).toBeDefined(); + expect(createdPerson1.company).not.toBeDefined(); + expect(createdPerson2.companyId).toBeDefined(); + expect(createdPerson2.company).not.toBeDefined(); + }); + }); + + it('should support depth 1 parameter', async () => { + const requestBody = [ + { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + { + id: TEST_PERSON_2_ID, + companyId: TEST_COMPANY_1_ID, + }, + ]; + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people?depth=1`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const [createdPerson1, createdPerson2] = res.body.data.createPeople; + + expect(createdPerson1.company).toBeDefined(); + expect(createdPerson1.company.people).not.toBeDefined(); + expect(createdPerson2.company).toBeDefined(); + expect(createdPerson2.company.people).not.toBeDefined(); + }); + }); + + it('should support depth 2 parameter', async () => { + const requestBody = [ + { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + { + id: TEST_PERSON_2_ID, + companyId: TEST_COMPANY_1_ID, + }, + ]; + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people?depth=2`, + body: requestBody, + }) + .expect(201) + .expect((res) => { + const [createdPerson1, createdPerson2] = res.body.data.createPeople; + + expect(createdPerson1.company.people).toBeDefined(); + expect(createdPerson2.company.people).toBeDefined(); + + const depth2Person1 = createdPerson1.company.people.find( + (p) => p.id === createdPerson1.id, + ); + const depth2Person2 = createdPerson2.company.people.find( + (p) => p.id === createdPerson2.id, + ); + + expect(depth2Person1).toBeDefined(); + expect(depth2Person2).toBeDefined(); + }); + }); + + it('should return a BadRequestException when trying to create a person with an existing ID', async () => { + const requestBody = [ + { + id: TEST_PERSON_1_ID, + }, + { + id: TEST_PERSON_2_ID, + }, + ]; + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people`, + body: requestBody, + }); + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people`, + body: requestBody, + }) + .expect(400) + .expect((res) => { + expect(res.body.messages[0]).toContain(`Record already exists`); + expect(res.body.error).toBe('BadRequestException'); + }); + }); +});