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',
|
type: 'link',
|
||||||
label: 'Metadata API',
|
label: 'Metadata API',
|
||||||
href: '#',
|
href: '/rest-api/metadata',
|
||||||
className: 'coming-soon',
|
|
||||||
//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],
|
imports: [ApiRestQueryBuilderModule, AuthModule, HttpModule],
|
||||||
controllers: [ApiRestMetadataController, ApiRestController],
|
controllers: [ApiRestMetadataController, ApiRestController],
|
||||||
providers: [ApiRestMetadataService, ApiRestService],
|
providers: [ApiRestMetadataService, ApiRestService],
|
||||||
|
exports: [ApiRestMetadataService],
|
||||||
})
|
})
|
||||||
export class ApiRestModule {}
|
export class ApiRestModule {}
|
||||||
|
|||||||
@ -12,11 +12,19 @@ import {
|
|||||||
} from 'src/core/open-api/utils/path.utils';
|
} from 'src/core/open-api/utils/path.utils';
|
||||||
import { getErrorResponses } from 'src/core/open-api/utils/get-error-responses.utils';
|
import { getErrorResponses } from 'src/core/open-api/utils/get-error-responses.utils';
|
||||||
import {
|
import {
|
||||||
|
computeMetadataSchemaComponents,
|
||||||
computeParameterComponents,
|
computeParameterComponents,
|
||||||
computeSchemaComponents,
|
computeSchemaComponents,
|
||||||
} from 'src/core/open-api/utils/components.utils';
|
} from 'src/core/open-api/utils/components.utils';
|
||||||
import { computeSchemaTags } from 'src/core/open-api/utils/compute-schema-tags.utils';
|
import { computeSchemaTags } from 'src/core/open-api/utils/compute-schema-tags.utils';
|
||||||
import { computeWebhooks } from 'src/core/open-api/utils/computeWebhooks.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()
|
@Injectable()
|
||||||
export class OpenApiService {
|
export class OpenApiService {
|
||||||
@ -26,7 +34,7 @@ export class OpenApiService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
|
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
|
||||||
const schema = baseSchema();
|
const schema = baseSchema('core');
|
||||||
|
|
||||||
let objectMetadataItems;
|
let objectMetadataItems;
|
||||||
|
|
||||||
@ -80,10 +88,98 @@ export class OpenApiService {
|
|||||||
|
|
||||||
async generateMetaDataSchema(): Promise<OpenAPIV3_1.Document> {
|
async generateMetaDataSchema(): Promise<OpenAPIV3_1.Document> {
|
||||||
//TODO Add once Rest MetaData api is ready
|
//TODO Add once Rest MetaData api is ready
|
||||||
const schema = baseSchema();
|
const schema = baseSchema('metadata');
|
||||||
|
|
||||||
schema.tags = [{ name: 'placeholder' }];
|
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;
|
return schema;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { OpenAPIV3_1 } from 'openapi-types';
|
|||||||
|
|
||||||
import { computeOpenApiPath } from 'src/core/open-api/utils/path.utils';
|
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 {
|
return {
|
||||||
openapi: '3.0.3',
|
openapi: '3.0.3',
|
||||||
info: {
|
info: {
|
||||||
@ -21,7 +23,9 @@ export const baseSchema = (): OpenAPIV3_1.Document => {
|
|||||||
// Testing purposes
|
// Testing purposes
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
url: 'https://api.twenty.com/rest',
|
url: `https://api.twenty.com/rest/${
|
||||||
|
schemaName !== 'core' ? schemaName : ''
|
||||||
|
}`,
|
||||||
description: 'Production Development',
|
description: 'Production Development',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -45,6 +49,6 @@ export const baseSchema = (): OpenAPIV3_1.Document => {
|
|||||||
description: 'Find out more about **Twenty**',
|
description: 'Find out more about **Twenty**',
|
||||||
url: 'https://twenty.com',
|
url: 'https://twenty.com',
|
||||||
},
|
},
|
||||||
paths: { '/open-api': computeOpenApiPath() },
|
paths: { [`/open-api/${schemaName}`]: computeOpenApiPath() },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -150,3 +150,146 @@ export const computeParameterComponents = (): Record<
|
|||||||
limit: computeLimitParameters(),
|
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'],
|
tags: ['General'],
|
||||||
summary: 'Get Open Api Schema',
|
summary: 'Get Open Api Schema',
|
||||||
operationId: 'GetOpenApiSchema',
|
operationId: 'GetOpenApiSchema',
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: 'https://api.twenty.com/',
|
||||||
|
},
|
||||||
|
],
|
||||||
responses: {
|
responses: {
|
||||||
'200': getJsonResponse(),
|
'200': getJsonResponse(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { capitalize } from 'src/utils/capitalize';
|
|
||||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
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 {
|
return {
|
||||||
description: 'body',
|
description: 'body',
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
|
||||||
import { capitalize } from 'src/utils/capitalize';
|
import { capitalize } from 'src/utils/capitalize';
|
||||||
|
|
||||||
export const getManyResultResponse200 = (item: ObjectMetadataEntity) => {
|
export const getManyResultResponse200 = (
|
||||||
|
item: Pick<ObjectMetadataEntity, 'nameSingular' | 'namePlural'>,
|
||||||
|
) => {
|
||||||
return {
|
return {
|
||||||
description: 'Successful operation',
|
description: 'Successful operation',
|
||||||
content: {
|
content: {
|
||||||
@ -37,7 +39,9 @@ export const getManyResultResponse200 = (item: ObjectMetadataEntity) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSingleResultSuccessResponse = (item: ObjectMetadataEntity) => {
|
export const getSingleResultSuccessResponse = (
|
||||||
|
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
|
||||||
|
) => {
|
||||||
return {
|
return {
|
||||||
description: 'Successful operation',
|
description: 'Successful operation',
|
||||||
content: {
|
content: {
|
||||||
@ -60,7 +64,9 @@ export const getSingleResultSuccessResponse = (item: ObjectMetadataEntity) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDeleteResponse200 = (item) => {
|
export const getDeleteResponse200 = (
|
||||||
|
item: Pick<ObjectMetadataEntity, 'nameSingular'>,
|
||||||
|
) => {
|
||||||
return {
|
return {
|
||||||
description: 'Successful operation',
|
description: 'Successful operation',
|
||||||
content: {
|
content: {
|
||||||
@ -96,6 +102,49 @@ export const getJsonResponse = () => {
|
|||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
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