feat: adding metadata open-api endpoints and updating docs (#4170)
* initialise metadata schema for open-api * remove "soon" label on metadata rest-api * open-api fetch paths * remove parameter type for metadata schema * add REST module to open-api * metadata schema components * metadata paths * refactor and /open-api route fix
This commit is contained in:
@ -56,9 +56,7 @@ const sidebars = {
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Metadata API',
|
||||
href: '#',
|
||||
className: 'coming-soon',
|
||||
//href: '/rest-api/metadata',
|
||||
href: '/rest-api/metadata',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
55
packages/twenty-docs/src/pages/rest-api/metadata.tsx
Normal file
55
packages/twenty-docs/src/pages/rest-api/metadata.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BrowserOnly from '@docusaurus/BrowserOnly';
|
||||
import { API } from '@stoplight/elements';
|
||||
import Layout from '@theme/Layout';
|
||||
|
||||
import Playground from '../../components/playground';
|
||||
|
||||
import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css';
|
||||
|
||||
const RestApiComponent = ({ openApiJson }) => {
|
||||
// We load spotlightTheme style using useEffect as it breaks remaining docs style
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.innerHTML = spotlightTheme.toString();
|
||||
document.head.append(styleElement);
|
||||
|
||||
return () => styleElement.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - var(--ifm-navbar-height) - 45px)',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<API apiDescriptionDocument={JSON.stringify(openApiJson)} router="hash" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const restApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
|
||||
const children = <RestApiComponent openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="REST API Playground"
|
||||
description="REST API Playground for Twenty"
|
||||
>
|
||||
<BrowserOnly>
|
||||
{() => (
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="metadata"
|
||||
/>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default restApi;
|
||||
@ -12,5 +12,6 @@ import { ApiRestMetadataService } from 'src/core/api-rest/metadata-rest.service'
|
||||
imports: [ApiRestQueryBuilderModule, AuthModule, HttpModule],
|
||||
controllers: [ApiRestMetadataController, ApiRestController],
|
||||
providers: [ApiRestMetadataService, ApiRestService],
|
||||
exports: [ApiRestMetadataService],
|
||||
})
|
||||
export class ApiRestModule {}
|
||||
|
||||
@ -12,11 +12,19 @@ import {
|
||||
} from 'src/core/open-api/utils/path.utils';
|
||||
import { getErrorResponses } from 'src/core/open-api/utils/get-error-responses.utils';
|
||||
import {
|
||||
computeMetadataSchemaComponents,
|
||||
computeParameterComponents,
|
||||
computeSchemaComponents,
|
||||
} from 'src/core/open-api/utils/components.utils';
|
||||
import { computeSchemaTags } from 'src/core/open-api/utils/compute-schema-tags.utils';
|
||||
import { computeWebhooks } from 'src/core/open-api/utils/computeWebhooks.utils';
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
import {
|
||||
getDeleteResponse200,
|
||||
getManyResultResponse200,
|
||||
getSingleResultSuccessResponse,
|
||||
} from 'src/core/open-api/utils/responses.utils';
|
||||
import { getRequestBody } from 'src/core/open-api/utils/request-body.utils';
|
||||
|
||||
@Injectable()
|
||||
export class OpenApiService {
|
||||
@ -26,7 +34,7 @@ export class OpenApiService {
|
||||
) {}
|
||||
|
||||
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
|
||||
const schema = baseSchema();
|
||||
const schema = baseSchema('core');
|
||||
|
||||
let objectMetadataItems;
|
||||
|
||||
@ -80,10 +88,98 @@ export class OpenApiService {
|
||||
|
||||
async generateMetaDataSchema(): Promise<OpenAPIV3_1.Document> {
|
||||
//TODO Add once Rest MetaData api is ready
|
||||
const schema = baseSchema();
|
||||
const schema = baseSchema('metadata');
|
||||
|
||||
schema.tags = [{ name: 'placeholder' }];
|
||||
|
||||
const metadata = [
|
||||
{
|
||||
nameSingular: 'object',
|
||||
namePlural: 'objects',
|
||||
},
|
||||
{
|
||||
nameSingular: 'field',
|
||||
namePlural: 'fields',
|
||||
},
|
||||
{
|
||||
nameSingular: 'relation',
|
||||
namePlural: 'relations',
|
||||
},
|
||||
];
|
||||
|
||||
schema.paths = metadata.reduce((path, item) => {
|
||||
path[`/${item.namePlural}`] = {
|
||||
get: {
|
||||
tags: [item.namePlural],
|
||||
summary: `Find Many ${item.namePlural}`,
|
||||
parameters: [{ $ref: '#/components/parameters/filter' }],
|
||||
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)}`,
|
||||
requestBody: getRequestBody(item),
|
||||
responses: {
|
||||
'200': getSingleResultSuccessResponse(item),
|
||||
'400': { $ref: '#/components/responses/400' },
|
||||
'401': { $ref: '#/components/responses/401' },
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3_1.PathItemObject;
|
||||
path[`/${item.namePlural}/{id}`] = {
|
||||
get: {
|
||||
tags: [item.namePlural],
|
||||
summary: `Find One ${item.nameSingular}`,
|
||||
parameters: [{ $ref: '#/components/parameters/idPath' }],
|
||||
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' }],
|
||||
requestBody: getRequestBody(item),
|
||||
responses: {
|
||||
'200': getSingleResultSuccessResponse(item),
|
||||
'400': { $ref: '#/components/responses/400' },
|
||||
'401': { $ref: '#/components/responses/401' },
|
||||
},
|
||||
},
|
||||
} as OpenAPIV3_1.PathItemObject;
|
||||
|
||||
return path;
|
||||
}, schema.paths as OpenAPIV3_1.PathsObject);
|
||||
|
||||
schema.components = {
|
||||
...schema.components, // components.securitySchemes is defined in base Schema
|
||||
schemas: computeMetadataSchemaComponents(metadata),
|
||||
parameters: computeParameterComponents(),
|
||||
responses: {
|
||||
'400': getErrorResponses('Invalid request'),
|
||||
'401': getErrorResponses('Unauthorized'),
|
||||
},
|
||||
};
|
||||
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,9 @@ import { OpenAPIV3_1 } from 'openapi-types';
|
||||
|
||||
import { computeOpenApiPath } from 'src/core/open-api/utils/path.utils';
|
||||
|
||||
export const baseSchema = (): OpenAPIV3_1.Document => {
|
||||
export const baseSchema = (
|
||||
schemaName: 'core' | 'metadata',
|
||||
): OpenAPIV3_1.Document => {
|
||||
return {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
@ -21,7 +23,9 @@ export const baseSchema = (): OpenAPIV3_1.Document => {
|
||||
// Testing purposes
|
||||
servers: [
|
||||
{
|
||||
url: 'https://api.twenty.com/rest',
|
||||
url: `https://api.twenty.com/rest/${
|
||||
schemaName !== 'core' ? schemaName : ''
|
||||
}`,
|
||||
description: 'Production Development',
|
||||
},
|
||||
],
|
||||
@ -45,6 +49,6 @@ export const baseSchema = (): OpenAPIV3_1.Document => {
|
||||
description: 'Find out more about **Twenty**',
|
||||
url: 'https://twenty.com',
|
||||
},
|
||||
paths: { '/open-api': computeOpenApiPath() },
|
||||
paths: { [`/open-api/${schemaName}`]: computeOpenApiPath() },
|
||||
};
|
||||
};
|
||||
|
||||
@ -150,3 +150,146 @@ export const computeParameterComponents = (): Record<
|
||||
limit: computeLimitParameters(),
|
||||
};
|
||||
};
|
||||
|
||||
export const computeMetadataSchemaComponents = (
|
||||
metadataSchema: { nameSingular: string; namePlural: string }[],
|
||||
): Record<string, OpenAPIV3_1.SchemaObject> => {
|
||||
return metadataSchema.reduce(
|
||||
(schemas, item) => {
|
||||
switch (item.nameSingular) {
|
||||
case 'object': {
|
||||
schemas[`${capitalize(item.nameSingular)}`] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
dataSourceId: { type: 'string' },
|
||||
nameSingular: { type: 'string' },
|
||||
namePlural: { type: 'string' },
|
||||
labelSingular: { type: 'string' },
|
||||
labelPlural: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
isCustom: { type: 'boolean' },
|
||||
isActive: { type: 'boolean' },
|
||||
isSystem: { type: 'boolean' },
|
||||
createdAt: { type: 'string' },
|
||||
updatedAt: { type: 'string' },
|
||||
labelIdentifierFieldMetadataId: { type: 'string' },
|
||||
imageIdentifierFieldMetadataId: { type: 'string' },
|
||||
fields: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
edges: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/Field',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return schemas;
|
||||
}
|
||||
case 'field': {
|
||||
schemas[`${capitalize(item.nameSingular)}`] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
isCustom: { type: 'boolean' },
|
||||
isActive: { type: 'boolean' },
|
||||
isSystem: { type: 'boolean' },
|
||||
isNullable: { type: 'boolean' },
|
||||
createdAt: { type: 'string' },
|
||||
updatedAt: { type: 'string' },
|
||||
fromRelationMetadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
relationType: { type: 'string' },
|
||||
toObjectMetadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
dataSourceId: { type: 'string' },
|
||||
nameSingular: { type: 'string' },
|
||||
namePlural: { type: 'string' },
|
||||
isSystem: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
toFieldMetadataId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
toRelationMetadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
relationType: { type: 'string' },
|
||||
fromObjectMetadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
dataSourceId: { type: 'string' },
|
||||
nameSingular: { type: 'string' },
|
||||
namePlural: { type: 'string' },
|
||||
isSystem: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
fromFieldMetadataId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
defaultValue: { type: 'object' },
|
||||
options: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
return schemas;
|
||||
}
|
||||
case 'relation': {
|
||||
schemas[`${capitalize(item.nameSingular)}`] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
relationType: { type: 'string' },
|
||||
fromObjectMetadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
dataSourceId: { type: 'string' },
|
||||
nameSingular: { type: 'string' },
|
||||
namePlural: { type: 'string' },
|
||||
isSystem: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
fromObjectMetadataId: { type: 'string' },
|
||||
toObjectMetadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
dataSourceId: { type: 'string' },
|
||||
nameSingular: { type: 'string' },
|
||||
namePlural: { type: 'string' },
|
||||
isSystem: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
toObjectMetadataId: { type: 'string' },
|
||||
fromFieldMetadataId: { type: 'string' },
|
||||
toFieldMetadataId: { type: 'string' },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return schemas;
|
||||
},
|
||||
{} as Record<string, OpenAPIV3_1.SchemaObject>,
|
||||
);
|
||||
};
|
||||
|
||||
@ -101,6 +101,11 @@ export const computeOpenApiPath = (): OpenAPIV3_1.PathItemObject => {
|
||||
tags: ['General'],
|
||||
summary: 'Get Open Api Schema',
|
||||
operationId: 'GetOpenApiSchema',
|
||||
servers: [
|
||||
{
|
||||
url: 'https://api.twenty.com/',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': getJsonResponse(),
|
||||
},
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
|
||||
export const getRequestBody = (item: ObjectMetadataEntity) => {
|
||||
export const getRequestBody = (
|
||||
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
|
||||
) => {
|
||||
return {
|
||||
description: 'body',
|
||||
required: true,
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
|
||||
export const getManyResultResponse200 = (item: ObjectMetadataEntity) => {
|
||||
export const getManyResultResponse200 = (
|
||||
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
|
||||
) => {
|
||||
return {
|
||||
description: 'Successful operation',
|
||||
content: {
|
||||
@ -37,7 +39,9 @@ export const getManyResultResponse200 = (item: ObjectMetadataEntity) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getSingleResultSuccessResponse = (item: ObjectMetadataEntity) => {
|
||||
export const getSingleResultSuccessResponse = (
|
||||
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
|
||||
) => {
|
||||
return {
|
||||
description: 'Successful operation',
|
||||
content: {
|
||||
@ -60,7 +64,9 @@ export const getSingleResultSuccessResponse = (item: ObjectMetadataEntity) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getDeleteResponse200 = (item) => {
|
||||
export const getDeleteResponse200 = (
|
||||
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
|
||||
) => {
|
||||
return {
|
||||
description: 'Successful operation',
|
||||
content: {
|
||||
@ -96,6 +102,49 @@ export const getJsonResponse = () => {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
openapi: { type: 'string' },
|
||||
info: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
termsOfService: { type: 'string' },
|
||||
contact: {
|
||||
type: 'object',
|
||||
properties: { email: { type: 'string' } },
|
||||
},
|
||||
license: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
url: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
},
|
||||
components: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
schemas: { type: 'object' },
|
||||
parameters: { type: 'object' },
|
||||
responses: { type: 'object' },
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
type: 'object',
|
||||
},
|
||||
tags: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user