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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 957 additions and 13 deletions

View File

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

View File

@ -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', () => {

View File

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

View File

@ -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', () => {

View File

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

View File

@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
enum FilterComparators {
export enum FilterComparators {
eq = 'eq',
neq = 'neq',
in = 'in',

View File

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

View File

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

View File

@ -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', () => {

View File

@ -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', () => {

View File

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

View File

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

View File

@ -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: [

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

View File

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

View File

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