mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-26 04:17:15 +03:00
Add generate openapi schema for rest api (#2923)
* Add generate openapi schema for rest api * Split method in utils * Add paramters * Add error response * Update description of filter and order by * Add get/id routes * Add delete route * Use components * Fix Typo * Add tags * Add create query * Add required field * Add update query * Add body request example * Add 201 on create request * Add servers * Fix failing test * Add open-api endpoint * Update description * Return base schema if no auth * Code review returns * Use open-api/types * Fix tag * Use components for parameters * Improve response examples * Improve axios error message * Fix tests
This commit is contained in:
parent
366ae0d448
commit
e3e42be723
@ -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",
|
||||
|
@ -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', () => {
|
||||
|
@ -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 };
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
enum FilterComparators {
|
||||
export enum FilterComparators {
|
||||
eq = 'eq',
|
||||
neq = 'neq',
|
||||
in = 'in',
|
||||
|
@ -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',
|
||||
|
@ -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 {
|
||||
|
@ -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', () => {
|
||||
|
@ -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', () => {
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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: [
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
13
packages/twenty-server/src/core/open-api/open-api.module.ts
Normal file
13
packages/twenty-server/src/core/open-api/open-api.module.ts
Normal file
@ -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 {}
|
@ -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>(OpenApiService);
|
||||
});
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
67
packages/twenty-server/src/core/open-api/open-api.service.ts
Normal file
67
packages/twenty-server/src/core/open-api/open-api.service.ts
Normal file
@ -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<OpenAPIV3.Document> {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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() },
|
||||
};
|
||||
};
|
@ -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<string, string>);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const computeSchemaComponents = (
|
||||
objectMetadataItems: ObjectMetadataEntity[],
|
||||
): Record<string, OpenAPIV3.SchemaObject> => {
|
||||
return objectMetadataItems.reduce((schemas, item) => {
|
||||
schemas[capitalize(item.nameSingular)] = computeSchemaComponent(item);
|
||||
|
||||
return schemas;
|
||||
}, {} as Record<string, OpenAPIV3.SchemaObject>);
|
||||
};
|
||||
|
||||
export const computeParameterComponents = (): Record<
|
||||
string,
|
||||
OpenAPIV3.ParameterObject
|
||||
> => {
|
||||
return {
|
||||
idPath: computeIdPathParameter(),
|
||||
lastCursor: computeLastCursorParameters(),
|
||||
filter: computeFilterParameters(),
|
||||
depth: computeDepthParameters(),
|
||||
orderBy: computeOrderByParameters(),
|
||||
limit: computeLimitParameters(),
|
||||
};
|
||||
};
|
@ -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;
|
||||
};
|
@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
@ -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',
|
||||
},
|
||||
};
|
||||
};
|
109
packages/twenty-server/src/core/open-api/utils/path.utils.ts
Normal file
109
packages/twenty-server/src/core/open-api/utils/path.utils.ts
Normal file
@ -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;
|
||||
};
|
@ -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)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
@ -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<ObjectMetadataEntity> = {
|
||||
targetTableName: 'testingObject',
|
||||
nameSingular: 'objectName',
|
||||
namePlural: 'objectsName',
|
||||
fields: [fieldNumber, fieldString, fieldLink, fieldCurrency],
|
||||
};
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user