2914 graphql api documentation (#3065)

* Remove dead code

* Create playground component

* Remove useless call to action

* Fix graphiql theme

* Fix style

* Split components

* Move headers to headers form

* Fix nodes in open-api components

* Remove useless check

* Clean code

* Fix css differences

* Keep carret when fetching schema
This commit is contained in:
martmull
2023-12-20 12:01:55 +01:00
committed by GitHub
parent d2666dc667
commit ed7bd0ba26
9 changed files with 250 additions and 158 deletions

View File

@ -0,0 +1,27 @@
import React, { useState } from 'react';
import TokenForm, { TokenFormProps } from '../components/token-form';
const Playground = (
{
children,
setOpenApiJson,
setToken
}: Partial<React.PropsWithChildren | TokenFormProps>
) => {
const [isTokenValid, setIsTokenValid] = useState(false)
return (
<>
<TokenForm
setOpenApiJson={setOpenApiJson}
setToken={setToken}
isTokenValid={isTokenValid}
setIsTokenValid={setIsTokenValid}
/>
{
isTokenValid && children
}
</>
)
}
export default Playground;

View File

@ -18,8 +18,12 @@
transition: color 0.3s ease; transition: color 0.3s ease;
} }
[data-theme='dark'] .link {
color: #a3c0f8;
}
.input { .input {
padding: 4px; padding: 6px;
margin: 20px 0 5px 0; margin: 20px 0 5px 0;
max-width: 460px; max-width: 460px;
width: 100%; width: 100%;
@ -27,17 +31,19 @@
background-color: #f3f3f3; background-color: #f3f3f3;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
}
[data-theme='dark'] .input {
background-color: #16233f;
} }
.invalid { .invalid {
border: 1px solid red; border: 1px solid #f83e3e;
} }
.token-invalid { .token-invalid {
color: red; color: #f83e3e;
font-size: 12px; font-size: 12px;
text-align: left;
} }
.not-visible { .not-visible {
@ -50,6 +56,10 @@
animation: animate 2s infinite; animation: animate 2s infinite;
} }
[data-theme='dark'] .loader {
color: #a3c0f8;
}
@keyframes animate { @keyframes animate {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);

View File

@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import { parseJson } from 'nx/src/utils/json';
import tokenForm from '!css-loader!./token-form.css';
import { TbLoader2 } from 'react-icons/tb';
export type TokenFormProps = {
setOpenApiJson?: (json: object) => void,
setToken?: (token: string) => void,
isTokenValid: boolean,
setIsTokenValid: (boolean) => void,
}
const TokenForm = (
{
setOpenApiJson,
setToken,
isTokenValid,
setIsTokenValid,
}: TokenFormProps
) => {
const [isLoading, setIsLoading] = useState(false)
const token = parseJson(localStorage.getItem('TryIt_securitySchemeValues'))?.bearerAuth ?? ''
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
localStorage.setItem(
'TryIt_securitySchemeValues',
JSON.stringify({bearerAuth: event.target.value}),
)
await submitToken(event.target.value)
}
const validateToken = (openApiJson) => setIsTokenValid(!!openApiJson.tags)
const getJson = async (token: string ) => {
setIsLoading(true)
return await fetch(
'https://api.twenty.com/open-api',
{headers: {Authorization: `Bearer ${token}`}}
)
.then((res)=> res.json())
.then((result)=> {
validateToken(result)
setIsLoading(false)
return result
})
.catch(() => setIsLoading(false))
}
const submitToken = async (token) => {
if (isLoading) return
const json = await getJson(token)
setToken && setToken(token)
setOpenApiJson && setOpenApiJson(json)
}
useEffect(()=> {
(async ()=> {
await submitToken(token)
})()
},[])
// We load playground style using useEffect as it breaks remaining docs style
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.innerHTML = tokenForm.toString();
document.head.append(styleElement);
return () => styleElement.remove();
}, []);
return !isTokenValid && (
<div>
<div className='container'>
<form className="form">
<label>
To load your playground schema, <a className='link' href='https://app.twenty.com/settings/developers/api-keys'>generate an API key</a> and paste it here:
</label>
<p>
<input
className={(token && !isLoading) ? 'input invalid' : 'input'}
type='text'
readOnly={isLoading}
placeholder='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMD...'
defaultValue={token}
onChange={updateToken}
/>
<span className={`token-invalid ${(!token || isLoading )&& 'not-visible'}`}>Token invalid</span>
<div className='loader-container'>
<TbLoader2 className={`loader ${!isLoading && 'not-visible'}`} />
</div>
</p>
</form>
</div>
</div>
)
}
export default TokenForm;

View File

@ -1,35 +1,69 @@
import { createGraphiQLFetcher } from "@graphiql/toolkit"; import { createGraphiQLFetcher } from '@graphiql/toolkit';
import { GraphiQL } from "graphiql"; import { GraphiQL } from 'graphiql';
import React from "react"; import React, { useEffect, useState } from 'react';
import Layout from "@theme/Layout"; import Layout from '@theme/Layout';
import BrowserOnly from "@docusaurus/BrowserOnly"; import BrowserOnly from '@docusaurus/BrowserOnly';
import { useTheme, Theme } from '@graphiql/react';
import Playground from '../components/playground';
import graphiqlCss from '!css-loader!graphiql/graphiql.css';
import "graphiql/graphiql.css";
// Docusaurus does SSR for custom pages but we need to load GraphiQL in the browser // Docusaurus does SSR for custom pages, but we need to load GraphiQL in the browser
const GraphiQLComponent = () => { const GraphQlComponent = ({token}) => {
if ( const fetcher = createGraphiQLFetcher({ url: "https://api.twenty.com/graphql" });
!window.localStorage.getItem("graphiql:theme") &&
window.localStorage.getItem("theme") != "dark" // We load graphiql style using useEffect as it breaks remaining docs style
) { useEffect(()=> {
window.localStorage.setItem("graphiql:theme", "light"); const styleElement = document.createElement('style')
} styleElement.innerHTML = graphiqlCss.toString()
document.head.append(styleElement)
return ()=> styleElement.remove();
}, [])
const fetcher = createGraphiQLFetcher({url: "https://api.twenty.com/graphql"});
return ( return (
<div className="fullHeightPlayground"> <div className="fullHeightPlayground">
<GraphiQL fetcher={fetcher} />; <GraphiQL
fetcher={fetcher}
defaultHeaders={JSON.stringify({Authorization: `Bearer ${token}`})}
/>
</div> </div>
); )
}
const graphQL = () => {
const [token , setToken] = useState()
const { setTheme } = useTheme();
useEffect(()=> {
window.localStorage.setItem("graphiql:theme", window.localStorage.getItem("theme") || 'light');
const handleThemeChange = (ev) => {
if(ev.key === 'theme') {
setTheme(ev.newValue as Theme);
}
}
window.addEventListener('storage', handleThemeChange)
return () => window.removeEventListener('storage', handleThemeChange)
}, [])
const children = <GraphQlComponent token={token} />
return (
<Layout
title="GraphQL Playground"
description="GraphQL Playground for Twenty"
>
<BrowserOnly>{
() => <Playground
children={children}
setToken={setToken}
/>
}</BrowserOnly>
</Layout>
)
}; };
const graphQL = () => (
<Layout
title="GraphQL Playground"
description="GraphQL Playground for Twenty"
>
<BrowserOnly>{() => <GraphiQLComponent />}</BrowserOnly>
</Layout>
);
export default graphQL; export default graphQL;

View File

@ -1,25 +1,14 @@
import Layout from "@theme/Layout"; import Layout from '@theme/Layout';
import BrowserOnly from "@docusaurus/BrowserOnly"; import BrowserOnly from '@docusaurus/BrowserOnly';
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
import { API } from '@stoplight/elements'; import { API } from '@stoplight/elements';
import Playground from '../components/playground';
import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css'; import spotlightTheme from '!css-loader!@stoplight/elements/styles.min.css';
import restApiCss from '!css-loader!./rest-api.css';
import { parseJson } from "nx/src/utils/json";
import { TbLoader2 } from "react-icons/tb";
type TokenFormProps = {
onSubmit: (token: string) => void,
isTokenValid: boolean,
isLoading: boolean,
token: string,
}
const TokenForm = ({onSubmit, isTokenValid, token, isLoading}: TokenFormProps)=> { const RestApiComponent = ({openApiJson}) => {
const updateToken = (event: React.ChangeEvent<HTMLInputElement>) => {
localStorage.setItem('TryIt_securitySchemeValues', JSON.stringify({bearerAuth: event.target.value}))
onSubmit(event.target.value)
}
// We load spotlightTheme style using useEffect as it breaks remaining docs style
useEffect(() => { useEffect(() => {
const styleElement = document.createElement('style'); const styleElement = document.createElement('style');
styleElement.innerHTML = spotlightTheme.toString(); styleElement.innerHTML = spotlightTheme.toString();
@ -28,103 +17,32 @@ const TokenForm = ({onSubmit, isTokenValid, token, isLoading}: TokenFormProps)=>
return () => styleElement.remove(); return () => styleElement.remove();
}, []); }, []);
useEffect(() => { return (
const styleElement = document.createElement('style'); <API
styleElement.innerHTML = restApiCss.toString(); apiDescriptionDocument={JSON.stringify(openApiJson)}
document.head.append(styleElement); router="hash"
/>
return () => styleElement.remove();
}, []);
return !isTokenValid && (
<div>
<div className='container'>
<form className="form">
<label>
To load your REST API schema, <a className='link' href='https://app.twenty.com/settings/developers/api-keys'>generate an API key</a> and paste it here:
</label>
<p>
<input
className={(token && !isLoading) ? "input invalid" : "input"}
type='text'
disabled={isLoading}
placeholder='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMD...'
defaultValue={token}
onChange={updateToken}
/>
<p className={`token-invalid ${(!token || isLoading )&& 'not-visible'}`}>Token invalid</p>
<div className='loader-container'>
<TbLoader2 className={`loader ${!isLoading && 'not-visible'}`} />
</div>
</p>
</form>
</div>
</div>
)
}
const RestApiComponent = () => {
const [openApiJson, setOpenApiJson] = useState({})
const [isTokenValid, setIsTokenValid] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const storedToken = parseJson(localStorage.getItem('TryIt_securitySchemeValues'))?.bearerAuth ?? ''
const validateToken = (openApiJson) => setIsTokenValid(!!openApiJson.tags)
const getJson = async (token: string ) => {
setIsLoading(true)
return await fetch(
"https://api.twenty.com/open-api",
{headers: {Authorization: `Bearer ${token}`}}
)
.then((res)=> res.json())
.then((result)=> {
validateToken(result)
setIsLoading(false)
return result
})
.catch(() => setIsLoading(false))
}
const submitToken = async (token) => {
if (isLoading) return
const json = await getJson(token)
setOpenApiJson(json)
}
useEffect(()=> {
(async ()=> {
await submitToken(storedToken)
})()
},[])
return isTokenValid !== undefined && (
<>
<TokenForm
onSubmit={submitToken}
isTokenValid={isTokenValid}
isLoading={isLoading}
token={storedToken}
/>
{
isTokenValid && (
<API
apiDescriptionDocument={JSON.stringify(openApiJson)}
router="hash"
/>
)
}
</>
) )
} }
const restApi = () => ( const restApi = () => {
<Layout const [openApiJson, setOpenApiJson] = useState({})
title="REST API Playground"
description="REST API Playground for Twenty" const children = <RestApiComponent openApiJson={openApiJson} />
>
<BrowserOnly>{() => <RestApiComponent />}</BrowserOnly> return (
</Layout> <Layout
); title="REST API Playground"
description="REST API Playground for Twenty"
>
<BrowserOnly>{
() => <Playground
children={children}
setOpenApiJson={setOpenApiJson}
/>
}</BrowserOnly>
</Layout>
)
};
export default restApi; export default restApi;

View File

@ -5,7 +5,6 @@ import { OpenAPIV3 } from 'openapi-types';
import { TokenService } from 'src/core/auth/services/token.service'; import { TokenService } from 'src/core/auth/services/token.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.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 { baseSchema } from 'src/core/open-api/utils/base-schema.utils';
import { import {
computeManyResultPath, computeManyResultPath,
@ -23,11 +22,10 @@ export class OpenApiService {
constructor( constructor(
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly objectMetadataService: ObjectMetadataService, private readonly objectMetadataService: ObjectMetadataService,
private readonly environmentService: EnvironmentService,
) {} ) {}
async generateSchema(request: Request): Promise<OpenAPIV3.Document> { async generateSchema(request: Request): Promise<OpenAPIV3.Document> {
const schema = baseSchema(this.environmentService.getFrontBaseUrl()); const schema = baseSchema();
let objectMetadataItems; let objectMetadataItems;

View File

@ -2,12 +2,12 @@ import { OpenAPIV3 } 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 = (frontBaseUrl: string): OpenAPIV3.Document => { export const baseSchema = (): OpenAPIV3.Document => {
return { return {
openapi: '3.0.3', openapi: '3.0.3',
info: { info: {
title: 'Twenty Api', 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`, description: `This is a **Twenty REST/API** playground based on the **OpenAPI 3.0 specification**.`,
termsOfService: 'https://github.com/twentyhq/twenty?tab=coc-ov-file', termsOfService: 'https://github.com/twentyhq/twenty?tab=coc-ov-file',
contact: { contact: {
email: 'felix@twenty.com', email: 'felix@twenty.com',

View File

@ -42,17 +42,16 @@ const getSchemaComponentsProperties = (
itemProperty.type = 'boolean'; itemProperty.type = 'boolean';
break; break;
case FieldMetadataType.RELATION: case FieldMetadataType.RELATION:
itemProperty = { if (field.fromRelationMetadata?.toObjectMetadata.nameSingular) {
type: 'array', itemProperty = {
items: { type: 'array',
type: 'object', items: {
properties: { $ref: `#/components/schemas/${capitalize(
node: { field.fromRelationMetadata?.toObjectMetadata.nameSingular || '',
type: 'object', )}`,
},
}, },
}, };
}; }
break; break;
case FieldMetadataType.LINK: case FieldMetadataType.LINK:
case FieldMetadataType.CURRENCY: case FieldMetadataType.CURRENCY:
@ -74,7 +73,9 @@ const getSchemaComponentsProperties = (
break; break;
} }
node[field.name] = itemProperty; if (Object.keys(itemProperty).length) {
node[field.name] = itemProperty;
}
return node; return node;
}, {} as Properties); }, {} as Properties);

View File

@ -339,6 +339,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
'fields', 'fields',
'fields.fromRelationMetadata', 'fields.fromRelationMetadata',
'fields.toRelationMetadata', 'fields.toRelationMetadata',
'fields.fromRelationMetadata.toObjectMetadata',
], ],
}); });
} }