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:
martmull
2023-12-13 14:58:34 +01:00
committed by GitHub
parent 366ae0d448
commit e3e42be723
29 changed files with 957 additions and 13 deletions

View File

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

View 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 {}

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
};

View File

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

View File

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