diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index b6c5fd1e3b..491b3ca2e7 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -88,6 +88,7 @@ "lodash.upperfirst": "^4.3.1", "microdiff": "^1.3.2", "nest-commander": "^3.12.0", + "openapi-types": "^12.1.3", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts index 1480251535..d468878347 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/filter-input.factory.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; +import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item'; import { FilterInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-input.factory'; describe('FilterInputFactory', () => { diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts index e97237ca4a..c8e07b7b7b 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/__tests__/order-by-input.factory.spec.ts @@ -2,8 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { OrderByDirection } from 'src/workspace/workspace-query-builder/interfaces/record.interface'; +import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item'; import { OrderByInputFactory } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory'; -import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; describe('OrderByInputFactory', () => { const objectMetadata = { objectMetadataItem: objectMetadataItem }; diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts index a0d8d2737d..b0bbfef883 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/__tests__/parse-filter.utils.spec.ts @@ -1,4 +1,4 @@ -import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; +import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item'; import { parseFilter } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils'; describe('parseFilter', () => { diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts index af2591719f..d1197ad485 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils.ts @@ -1,6 +1,10 @@ +import { Conjunctions } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils'; + +export const DEFAULT_CONJUNCTION = Conjunctions.and; + export const addDefaultConjunctionIfMissing = (filterQuery: string): string => { if (!(filterQuery.includes('(') && filterQuery.includes(')'))) { - return `and(${filterQuery})`; + return `${DEFAULT_CONJUNCTION}(${filterQuery})`; } return filterQuery; diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts index db4fb4a22b..5a11d1a200 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; -enum FilterComparators { +export enum FilterComparators { eq = 'eq', neq = 'neq', in = 'in', diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts index 6b3b45e554..34ac831aa6 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils.ts @@ -9,7 +9,7 @@ import { import { formatFieldValue } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/format-field-values.utils'; import { FieldValue } from 'src/core/api-rest/types/api-rest-field-value.type'; -enum Conjunctions { +export enum Conjunctions { or = 'or', and = 'and', not = 'not', diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts index 893bfb6125..2fb18068ac 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory.ts @@ -9,7 +9,7 @@ import { import { checkFields } from 'src/core/api-rest/api-rest-query-builder/utils/fields.utils'; -const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst; +export const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst; @Injectable() export class OrderByInputFactory { diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts index 27ac2718d5..37f344c4d4 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/fields.utils.spec.ts @@ -1,8 +1,8 @@ +import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item'; import { checkFields, getFieldType, } from 'src/core/api-rest/api-rest-query-builder/utils/fields.utils'; -import { objectMetadataItem } from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; describe('FieldUtils', () => { diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts index ae1e2de463..cdd88db4c1 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts @@ -1,11 +1,11 @@ -import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; import { fieldCurrency, fieldLink, fieldNumber, fieldString, objectMetadataItem, -} from 'src/core/api-rest/api-rest-query-builder/utils/__tests__/utils'; +} from 'src/utils/utils-test/object-metadata-item'; +import { mapFieldMetadataToGraphqlQuery } from 'src/core/api-rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils'; describe('mapFieldMetadataToGraphqlQuery', () => { it('should map properly', () => { diff --git a/packages/twenty-server/src/core/api-rest/api-rest.service.ts b/packages/twenty-server/src/core/api-rest/api-rest.service.ts index c1767b05fa..48f5811195 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest.service.ts +++ b/packages/twenty-server/src/core/api-rest/api-rest.service.ts @@ -28,14 +28,15 @@ export class ApiRestService { try { return await axios.post(`${baseUrl}/graphql`, data, { headers: { + 'Content-Type': 'application/json', Authorization: request.headers.authorization, }, }); } catch (err) { return { data: { - error: `AxiosError: please double check your query and your API key (to generate a new one, see here: ${this.environmentService.getFrontBaseUrl()}/settings/developers/api-keys)`, - status: 400, + error: `${err}. Please check your query.`, + status: err.response.status, }, }; } diff --git a/packages/twenty-server/src/core/auth/auth.resolver.spec.ts b/packages/twenty-server/src/core/auth/auth.resolver.spec.ts index 5ab0859030..6472cd1f0e 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Workspace } from 'src/core/workspace/workspace.entity'; +import { UserService } from 'src/core/user/services/user.service'; import { AuthResolver } from './auth.resolver'; @@ -27,6 +28,10 @@ describe('AuthResolver', () => { provide: TokenService, useValue: {}, }, + { + provide: UserService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/core/core.module.ts b/packages/twenty-server/src/core/core.module.ts index 5e82543bbc..9c225defe4 100644 --- a/packages/twenty-server/src/core/core.module.ts +++ b/packages/twenty-server/src/core/core.module.ts @@ -6,6 +6,7 @@ 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 { OpenApiModule } from 'src/core/open-api/open-api.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { FileModule } from './file/file.module'; @@ -21,6 +22,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; FileModule, ClientConfigModule, ApiRestModule, + OpenApiModule, FeatureFlagModule, ], exports: [ diff --git a/packages/twenty-server/src/core/open-api/open-api.controller.ts b/packages/twenty-server/src/core/open-api/open-api.controller.ts new file mode 100644 index 0000000000..60b485f4a9 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/open-api.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Req, Res } from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { OpenApiService } from 'src/core/open-api/open-api.service'; + +@Controller('open-api') +export class OpenApiController { + constructor(private readonly openApiService: OpenApiService) {} + + @Get() + async generateOpenApiSchema(@Req() request: Request, @Res() res: Response) { + const data = await this.openApiService.generateSchema(request); + + res.send(data); + } +} diff --git a/packages/twenty-server/src/core/open-api/open-api.module.ts b/packages/twenty-server/src/core/open-api/open-api.module.ts new file mode 100644 index 0000000000..eb9425e65e --- /dev/null +++ b/packages/twenty-server/src/core/open-api/open-api.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { OpenApiController } from 'src/core/open-api/open-api.controller'; +import { OpenApiService } from 'src/core/open-api/open-api.service'; +import { AuthModule } from 'src/core/auth/auth.module'; +import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module'; + +@Module({ + imports: [ObjectMetadataModule, AuthModule], + controllers: [OpenApiController], + providers: [OpenApiService], +}) +export class OpenApiModule {} diff --git a/packages/twenty-server/src/core/open-api/open-api.service.spec.ts b/packages/twenty-server/src/core/open-api/open-api.service.spec.ts new file mode 100644 index 0000000000..4ea1dc8634 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/open-api.service.spec.ts @@ -0,0 +1,35 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { OpenApiService } from 'src/core/open-api/open-api.service'; +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { EnvironmentService } from "src/integrations/environment/environment.service"; + +describe('OpenApiService', () => { + let service: OpenApiService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OpenApiService, + { + provide: TokenService, + useValue: {}, + }, + { + provide: ObjectMetadataService, + useValue: {}, + }, + { + provide: EnvironmentService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(OpenApiService); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/twenty-server/src/core/open-api/open-api.service.ts b/packages/twenty-server/src/core/open-api/open-api.service.ts new file mode 100644 index 0000000000..4d3bfc0fb3 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/open-api.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; + +import { Request } from 'express'; +import { OpenAPIV3 } from 'openapi-types'; + +import { TokenService } from 'src/core/auth/services/token.service'; +import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { baseSchema } from 'src/core/open-api/utils/base-schema.utils'; +import { + computeManyResultPath, + computeSingleResultPath, +} from 'src/core/open-api/utils/path.utils'; +import { getErrorResponses } from 'src/core/open-api/utils/get-error-responses.utils'; +import { + computeParameterComponents, + computeSchemaComponents, +} from 'src/core/open-api/utils/components.utils'; +import { computeSchemaTags } from 'src/core/open-api/utils/compute-schema-tags.utils'; + +@Injectable() +export class OpenApiService { + constructor( + private readonly tokenService: TokenService, + private readonly objectMetadataService: ObjectMetadataService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateSchema(request: Request): Promise { + const schema = baseSchema(this.environmentService.getFrontBaseUrl()); + + let objectMetadataItems; + + try { + const workspace = await this.tokenService.validateToken(request); + + objectMetadataItems = + await this.objectMetadataService.findManyWithinWorkspace(workspace.id); + } catch (err) { + return schema; + } + + if (!objectMetadataItems.length) { + return schema; + } + schema.paths = objectMetadataItems.reduce((paths, item) => { + paths[`/rest/${item.namePlural}`] = computeManyResultPath(item); + paths[`/rest/${item.namePlural}/{id}`] = computeSingleResultPath(item); + + return paths; + }, schema.paths as OpenAPIV3.PathsObject); + + schema.tags = computeSchemaTags(objectMetadataItems); + + schema.components = { + ...schema.components, // components.securitySchemes is defined in base Schema + schemas: computeSchemaComponents(objectMetadataItems), + parameters: computeParameterComponents(), + responses: { + '400': getErrorResponses('Invalid request'), + '401': getErrorResponses('Unauthorized'), + }, + }; + + return schema; + } +} diff --git a/packages/twenty-server/src/core/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/core/open-api/utils/__tests__/components.utils.spec.ts new file mode 100644 index 0000000000..d6f9c4733f --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/__tests__/components.utils.spec.ts @@ -0,0 +1,39 @@ +import { computeSchemaComponents } from 'src/core/open-api/utils/components.utils'; +import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +describe('computeSchemaComponents', () => { + it('should compute schema components', () => { + expect( + computeSchemaComponents([objectMetadataItem] as ObjectMetadataEntity[]), + ).toEqual({ + ObjectName: { + type: 'object', + required: ['fieldNumber'], + example: { fieldNumber: '' }, + properties: { + fieldCurrency: { + properties: { + amountMicros: { type: 'string' }, + currencyCode: { type: 'string' }, + }, + type: 'object', + }, + fieldLink: { + properties: { + label: { type: 'string' }, + url: { type: 'string' }, + }, + type: 'object', + }, + fieldNumber: { + type: 'number', + }, + fieldString: { + type: 'string', + }, + }, + }, + }); + }); +}); diff --git a/packages/twenty-server/src/core/open-api/utils/__tests__/parameters.utils.spec.ts b/packages/twenty-server/src/core/open-api/utils/__tests__/parameters.utils.spec.ts new file mode 100644 index 0000000000..1b27b2a345 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/__tests__/parameters.utils.spec.ts @@ -0,0 +1,136 @@ +import { OrderByDirection } from 'src/workspace/workspace-query-builder/interfaces/record.interface'; + +import { + computeDepthParameters, + computeFilterParameters, + computeIdPathParameter, + computeLastCursorParameters, + computeLimitParameters, + computeOrderByParameters, +} from 'src/core/open-api/utils/parameters.utils'; +import { DEFAULT_ORDER_DIRECTION } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory'; +import { FilterComparators } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils'; +import { Conjunctions } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils'; +import { DEFAULT_CONJUNCTION } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils'; + +describe('computeParameters', () => { + describe('computeLimit', () => { + it('should compute limit', () => { + expect(computeLimitParameters()).toEqual({ + name: 'limit', + in: 'query', + description: 'Limits the number of objects returned.', + required: false, + schema: { + type: 'integer', + minimum: 0, + maximum: 60, + default: 60, + }, + }); + }); + }); + describe('computeOrderBy', () => { + it('should compute order by', () => { + expect(computeOrderByParameters()).toEqual({ + name: 'order_by', + in: 'query', + description: `Sorts objects returned. + Should have the following shape: **field_name_1,field_name_2[DIRECTION_2],...** + Available directions are **${Object.values(OrderByDirection).join( + '**, **', + )}**. + Default direction is **${DEFAULT_ORDER_DIRECTION}**`, + required: false, + schema: { + type: 'string', + }, + examples: { + simple: { + value: 'createdAt', + summary: 'A simple order_by param', + }, + complex: { + value: `id[${OrderByDirection.AscNullsFirst}],createdAt[${OrderByDirection.DescNullsLast}]`, + summary: 'A more complex order_by param', + }, + }, + }); + }); + }); + describe('computeDepth', () => { + it('should compute depth', () => { + expect(computeDepthParameters()).toEqual({ + name: 'depth', + in: 'query', + description: 'Limits the depth objects returned.', + required: false, + schema: { + type: 'integer', + enum: [1, 2], + }, + }); + }); + }); + describe('computeFilter', () => { + it('should compute filters', () => { + expect(computeFilterParameters()).toEqual({ + name: 'filter', + in: 'query', + description: `Filters objects returned. + Should have the following shape: **field_1[COMPARATOR]:value_1,field_2[COMPARATOR]:value_2,...** + Available comparators are **${Object.values(FilterComparators).join( + '**, **', + )}**. + You can create more complex filters using conjunctions **${Object.values( + Conjunctions, + ).join('**, **')}**. + Default root conjunction is **${DEFAULT_CONJUNCTION}**. + To filter **null** values use **field[is]:NULL** or **field[is]:NOT_NULL** + To filter using **boolean** values use **field[eq]:true** or **field[eq]:false**`, + required: false, + schema: { + type: 'string', + }, + examples: { + simple: { + value: 'createdAt[gte]:"2023-01-01"', + description: 'A simple filter param', + }, + complex: { + value: + 'or(createdAt[gte]:"2024-01-01",createdAt[lte]:"2023-01-01",not(id[is]:NULL))', + description: 'A more complex filter param', + }, + }, + }); + }); + }); + describe('computeLastCursor', () => { + it('should compute last cursor', () => { + expect(computeLastCursorParameters()).toEqual({ + name: 'last_cursor', + in: 'query', + description: 'Returns objects starting from a specific cursor.', + required: false, + schema: { + type: 'string', + }, + }); + }); + }); + describe('computeIdPathParameter', () => { + it('should compute id path param', () => { + expect(computeIdPathParameter()).toEqual({ + name: 'id', + in: 'path', + description: 'Object id.', + required: true, + schema: { + type: 'string', + format: 'uuid', + }, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/core/open-api/utils/base-schema.utils.ts b/packages/twenty-server/src/core/open-api/utils/base-schema.utils.ts new file mode 100644 index 0000000000..87de0ea363 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/base-schema.utils.ts @@ -0,0 +1,62 @@ +import { OpenAPIV3 } from 'openapi-types'; + +import { computeOpenApiPath } from 'src/core/open-api/utils/path.utils'; + +export const baseSchema = (frontBaseUrl: string): OpenAPIV3.Document => { + return { + openapi: '3.0.3', + info: { + title: 'Twenty Api', + description: `This is a **Twenty REST/API** playground based on the **OpenAPI 3.0 specification**.\n\nTo use the Playground, please log to your twenty account and generate an API key here: ${frontBaseUrl}/settings/developers/api-keys`, + termsOfService: 'https://github.com/twentyhq/twenty?tab=coc-ov-file', + contact: { + email: 'felix@twenty.com', + }, + license: { + name: 'AGPL-3.0', + url: 'https://github.com/twentyhq/twenty?tab=AGPL-3.0-1-ov-file#readme', + }, + version: '0.2.0', + }, + // Testing purposes + servers: [ + { + url: 'http://localhost:3000', + description: 'Local Development', + }, + { + url: 'https://api-main.twenty.com/', + description: 'Staging Development', + }, + { + url: 'https://api.twenty.com/', + description: 'Production Development', + }, + { + url: '/', + description: 'Current Host', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: + 'Enter the token with the `Bearer: ` prefix, e.g. "Bearer abcde12345".', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + externalDocs: { + description: 'Find out more about **Twenty**', + url: 'https://twenty.com', + }, + paths: { '/open-api': computeOpenApiPath() }, + }; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/components.utils.ts b/packages/twenty-server/src/core/open-api/utils/components.utils.ts new file mode 100644 index 0000000000..fc686963b5 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/components.utils.ts @@ -0,0 +1,140 @@ +import { OpenAPIV3 } from 'openapi-types'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { capitalize } from 'src/utils/capitalize'; +import { + computeDepthParameters, + computeFilterParameters, + computeIdPathParameter, + computeLastCursorParameters, + computeLimitParameters, + computeOrderByParameters, +} from 'src/core/open-api/utils/parameters.utils'; + +type Property = OpenAPIV3.SchemaObject; + +type Properties = { + [name: string]: Property; +}; + +const getSchemaComponentsProperties = ( + item: ObjectMetadataEntity, +): Properties => { + return item.fields.reduce((node, field) => { + let itemProperty = {} as Property; + + switch (field.type) { + case FieldMetadataType.UUID: + case FieldMetadataType.TEXT: + case FieldMetadataType.PHONE: + case FieldMetadataType.EMAIL: + case FieldMetadataType.DATE_TIME: + itemProperty.type = 'string'; + break; + case FieldMetadataType.NUMBER: + case FieldMetadataType.NUMERIC: + case FieldMetadataType.PROBABILITY: + case FieldMetadataType.RATING: + itemProperty.type = 'number'; + break; + case FieldMetadataType.BOOLEAN: + itemProperty.type = 'boolean'; + break; + case FieldMetadataType.RELATION: + itemProperty = { + type: 'array', + items: { + type: 'object', + properties: { + node: { + type: 'object', + }, + }, + }, + }; + break; + case FieldMetadataType.LINK: + case FieldMetadataType.CURRENCY: + case FieldMetadataType.FULL_NAME: + itemProperty = { + type: 'object', + properties: Object.keys(field.targetColumnMap).reduce( + (properties, key) => { + properties[key] = { type: 'string' }; + + return properties; + }, + {} as Properties, + ), + }; + break; + default: + itemProperty.type = 'string'; + break; + } + + node[field.name] = itemProperty; + + return node; + }, {} as Properties); +}; + +const getRequiredFields = (item: ObjectMetadataEntity): string[] => { + return item.fields.reduce((required, field) => { + if (!field.isNullable && field.defaultValue === null) { + required.push(field.name); + + return required; + } + + return required; + }, [] as string[]); +}; + +const computeSchemaComponent = ( + item: ObjectMetadataEntity, +): OpenAPIV3.SchemaObject => { + const result = { + type: 'object', + properties: getSchemaComponentsProperties(item), + example: {}, + } as OpenAPIV3.SchemaObject; + + const requiredFields = getRequiredFields(item); + + if (requiredFields?.length) { + result.required = requiredFields; + result.example = requiredFields.reduce((example, requiredField) => { + example[requiredField] = ''; + + return example; + }, {} as Record); + } + + return result; +}; + +export const computeSchemaComponents = ( + objectMetadataItems: ObjectMetadataEntity[], +): Record => { + return objectMetadataItems.reduce((schemas, item) => { + schemas[capitalize(item.nameSingular)] = computeSchemaComponent(item); + + return schemas; + }, {} as Record); +}; + +export const computeParameterComponents = (): Record< + string, + OpenAPIV3.ParameterObject +> => { + return { + idPath: computeIdPathParameter(), + lastCursor: computeLastCursorParameters(), + filter: computeFilterParameters(), + depth: computeDepthParameters(), + orderBy: computeOrderByParameters(), + limit: computeLimitParameters(), + }; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/compute-schema-tags.utils.ts b/packages/twenty-server/src/core/open-api/utils/compute-schema-tags.utils.ts new file mode 100644 index 0000000000..7c0281ae3b --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/compute-schema-tags.utils.ts @@ -0,0 +1,19 @@ +import { OpenAPIV3 } from 'openapi-types'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { capitalize } from 'src/utils/capitalize'; + +export const computeSchemaTags = ( + items: ObjectMetadataEntity[], +): OpenAPIV3.TagObject[] => { + const results = [{ name: 'General', description: 'General requests' }]; + + items.forEach((item) => { + results.push({ + name: item.namePlural, + description: `Object \`${capitalize(item.namePlural)}\``, + }); + }); + + return results; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/get-error-responses.utils.ts b/packages/twenty-server/src/core/open-api/utils/get-error-responses.utils.ts new file mode 100644 index 0000000000..a9984bd4b5 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/get-error-responses.utils.ts @@ -0,0 +1,19 @@ +import { OpenAPIV3 } from 'openapi-types'; + +export const getErrorResponses = ( + description: string, +): OpenAPIV3.ResponseObject => { + return { + description, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { type: 'string' }, + }, + }, + }, + }, + }; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/parameters.utils.ts b/packages/twenty-server/src/core/open-api/utils/parameters.utils.ts new file mode 100644 index 0000000000..fbb8751acf --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/parameters.utils.ts @@ -0,0 +1,121 @@ +import { OpenAPIV3 } from 'openapi-types'; + +import { OrderByDirection } from 'src/workspace/workspace-query-builder/interfaces/record.interface'; + +import { FilterComparators } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-base-filter.utils'; +import { Conjunctions } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/parse-filter.utils'; +import { DEFAULT_ORDER_DIRECTION } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/order-by-input.factory'; +import { DEFAULT_CONJUNCTION } from 'src/core/api-rest/api-rest-query-builder/factories/input-factories/filter-utils/add-default-conjunction.utils'; + +export const computeLimitParameters = (): OpenAPIV3.ParameterObject => { + return { + name: 'limit', + in: 'query', + description: 'Limits the number of objects returned.', + required: false, + schema: { + type: 'integer', + minimum: 0, + maximum: 60, + default: 60, + }, + }; +}; + +export const computeOrderByParameters = (): OpenAPIV3.ParameterObject => { + return { + name: 'order_by', + in: 'query', + description: `Sorts objects returned. + Should have the following shape: **field_name_1,field_name_2[DIRECTION_2],...** + Available directions are **${Object.values(OrderByDirection).join( + '**, **', + )}**. + Default direction is **${DEFAULT_ORDER_DIRECTION}**`, + required: false, + schema: { + type: 'string', + }, + examples: { + simple: { + value: `createdAt`, + summary: 'A simple order_by param', + }, + complex: { + value: `id[${OrderByDirection.AscNullsFirst}],createdAt[${OrderByDirection.DescNullsLast}]`, + summary: 'A more complex order_by param', + }, + }, + }; +}; + +export const computeDepthParameters = (): OpenAPIV3.ParameterObject => { + return { + name: 'depth', + in: 'query', + description: 'Limits the depth objects returned.', + required: false, + schema: { + type: 'integer', + enum: [1, 2], + }, + }; +}; + +export const computeFilterParameters = (): OpenAPIV3.ParameterObject => { + return { + name: 'filter', + in: 'query', + description: `Filters objects returned. + Should have the following shape: **field_1[COMPARATOR]:value_1,field_2[COMPARATOR]:value_2,...** + Available comparators are **${Object.values(FilterComparators).join( + '**, **', + )}**. + You can create more complex filters using conjunctions **${Object.values( + Conjunctions, + ).join('**, **')}**. + Default root conjunction is **${DEFAULT_CONJUNCTION}**. + To filter **null** values use **field[is]:NULL** or **field[is]:NOT_NULL** + To filter using **boolean** values use **field[eq]:true** or **field[eq]:false**`, + required: false, + schema: { + type: 'string', + }, + examples: { + simple: { + value: 'createdAt[gte]:"2023-01-01"', + description: 'A simple filter param', + }, + complex: { + value: + 'or(createdAt[gte]:"2024-01-01",createdAt[lte]:"2023-01-01",not(id[is]:NULL))', + description: 'A more complex filter param', + }, + }, + }; +}; + +export const computeLastCursorParameters = (): OpenAPIV3.ParameterObject => { + return { + name: 'last_cursor', + in: 'query', + description: 'Returns objects starting from a specific cursor.', + required: false, + schema: { + type: 'string', + }, + }; +}; + +export const computeIdPathParameter = (): OpenAPIV3.ParameterObject => { + return { + name: 'id', + in: 'path', + description: 'Object id.', + required: true, + schema: { + type: 'string', + format: 'uuid', + }, + }; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/path.utils.ts b/packages/twenty-server/src/core/open-api/utils/path.utils.ts new file mode 100644 index 0000000000..d53d3a240c --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/path.utils.ts @@ -0,0 +1,109 @@ +import { OpenAPIV3 } from 'openapi-types'; + +import { capitalize } from 'src/utils/capitalize'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { + getDeleteResponse200, + getJsonResponse, + getManyResultResponse200, + getSingleResultSuccessResponse, +} from 'src/core/open-api/utils/responses.utils'; +import { getRequestBody } from 'src/core/open-api/utils/request-body.utils'; + +export const computeManyResultPath = ( + item: ObjectMetadataEntity, +): OpenAPIV3.PathItemObject => { + return { + get: { + tags: [item.namePlural], + summary: `Find Many ${item.namePlural}`, + description: `**order_by**, **filter**, **limit**, **depth** or **last_cursor** can be provided to request your **${item.namePlural}**`, + operationId: `findMany${capitalize(item.namePlural)}`, + parameters: [ + { $ref: '#/components/parameters/orderBy' }, + { $ref: '#/components/parameters/filter' }, + { $ref: '#/components/parameters/limit' }, + { $ref: '#/components/parameters/depth' }, + { $ref: '#/components/parameters/lastCursor' }, + ], + responses: { + '200': getManyResultResponse200(item), + '400': { $ref: '#/components/responses/400' }, + '401': { $ref: '#/components/responses/401' }, + }, + }, + post: { + tags: [item.namePlural], + summary: `Create One ${item.nameSingular}`, + operationId: `createOne${capitalize(item.nameSingular)}`, + parameters: [{ $ref: '#/components/parameters/depth' }], + requestBody: getRequestBody(item), + responses: { + '201': getSingleResultSuccessResponse(item), + '400': { $ref: '#/components/responses/400' }, + '401': { $ref: '#/components/responses/401' }, + }, + }, + } as OpenAPIV3.PathItemObject; +}; + +export const computeSingleResultPath = ( + item: ObjectMetadataEntity, +): OpenAPIV3.PathItemObject => { + return { + get: { + tags: [item.namePlural], + summary: `Find One ${item.nameSingular}`, + description: `**depth** can be provided to request your **${item.nameSingular}**`, + operationId: `findOne${capitalize(item.nameSingular)}`, + parameters: [ + { $ref: '#/components/parameters/idPath' }, + { $ref: '#/components/parameters/depth' }, + ], + responses: { + '200': getSingleResultSuccessResponse(item), + '400': { $ref: '#/components/responses/400' }, + '401': { $ref: '#/components/responses/401' }, + }, + }, + delete: { + tags: [item.namePlural], + summary: `Delete One ${item.nameSingular}`, + operationId: `deleteOne${capitalize(item.nameSingular)}`, + parameters: [{ $ref: '#/components/parameters/idPath' }], + responses: { + '200': getDeleteResponse200(item), + '400': { $ref: '#/components/responses/400' }, + '401': { $ref: '#/components/responses/401' }, + }, + }, + put: { + tags: [item.namePlural], + summary: `Update One ${item.namePlural}`, + operationId: `UpdateOne${capitalize(item.nameSingular)}`, + parameters: [ + { $ref: '#/components/parameters/idPath' }, + { $ref: '#/components/parameters/depth' }, + ], + requestBody: getRequestBody(item), + responses: { + '200': getSingleResultSuccessResponse(item), + '400': { $ref: '#/components/responses/400' }, + '401': { $ref: '#/components/responses/401' }, + }, + }, + } as OpenAPIV3.PathItemObject; +}; + +export const computeOpenApiPath = (): OpenAPIV3.PathItemObject => { + return { + get: { + tags: ['General'], + summary: 'Get Open Api Schema', + operationId: 'GetOpenApiSchema', + responses: { + '200': getJsonResponse(), + }, + }, + } as OpenAPIV3.PathItemObject; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/request-body.utils.ts b/packages/twenty-server/src/core/open-api/utils/request-body.utils.ts new file mode 100644 index 0000000000..d79e8c6096 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/request-body.utils.ts @@ -0,0 +1,16 @@ +import { capitalize } from 'src/utils/capitalize'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +export const getRequestBody = (item: ObjectMetadataEntity) => { + return { + description: 'body', + required: true, + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/${capitalize(item.nameSingular)}`, + }, + }, + }, + }; +}; diff --git a/packages/twenty-server/src/core/open-api/utils/responses.utils.ts b/packages/twenty-server/src/core/open-api/utils/responses.utils.ts new file mode 100644 index 0000000000..603a86d241 --- /dev/null +++ b/packages/twenty-server/src/core/open-api/utils/responses.utils.ts @@ -0,0 +1,119 @@ +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { capitalize } from 'src/utils/capitalize'; + +export const getManyResultResponse200 = (item: ObjectMetadataEntity) => { + return { + description: 'Successful operation', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + [item.namePlural]: { + type: 'object', + properties: { + edges: { + type: 'array', + items: { + type: 'object', + properties: { + node: { + $ref: `#/components/schemas/${capitalize( + item.nameSingular, + )}`, + }, + }, + }, + }, + }, + }, + }, + }, + }, + example: { + data: { + properties: { + [item.namePlural]: { + edges: [ + { + node: `${capitalize(item.nameSingular)}Object`, + }, + '...', + ], + }, + }, + }, + }, + }, + }, + }, + }; +}; + +export const getSingleResultSuccessResponse = (item: ObjectMetadataEntity) => { + return { + description: 'Successful operation', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + [item.nameSingular]: { + $ref: `#/components/schemas/${capitalize(item.nameSingular)}`, + }, + }, + }, + }, + }, + }, + }, + }; +}; + +export const getDeleteResponse200 = (item) => { + return { + description: 'Successful operation', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + [item.nameSingular]: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; +}; + +export const getJsonResponse = () => { + return { + description: 'Successful operation', + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }; +}; diff --git a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts b/packages/twenty-server/src/utils/utils-test/object-metadata-item.ts similarity index 66% rename from packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts rename to packages/twenty-server/src/utils/utils-test/object-metadata-item.ts index a61fb9d1d1..700805d222 100644 --- a/packages/twenty-server/src/core/api-rest/api-rest-query-builder/utils/__tests__/utils.ts +++ b/packages/twenty-server/src/utils/utils-test/object-metadata-item.ts @@ -1,33 +1,44 @@ import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; export const fieldNumber = { name: 'fieldNumber', type: FieldMetadataType.NUMBER, + isNullable: false, + defaultValue: null, targetColumnMap: { value: 'fieldNumber' }, }; export const fieldString = { name: 'fieldString', type: FieldMetadataType.TEXT, + isNullable: true, + defaultValue: null, targetColumnMap: { value: 'fieldString' }, }; export const fieldLink = { name: 'fieldLink', type: FieldMetadataType.LINK, + isNullable: false, + defaultValue: { label: '', url: '' }, targetColumnMap: { label: 'fieldLinkLabel', url: 'fieldLinkUrl' }, }; export const fieldCurrency = { name: 'fieldCurrency', type: FieldMetadataType.CURRENCY, + isNullable: true, + defaultValue: null, targetColumnMap: { amountMicros: 'fieldCurrencyAmountMicros', currencyCode: 'fieldCurrencyCurrencyCode', }, }; -export const objectMetadataItem = { +export const objectMetadataItem: DeepPartial = { targetTableName: 'testingObject', + nameSingular: 'objectName', + namePlural: 'objectsName', fields: [fieldNumber, fieldString, fieldLink, fieldCurrency], }; diff --git a/yarn.lock b/yarn.lock index a28726ac0b..cc8674e7ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27917,6 +27917,13 @@ __metadata: languageName: node linkType: hard +"openapi-types@npm:^12.1.3": + version: 12.1.3 + resolution: "openapi-types@npm:12.1.3" + checksum: 4ad4eb91ea834c237edfa6ab31394e87e00c888fc2918009763389c00d02342345195d6f302d61c3fd807f17723cd48df29b47b538b68375b3827b3758cd520f + languageName: node + linkType: hard + "opener@npm:^1.5.1, opener@npm:^1.5.2": version: 1.5.2 resolution: "opener@npm:1.5.2" @@ -34167,6 +34174,7 @@ __metadata: lodash.upperfirst: "npm:^4.3.1" microdiff: "npm:^1.3.2" nest-commander: "npm:^3.12.0" + openapi-types: "npm:^12.1.3" passport: "npm:^0.6.0" passport-google-oauth20: "npm:^2.0.0" passport-jwt: "npm:^4.0.1"