Custom swagger endpoint for docs (#3869)

* custom swagger endpoint
metadata graphql
remove /rest from endpoint

* fixed pseudo scheme creation

* move graphql playground creation to own file, added navbar to change baseurl and token

* add schema switcher, fix changing url not applied, add invalid overlay

* fix link color

* removed path on Graphql Playground, naming fixes subdoc

* - fixed overflow issue Rest docs

* history replace & goBack

* Small fix GraphQL playground broken

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
brendanlaschke
2024-02-08 16:54:20 +01:00
committed by GitHub
parent 719da29795
commit c53b593ea6
13 changed files with 338 additions and 113 deletions

View File

@ -51,13 +51,14 @@ const sidebars = {
{ {
type: 'link', type: 'link',
label: 'Core API', label: 'Core API',
href: '/rest-api/', href: '/rest-api/core',
}, },
{ {
type: 'link', type: 'link',
label: 'Metadata API', label: 'Metadata API',
href: '#', href: '#',
className: 'coming-soon', className: 'coming-soon',
//href: '/rest-api/metadata',
}, },
], ],
}, },
@ -73,13 +74,12 @@ const sidebars = {
{ {
type: 'link', type: 'link',
label: 'Core API', label: 'Core API',
href: '/graphql/', href: '/graphql/core',
}, },
{ {
type: 'link', type: 'link',
label: 'Metadata API', label: 'Metadata API',
href: '#', href: '/graphql/metadata',
className: 'coming-soon',
}, },
], ],
}, },

View File

@ -6,19 +6,27 @@ import { createGraphiQLFetcher } from '@graphiql/toolkit';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import { GraphiQL } from 'graphiql'; import { GraphiQL } from 'graphiql';
import Playground from '../components/playground'; import Playground from './playground';
import explorerCss from '!css-loader!@graphiql/plugin-explorer/dist/style.css'; import explorerCss from '!css-loader!@graphiql/plugin-explorer/dist/style.css';
import graphiqlCss from '!css-loader!graphiql/graphiql.css'; import graphiqlCss from '!css-loader!graphiql/graphiql.css';
const SubDocToPath = {
core: 'graphql',
metadata: 'metadata',
};
// 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 GraphQlComponent = ({ token }) => { const GraphQlComponent = ({ token, baseUrl, path }) => {
const explorer = explorerPlugin({ const explorer = explorerPlugin({
showAttribution: true, showAttribution: true,
}); });
if (!baseUrl || !token) {
return <></>;
}
const fetcher = createGraphiQLFetcher({ const fetcher = createGraphiQLFetcher({
url: 'https://api.twenty.com/graphql', url: baseUrl + '/' + path,
}); });
// We load graphiql style using useEffect as it breaks remaining docs style // We load graphiql style using useEffect as it breaks remaining docs style
@ -50,8 +58,9 @@ const GraphQlComponent = ({ token }) => {
); );
}; };
const graphQL = () => { const GraphQlPlayground = ({ subDoc }: { subDoc: 'core' | 'metadata' }) => {
const [token, setToken] = useState(); const [token, setToken] = useState();
const [baseUrl, setBaseUrl] = useState();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
useEffect(() => { useEffect(() => {
@ -71,7 +80,13 @@ const graphQL = () => {
return () => window.removeEventListener('storage', handleThemeChange); return () => window.removeEventListener('storage', handleThemeChange);
}, []); }, []);
const children = <GraphQlComponent token={token} />; const children = (
<GraphQlComponent
token={token}
baseUrl={baseUrl}
path={SubDocToPath[subDoc]}
/>
);
return ( return (
<Layout <Layout
@ -79,9 +94,16 @@ const graphQL = () => {
description="GraphQL Playground for Twenty" description="GraphQL Playground for Twenty"
> >
<BrowserOnly> <BrowserOnly>
{() => <Playground children={children} setToken={setToken} />} {() => (
<Playground
children={children}
setToken={setToken}
setBaseUrl={setBaseUrl}
subdocName={subDoc}
/>
)}
</BrowserOnly> </BrowserOnly>
</Layout> </Layout>
); );
}; };
export default graphQL; export default GraphQlPlayground;

View File

@ -1,27 +1,74 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { TbLoader2 } from 'react-icons/tb';
import TokenForm, { TokenFormProps } from '../components/token-form'; import TokenForm, { TokenFormProps } from '../components/token-form';
const Playground = ( const Playground = ({
{ children,
children, setOpenApiJson,
setOpenApiJson, setToken,
setToken setBaseUrl,
}: Partial<React.PropsWithChildren | TokenFormProps> subDoc,
) => { }: Partial<React.PropsWithChildren | TokenFormProps> & {
const [isTokenValid, setIsTokenValid] = useState(false) subDoc: string;
}) => {
const [isTokenValid, setIsTokenValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
return ( return (
<> <div style={{ position: 'relative' }}>
<TokenForm <TokenForm
setOpenApiJson={setOpenApiJson} setOpenApiJson={setOpenApiJson}
setToken={setToken} setToken={setToken}
setBaseUrl={setBaseUrl}
isTokenValid={isTokenValid} isTokenValid={isTokenValid}
setIsTokenValid={setIsTokenValid} setIsTokenValid={setIsTokenValid}
subDoc={subDoc}
setLoadingState={setIsLoading}
/> />
{ {!isTokenValid && (
isTokenValid && children <div
} style={{
</> position: 'absolute',
) width: '100%',
} height: 'calc(100vh - var(--ifm-navbar-height) - 45px)',
top: '45px',
display: 'flex',
flexFlow: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
background: 'rgba(23,23,23, 0.2)',
}}
>
<div
style={{
width: '50%',
background: 'rgba(23,23,23, 0.8)',
color: 'white',
padding: '16px',
borderRadius: '8px',
}}
>
A token is required as APIs are dynamically generated for each
workspace based on their unique metadata. <br /> Generate your token
under{' '}
<a
className="link"
href="https://app.twenty.com/settings/developers"
>
Settings &gt; Developers
</a>
</div>
{isLoading && (
<div className="loader-container">
<TbLoader2 className="loader" />
</div>
)}
</div>
)}
{children}
</div>
);
};
export default Playground; export default Playground;

View File

@ -1,42 +1,90 @@
.container { .form-container {
display: flex; height: 45px;
justify-content: center; overflow: hidden;
align-items: center; border-bottom: 1px solid var(--ifm-color-secondary-light);
height: 90vh; position: sticky;
top: var(--ifm-navbar-height);
padding: 0px 8px;
background: var(--ifm-color-secondary-contrast-background);
z-index: 2;
} }
.form { .form {
text-align: center; display: flex;
padding: 50px; height: 45px;
gap: 10px;
width: 50%;
margin-left: auto;
} }
.link { .link {
color: #16233f; color: white;
text-decoration: none; text-decoration: underline;
position: relative; position: relative;
font-weight: bold; font-weight: bold;
transition: color 0.3s ease; transition: color 0.3s ease;
}
[data-theme='dark'] .link { &:hover {
color: #a3c0f8; color: #ddd;
}
} }
.input { .input {
padding: 6px; padding: 6px;
margin: 20px 0 5px 0; margin: 5px 0 5px 0;
max-width: 460px; max-width: 460px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background-color: #f3f3f3; background-color: #f3f3f3;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
padding-left:30px;
height: 32px;
} }
[data-theme='dark'] .input { [data-theme='dark'] .input {
background-color: #16233f; background-color: #16233f;
} }
.inputWrapper {
display: flex;
align-items: center;
flex: 1;
position: relative;
}
.inputIcon {
display: flex;
align-items: center;
position: absolute;
top: 0;
height: 100%;
padding: 5px;
color: #B3B3B3;
}
[data-theme='dark'] .inputIcon {
color: white;
}
.select {
padding: 6px;
margin: 5px 0 5px 0;
max-width: 460px;
width: 100%;
box-sizing: border-box;
background-color: #f3f3f3;
border: 1px solid #ddd;
border-radius: 4px;
height: 32px;
flex: 1;
}
[data-theme='dark'] .select {
background-color: #16233f;
}
.invalid { .invalid {
border: 1px solid #f83e3e; border: 1px solid #f83e3e;
} }
@ -75,3 +123,18 @@
align-items: center; align-items: center;
height: 50px; height: 50px;
} }
.backButton {
position: absolute;
display: flex;
left: 8px;
height: 100%;
align-items: center;
cursor: pointer;
color: #999999;
&:hover {
color: #16233f;
}
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { TbLoader2 } from 'react-icons/tb'; import { useHistory, useLocation } from '@docusaurus/router';
import { TbApi, TbChevronLeft, TbLink } from '@theme/icons';
import { parseJson } from 'nx/src/utils/json'; import { parseJson } from 'nx/src/utils/json';
import tokenForm from '!css-loader!./token-form.css'; import tokenForm from '!css-loader!./token-form.css';
@ -7,21 +8,38 @@ import tokenForm from '!css-loader!./token-form.css';
export type TokenFormProps = { export type TokenFormProps = {
setOpenApiJson?: (json: object) => void; setOpenApiJson?: (json: object) => void;
setToken?: (token: string) => void; setToken?: (token: string) => void;
setBaseUrl?: (baseUrl: string) => void;
isTokenValid: boolean; isTokenValid: boolean;
setIsTokenValid: (boolean) => void; setIsTokenValid: (boolean) => void;
setLoadingState: (boolean) => void;
subDoc?: string;
}; };
const TokenForm = ({ const TokenForm = ({
setOpenApiJson, setOpenApiJson,
setToken, setToken,
setBaseUrl: submitBaseUrl,
isTokenValid, isTokenValid,
setIsTokenValid, setIsTokenValid,
subDoc,
setLoadingState,
}: TokenFormProps) => { }: TokenFormProps) => {
const history = useHistory();
const location = useLocation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [baseUrl, setBaseUrl] = useState(
parseJson(localStorage.getItem('baseUrl'))?.baseUrl ??
'https://api.twenty.com',
);
const token = const token =
parseJson(localStorage.getItem('TryIt_securitySchemeValues'))?.bearerAuth ?? parseJson(localStorage.getItem('TryIt_securitySchemeValues'))?.bearerAuth ??
''; '';
const updateLoading = (loading: boolean) => {
setIsLoading(loading);
setLoadingState(loading);
};
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => { const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
localStorage.setItem( localStorage.setItem(
'TryIt_securitySchemeValues', 'TryIt_securitySchemeValues',
@ -30,22 +48,33 @@ const TokenForm = ({
await submitToken(event.target.value); await submitToken(event.target.value);
}; };
const validateToken = (openApiJson) => setIsTokenValid(!!openApiJson.tags); const updateBaseUrl = (baseUrl) => {
setBaseUrl(baseUrl);
submitBaseUrl?.(baseUrl);
localStorage.setItem('baseUrl', JSON.stringify({ baseUrl: baseUrl }));
};
const validateToken = (openApiJson) => {
setIsTokenValid(!!openApiJson.tags);
};
const getJson = async (token: string) => { const getJson = async (token: string) => {
setIsLoading(true); updateLoading(true);
return await fetch('https://api.twenty.com/open-api', { return await fetch(baseUrl + '/open-api/' + (subDoc ?? 'core'), {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((result) => { .then((result) => {
validateToken(result); validateToken(result);
setIsLoading(false); updateLoading(false);
return result; return result;
}) })
.catch(() => setIsLoading(false)); .catch(() => {
updateLoading(false);
setIsTokenValid(false);
});
}; };
const submitToken = async (token) => { const submitToken = async (token) => {
@ -60,6 +89,7 @@ const TokenForm = ({
useEffect(() => { useEffect(() => {
(async () => { (async () => {
updateBaseUrl(baseUrl);
await submitToken(token); await submitToken(token);
})(); })();
}, []); }, []);
@ -74,46 +104,61 @@ const TokenForm = ({
}, []); }, []);
return ( return (
!isTokenValid && ( <div className="form-container">
<div> <form className="form">
<div className="container"> <div className="backButton" onClick={() => history.goBack()}>
<form className="form"> <TbChevronLeft size={18} />
<label> <span>Back</span>
To load your playground schema,{' '}
<a
className="link"
href="https://app.twenty.com/settings/developers"
>
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>
</div>
) <div className="inputWrapper">
<div className="inputIcon" title="Api Key">
<TbApi size={20} />
</div>
<input
className={!isTokenValid && !isLoading ? 'input invalid' : 'input'}
type="text"
readOnly={isLoading}
placeholder="API Key"
defaultValue={token}
onChange={updateToken}
/>
</div>
<div className="inputWrapper">
<div className="inputIcon" title="Base URL">
<TbLink size={20} />
</div>
<input
className={'input'}
type="text"
readOnly={isLoading}
placeholder="Base URL"
defaultValue={baseUrl}
onChange={(event) => updateBaseUrl(event.target.value)}
onBlur={() => submitToken(token)}
/>
</div>
{!location.pathname.includes('rest-api') && (
<div className="inputWrapper" style={{ maxWidth: '100px' }}>
<select
className="select"
onChange={(event) =>
history.replace(
'/' +
location.pathname.split('/').at(-2) +
'/' +
event.target.value,
)
}
value={location.pathname.split('/').at(-1)}
>
<option value="core">Core</option>
<option value="metadata">Metadata</option>
</select>
</div>
)}
</form>
</div>
); );
}; };

View File

@ -250,7 +250,7 @@ li.coming-soon a::after {
} }
.fullHeightPlayground { .fullHeightPlayground {
height: calc(100vh - var(--ifm-navbar-height)); height: calc(100vh - var(--ifm-navbar-height) - 45px);
} }
.display-none { .display-none {

View File

@ -0,0 +1,9 @@
import React from 'react';
import GraphQlPlayground from '../../components/graphql-playground';
const CoreGraphql = () => {
return <GraphQlPlayground subDoc={'core'} />;
};
export default CoreGraphql;

View File

@ -0,0 +1,9 @@
import React from 'react';
import GraphQlPlayground from '../../components/graphql-playground';
const CoreGraphql = () => {
return <GraphQlPlayground subDoc={'metadata'} />;
};
export default CoreGraphql;

View File

@ -1,13 +1,13 @@
import Layout from '@theme/Layout';
import BrowserOnly from '@docusaurus/BrowserOnly';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
import { API } from '@stoplight/elements'; import { API } from '@stoplight/elements';
import Playground from '../components/playground'; import Layout from '@theme/Layout';
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';
const RestApiComponent = ({ openApiJson }) => {
const RestApiComponent = ({openApiJson}) => {
// We load spotlightTheme style using useEffect as it breaks remaining docs style // We load spotlightTheme style using useEffect as it breaks remaining docs style
useEffect(() => { useEffect(() => {
const styleElement = document.createElement('style'); const styleElement = document.createElement('style');
@ -18,31 +18,38 @@ const RestApiComponent = ({openApiJson}) => {
}, []); }, []);
return ( return (
<API <div
apiDescriptionDocument={JSON.stringify(openApiJson)} style={{
router="hash" height: 'calc(100vh - var(--ifm-navbar-height) - 45px)',
/> overflow: 'auto',
) }}
} >
<API apiDescriptionDocument={JSON.stringify(openApiJson)} router="hash" />
</div>
);
};
const restApi = () => { const restApi = () => {
const [openApiJson, setOpenApiJson] = useState({}) const [openApiJson, setOpenApiJson] = useState({});
const children = <RestApiComponent openApiJson={openApiJson} /> const children = <RestApiComponent openApiJson={openApiJson} />;
return ( return (
<Layout <Layout
title="REST API Playground" title="REST API Playground"
description="REST API Playground for Twenty" description="REST API Playground for Twenty"
> >
<BrowserOnly>{ <BrowserOnly>
() => <Playground {() => (
children={children} <Playground
setOpenApiJson={setOpenApiJson} children={children}
/> setOpenApiJson={setOpenApiJson}
}</BrowserOnly> subDoc="core"
/>
)}
</BrowserOnly>
</Layout> </Layout>
) );
}; };
export default restApi; export default restApi;

View File

@ -21,6 +21,7 @@ export {
TbCheck, TbCheck,
TbCheckbox, TbCheckbox,
TbChecklist, TbChecklist,
TbChevronLeft,
TbCircleCheckFilled, TbCircleCheckFilled,
TbCircleDot, TbCircleDot,
TbCloud, TbCloud,

View File

@ -8,9 +8,22 @@ import { OpenApiService } from 'src/core/open-api/open-api.service';
export class OpenApiController { export class OpenApiController {
constructor(private readonly openApiService: OpenApiService) {} constructor(private readonly openApiService: OpenApiService) {}
@Get() @Get('core')
async generateOpenApiSchema(@Req() request: Request, @Res() res: Response) { async generateOpenApiSchemaCore(
const data = await this.openApiService.generateSchema(request); @Req() request: Request,
@Res() res: Response,
) {
const data = await this.openApiService.generateCoreSchema(request);
res.send(data);
}
@Get('metadata')
async generateOpenApiSchemaMetaData(
@Req() request: Request,
@Res() res: Response,
) {
const data = await this.openApiService.generateMetaDataSchema();
res.send(data); res.send(data);
} }

View File

@ -24,7 +24,7 @@ export class OpenApiService {
private readonly objectMetadataService: ObjectMetadataService, private readonly objectMetadataService: ObjectMetadataService,
) {} ) {}
async generateSchema(request: Request): Promise<OpenAPIV3.Document> { async generateCoreSchema(request: Request): Promise<OpenAPIV3.Document> {
const schema = baseSchema(); const schema = baseSchema();
let objectMetadataItems; let objectMetadataItems;
@ -42,8 +42,8 @@ export class OpenApiService {
return schema; return schema;
} }
schema.paths = objectMetadataItems.reduce((paths, item) => { schema.paths = objectMetadataItems.reduce((paths, item) => {
paths[`/rest/${item.namePlural}`] = computeManyResultPath(item); paths[`/${item.namePlural}`] = computeManyResultPath(item);
paths[`/rest/${item.namePlural}/{id}`] = computeSingleResultPath(item); paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(item);
return paths; return paths;
}, schema.paths as OpenAPIV3.PathsObject); }, schema.paths as OpenAPIV3.PathsObject);
@ -62,4 +62,13 @@ export class OpenApiService {
return schema; return schema;
} }
async generateMetaDataSchema(): Promise<OpenAPIV3.Document> {
//TODO Add once Rest MetaData api is ready
const schema = baseSchema();
schema.tags = [{ name: 'placeholder' }];
return schema;
}
} }

View File

@ -21,7 +21,7 @@ export const baseSchema = (): OpenAPIV3.Document => {
// Testing purposes // Testing purposes
servers: [ servers: [
{ {
url: 'https://api.twenty.com/', url: 'https://api.twenty.com/rest',
description: 'Production Development', description: 'Production Development',
}, },
], ],