960 api rest batch create not working (#12028)

- fix batch endpoint
- migrate batch endpoint to the rest api v2
- add new integration test for batch endpoints
This commit is contained in:
martmull
2025-05-14 14:44:04 +02:00
committed by GitHub
parent 0766e73fc4
commit a4c56bd7fb
9 changed files with 310 additions and 48 deletions

View File

@ -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));
}
}

View File

@ -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);

View File

@ -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<string, any>[] = [];
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,
});
}
}

View File

@ -228,12 +228,19 @@ export abstract class RestApiBaseHandler {
}: FormatResultParams<T>) {
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 {

View File

@ -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",
);
});
});

View File

@ -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] };
}

View File

@ -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);
}

View File

@ -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,

View File

@ -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');
});
});
});