4655 batch endpoints on the rest api (#5411)

- add POST rest/batch/<OBJECT> endpoint
- rearrange rest api code with Twenty quality standard
- unify REST API error format
- Added PATCH verb to update objects
- In openapi schema, we replaced PUT with PATCH verb to comply with REST
standard
- fix openApi schema to match the REST api

### Batch Create

![image](https://github.com/twentyhq/twenty/assets/29927851/fe8cd91d-7b35-477f-9077-3477b57b054c)

### Replace PUT by PATCH in open Api

![image](https://github.com/twentyhq/twenty/assets/29927851/9a95060d-0b21-4a04-a3fa-c53390897b5b)

### Error format unification

![image](https://github.com/twentyhq/twenty/assets/29927851/f47dfcef-a4f8-4f93-8504-22f82a8d8057)

![image](https://github.com/twentyhq/twenty/assets/29927851/d76a87e2-2bf6-4ed9-a142-71ad7c123beb)

![image](https://github.com/twentyhq/twenty/assets/29927851/6db59ad3-0ba7-4390-a02d-be15884e2516)
This commit is contained in:
martmull
2024-05-16 14:15:49 +02:00
committed by GitHub
parent ea5a7ba70e
commit fdf10f17e2
90 changed files with 1318 additions and 857 deletions

View File

@ -7,10 +7,14 @@ import { TokenService } from 'src/engine/core-modules/auth/services/token.servic
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
import {
computeBatchPath,
computeManyResultPath,
computeSingleResultPath,
} from 'src/engine/core-modules/open-api/utils/path.utils';
import { getErrorResponses } from 'src/engine/core-modules/open-api/utils/get-error-responses.utils';
import {
get400ErrorResponses,
get401ErrorResponses,
} from 'src/engine/core-modules/open-api/utils/get-error-responses.utils';
import {
computeMetadataSchemaComponents,
computeParameterComponents,
@ -21,8 +25,10 @@ import { computeWebhooks } from 'src/engine/core-modules/open-api/utils/computeW
import { capitalize } from 'src/utils/capitalize';
import {
getDeleteResponse200,
getManyResultResponse200,
getSingleResultSuccessResponse,
getFindManyResponse200,
getCreateOneResponse201,
getFindOneResponse200,
getUpdateOneResponse200,
} from 'src/engine/core-modules/open-api/utils/responses.utils';
import { getRequestBody } from 'src/engine/core-modules/open-api/utils/request-body.utils';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@ -60,6 +66,7 @@ export class OpenApiService {
}
schema.paths = objectMetadataItems.reduce((paths, item) => {
paths[`/${item.namePlural}`] = computeManyResultPath(item);
paths[`/batch/${item.namePlural}`] = computeBatchPath(item);
paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(item);
return paths;
@ -86,8 +93,8 @@ export class OpenApiService {
schemas: computeSchemaComponents(objectMetadataItems),
parameters: computeParameterComponents(),
responses: {
'400': getErrorResponses('Invalid request'),
'401': getErrorResponses('Unauthorized'),
'400': get400ErrorResponses(),
'401': get401ErrorResponses(),
},
};
@ -128,7 +135,7 @@ export class OpenApiService {
summary: `Find Many ${item.namePlural}`,
parameters: [{ $ref: '#/components/parameters/filter' }],
responses: {
'200': getManyResultResponse200(item),
'200': getFindManyResponse200(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -137,9 +144,9 @@ export class OpenApiService {
tags: [item.namePlural],
summary: `Create One ${item.nameSingular}`,
operationId: `createOne${capitalize(item.nameSingular)}`,
requestBody: getRequestBody(item),
requestBody: getRequestBody(capitalize(item.nameSingular)),
responses: {
'200': getSingleResultSuccessResponse(item),
'200': getCreateOneResponse201(item, true),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -151,7 +158,7 @@ export class OpenApiService {
summary: `Find One ${item.nameSingular}`,
parameters: [{ $ref: '#/components/parameters/idPath' }],
responses: {
'200': getSingleResultSuccessResponse(item),
'200': getFindOneResponse200(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -162,19 +169,19 @@ export class OpenApiService {
operationId: `deleteOne${capitalize(item.nameSingular)}`,
parameters: [{ $ref: '#/components/parameters/idPath' }],
responses: {
'200': getDeleteResponse200(item),
'200': getDeleteResponse200(item, true),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
},
put: {
patch: {
tags: [item.namePlural],
summary: `Update One ${item.namePlural}`,
operationId: `updateOne${capitalize(item.nameSingular)}`,
parameters: [{ $ref: '#/components/parameters/idPath' }],
requestBody: getRequestBody(item),
requestBody: getRequestBody(capitalize(item.nameSingular)),
responses: {
'200': getSingleResultSuccessResponse(item),
'200': getUpdateOneResponse200(item, true),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -189,8 +196,8 @@ export class OpenApiService {
schemas: computeMetadataSchemaComponents(metadata),
parameters: computeParameterComponents(),
responses: {
'400': getErrorResponses('Invalid request'),
'401': getErrorResponses('Unauthorized'),
'400': get400ErrorResponses(),
'401': get401ErrorResponses(),
},
};

View File

@ -40,6 +40,17 @@ describe('computeSchemaComponents', () => {
},
},
},
ObjectsName: {
description: 'A list of objectsName',
example: {
fieldNumber: '',
},
items: {
$ref: '#/components/schemas/ObjectName',
},
required: ['fieldNumber'],
type: 'array',
},
});
});
});

View File

@ -8,10 +8,10 @@ import {
computeLimitParameters,
computeOrderByParameters,
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/order-by-input.factory';
import { FilterComparators } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils';
import { Conjunctions } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils';
import { DEFAULT_CONJUNCTION } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils';
import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/order-by-input.factory';
import { FilterComparators } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils';
import { Conjunctions } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/filter-utils/parse-filter.utils';
import { DEFAULT_CONJUNCTION } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils';
describe('computeParameters', () => {
describe('computeLimit', () => {

View File

@ -105,6 +105,33 @@ const getRequiredFields = (item: ObjectMetadataEntity): string[] => {
}, [] as string[]);
};
const computeBatchSchemaComponent = (
item: ObjectMetadataEntity,
): OpenAPIV3_1.SchemaObject => {
const result = {
type: 'array',
description: `A list of ${item.namePlural}`,
items: { $ref: `#/components/schemas/${capitalize(item.nameSingular)}` },
example: [{}],
} as OpenAPIV3_1.SchemaObject;
const requiredFields = getRequiredFields(item);
if (requiredFields?.length) {
result.required = requiredFields;
result.example = requiredFields.reduce(
(example, requiredField) => {
example[requiredField] = '';
return example;
},
{} as Record<string, string>,
);
}
return result;
};
const computeSchemaComponent = (
item: ObjectMetadataEntity,
): OpenAPIV3_1.SchemaObject => {
@ -138,6 +165,7 @@ export const computeSchemaComponents = (
return objectMetadataItems.reduce(
(schemas, item) => {
schemas[capitalize(item.nameSingular)] = computeSchemaComponent(item);
schemas[capitalize(item.namePlural)] = computeBatchSchemaComponent(item);
return schemas;
},
@ -168,6 +196,7 @@ export const computeMetadataSchemaComponents = (
case 'object': {
schemas[`${capitalize(item.nameSingular)}`] = {
type: 'object',
description: `An object`,
properties: {
dataSourceId: { type: 'string' },
nameSingular: { type: 'string' },
@ -201,6 +230,15 @@ export const computeMetadataSchemaComponents = (
},
},
},
example: {},
};
schemas[`${capitalize(item.namePlural)}`] = {
type: 'array',
description: `A list of ${item.namePlural}`,
items: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
},
example: [{}],
};
return schemas;
@ -208,6 +246,7 @@ export const computeMetadataSchemaComponents = (
case 'field': {
schemas[`${capitalize(item.nameSingular)}`] = {
type: 'object',
description: `A field`,
properties: {
type: { type: 'string' },
name: { type: 'string' },
@ -259,6 +298,15 @@ export const computeMetadataSchemaComponents = (
defaultValue: { type: 'object' },
options: { type: 'object' },
},
example: {},
};
schemas[`${capitalize(item.namePlural)}`] = {
type: 'array',
description: `A list of ${item.namePlural}`,
items: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
},
example: [{}],
};
return schemas;
@ -266,6 +314,7 @@ export const computeMetadataSchemaComponents = (
case 'relation': {
schemas[`${capitalize(item.nameSingular)}`] = {
type: 'object',
description: 'A relation',
properties: {
relationType: { type: 'string' },
fromObjectMetadata: {
@ -293,6 +342,15 @@ export const computeMetadataSchemaComponents = (
fromFieldMetadataId: { type: 'string' },
toFieldMetadataId: { type: 'string' },
},
example: {},
};
schemas[`${capitalize(item.namePlural)}`] = {
type: 'array',
description: `A list of ${item.namePlural}`,
items: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
},
example: [{}],
};
}
}

View File

@ -1,17 +1,45 @@
import { OpenAPIV3_1 } from 'openapi-types';
export const getErrorResponses = (
description: string,
): OpenAPIV3_1.ResponseObject => {
export const get400ErrorResponses = (): OpenAPIV3_1.ResponseObject => {
return {
description,
description: 'Bad Request',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
statusCode: { type: 'number' },
message: { type: 'string' },
error: { type: 'string' },
},
example: {
statusCode: 400,
message: 'error message',
error: 'Bad Request',
},
},
},
},
};
};
export const get401ErrorResponses = (): OpenAPIV3_1.ResponseObject => {
return {
description: 'Unauthorized',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
statusCode: { type: 'number' },
message: { type: 'string' },
error: { type: 'string' },
},
example: {
statusCode: 401,
message: 'Token invalid.',
error: 'Unauthorized',
},
},
},
},

View File

@ -2,10 +2,10 @@ import { OpenAPIV3_1 } from 'openapi-types';
import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { FilterComparators } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils';
import { Conjunctions } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils';
import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/order-by-input.factory';
import { DEFAULT_CONJUNCTION } from 'src/engine/api/rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils';
import { FilterComparators } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils';
import { Conjunctions } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/filter-utils/parse-filter.utils';
import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/order-by-input.factory';
import { DEFAULT_CONJUNCTION } from 'src/engine/api/rest/rest-api-core-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils';
export const computeLimitParameters = (): OpenAPIV3_1.ParameterObject => {
return {

View File

@ -5,11 +5,33 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import {
getDeleteResponse200,
getJsonResponse,
getManyResultResponse200,
getSingleResultSuccessResponse,
getFindManyResponse200,
getCreateOneResponse201,
getCreateManyResponse201,
getFindOneResponse200,
getUpdateOneResponse200,
} from 'src/engine/core-modules/open-api/utils/responses.utils';
import { getRequestBody } from 'src/engine/core-modules/open-api/utils/request-body.utils';
export const computeBatchPath = (
item: ObjectMetadataEntity,
): OpenAPIV3_1.PathItemObject => {
return {
post: {
tags: [item.namePlural],
summary: `Create Many ${item.namePlural}`,
operationId: `createMany${capitalize(item.namePlural)}`,
parameters: [{ $ref: '#/components/parameters/depth' }],
requestBody: getRequestBody(capitalize(item.namePlural)),
responses: {
'201': getCreateManyResponse201(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
},
} as OpenAPIV3_1.PathItemObject;
};
export const computeManyResultPath = (
item: ObjectMetadataEntity,
): OpenAPIV3_1.PathItemObject => {
@ -27,7 +49,7 @@ export const computeManyResultPath = (
{ $ref: '#/components/parameters/lastCursor' },
],
responses: {
'200': getManyResultResponse200(item),
'200': getFindManyResponse200(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -37,9 +59,9 @@ export const computeManyResultPath = (
summary: `Create One ${item.nameSingular}`,
operationId: `createOne${capitalize(item.nameSingular)}`,
parameters: [{ $ref: '#/components/parameters/depth' }],
requestBody: getRequestBody(item),
requestBody: getRequestBody(capitalize(item.nameSingular)),
responses: {
'201': getSingleResultSuccessResponse(item),
'201': getCreateOneResponse201(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -61,7 +83,7 @@ export const computeSingleResultPath = (
{ $ref: '#/components/parameters/depth' },
],
responses: {
'200': getSingleResultSuccessResponse(item),
'200': getFindOneResponse200(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},
@ -77,7 +99,7 @@ export const computeSingleResultPath = (
'401': { $ref: '#/components/responses/401' },
},
},
put: {
patch: {
tags: [item.namePlural],
summary: `Update One ${item.namePlural}`,
operationId: `UpdateOne${capitalize(item.nameSingular)}`,
@ -85,9 +107,9 @@ export const computeSingleResultPath = (
{ $ref: '#/components/parameters/idPath' },
{ $ref: '#/components/parameters/depth' },
],
requestBody: getRequestBody(item),
requestBody: getRequestBody(capitalize(item.nameSingular)),
responses: {
'200': getSingleResultSuccessResponse(item),
'200': getUpdateOneResponse200(item),
'400': { $ref: '#/components/responses/400' },
'401': { $ref: '#/components/responses/401' },
},

View File

@ -1,16 +1,11 @@
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { capitalize } from 'src/utils/capitalize';
export const getRequestBody = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
) => {
export const getRequestBody = (name: string) => {
return {
description: 'body',
required: true,
content: {
'application/json': {
schema: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
$ref: `#/components/schemas/${name}`,
},
},
},

View File

@ -1,7 +1,7 @@
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { capitalize } from 'src/utils/capitalize';
export const getManyResultResponse200 = (
export const getFindManyResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
) => {
return {
@ -28,7 +28,8 @@ export const getManyResultResponse200 = (
example: {
data: {
[item.namePlural]: [
`${capitalize(item.nameSingular)}Object`,
`${capitalize(item.nameSingular)}Object1`,
`${capitalize(item.nameSingular)}Object2`,
'...',
],
},
@ -39,7 +40,7 @@ export const getManyResultResponse200 = (
};
};
export const getSingleResultSuccessResponse = (
export const getFindOneResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
) => {
return {
@ -58,14 +59,54 @@ export const getSingleResultSuccessResponse = (
},
},
},
example: {
data: {
[item.nameSingular]: `${capitalize(item.nameSingular)}Object`,
},
},
},
},
},
};
};
export const getDeleteResponse200 = (
export const getCreateOneResponse201 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
fromMetadata = false,
) => {
const one = fromMetadata ? 'One' : '';
return {
description: 'Successful operation',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'object',
properties: {
[`create${one}${capitalize(item.nameSingular)}`]: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
},
},
},
},
example: {
data: {
[`create${one}${capitalize(item.nameSingular)}`]: `${capitalize(
item.nameSingular,
)}Object`,
},
},
},
},
},
};
};
export const getCreateManyResponse201 = (
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
) => {
return {
description: 'Successful operation',
@ -77,7 +118,84 @@ export const getDeleteResponse200 = (
data: {
type: 'object',
properties: {
[item.nameSingular]: {
[`create${capitalize(item.namePlural)}`]: {
type: 'array',
items: {
$ref: `#/components/schemas/${capitalize(
item.nameSingular,
)}`,
},
},
},
},
},
example: {
data: {
[`create${capitalize(item.namePlural)}`]: [
`${capitalize(item.nameSingular)}Object1`,
`${capitalize(item.nameSingular)}Object2`,
'...',
],
},
},
},
},
},
};
};
export const getUpdateOneResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
fromMetadata = false,
) => {
const one = fromMetadata ? 'One' : '';
return {
description: 'Successful operation',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'object',
properties: {
[`update${one}${capitalize(item.nameSingular)}`]: {
$ref: `#/components/schemas/${capitalize(item.nameSingular)}`,
},
},
},
},
example: {
data: {
[`update${one}${capitalize(item.nameSingular)}`]: `${capitalize(
item.nameSingular,
)}Object`,
},
},
},
},
},
};
};
export const getDeleteResponse200 = (
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
fromMetadata = false,
) => {
const one = fromMetadata ? 'One' : '';
return {
description: 'Successful operation',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'object',
properties: {
[`delete${one}${capitalize(item.nameSingular)}`]: {
type: 'object',
properties: {
id: {
@ -89,6 +207,13 @@ export const getDeleteResponse200 = (
},
},
},
example: {
data: {
[`delete${one}${capitalize(item.nameSingular)}`]: {
id: 'ffe75ac3-9786-4846-b56f-640685c3631e',
},
},
},
},
},
},