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',
label: 'Core API',
href: '/rest-api/',
href: '/rest-api/core',
},
{
type: 'link',
label: 'Metadata API',
href: '#',
className: 'coming-soon',
//href: '/rest-api/metadata',
},
],
},
@ -73,13 +74,12 @@ const sidebars = {
{
type: 'link',
label: 'Core API',
href: '/graphql/',
href: '/graphql/core',
},
{
type: 'link',
label: 'Metadata API',
href: '#',
className: 'coming-soon',
href: '/graphql/metadata',
},
],
},

View File

@ -6,19 +6,27 @@ import { createGraphiQLFetcher } from '@graphiql/toolkit';
import Layout from '@theme/Layout';
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 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
const GraphQlComponent = ({ token }) => {
const GraphQlComponent = ({ token, baseUrl, path }) => {
const explorer = explorerPlugin({
showAttribution: true,
});
if (!baseUrl || !token) {
return <></>;
}
const fetcher = createGraphiQLFetcher({
url: 'https://api.twenty.com/graphql',
url: baseUrl + '/' + path,
});
// 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 [baseUrl, setBaseUrl] = useState();
const { setTheme } = useTheme();
useEffect(() => {
@ -71,7 +80,13 @@ const graphQL = () => {
return () => window.removeEventListener('storage', handleThemeChange);
}, []);
const children = <GraphQlComponent token={token} />;
const children = (
<GraphQlComponent
token={token}
baseUrl={baseUrl}
path={SubDocToPath[subDoc]}
/>
);
return (
<Layout
@ -79,9 +94,16 @@ const graphQL = () => {
description="GraphQL Playground for Twenty"
>
<BrowserOnly>
{() => <Playground children={children} setToken={setToken} />}
{() => (
<Playground
children={children}
setToken={setToken}
setBaseUrl={setBaseUrl}
subdocName={subDoc}
/>
)}
</BrowserOnly>
</Layout>
);
};
export default graphQL;
export default GraphQlPlayground;

View File

@ -1,27 +1,74 @@
import React, { useState } from 'react';
import { TbLoader2 } from 'react-icons/tb';
import TokenForm, { TokenFormProps } from '../components/token-form';
const Playground = (
{
children,
setOpenApiJson,
setToken
}: Partial<React.PropsWithChildren | TokenFormProps>
) => {
const [isTokenValid, setIsTokenValid] = useState(false)
const Playground = ({
children,
setOpenApiJson,
setToken,
setBaseUrl,
subDoc,
}: Partial<React.PropsWithChildren | TokenFormProps> & {
subDoc: string;
}) => {
const [isTokenValid, setIsTokenValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
return (
<>
<div style={{ position: 'relative' }}>
<TokenForm
setOpenApiJson={setOpenApiJson}
setToken={setToken}
setBaseUrl={setBaseUrl}
isTokenValid={isTokenValid}
setIsTokenValid={setIsTokenValid}
subDoc={subDoc}
setLoadingState={setIsLoading}
/>
{
isTokenValid && children
}
</>
)
}
{!isTokenValid && (
<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;

View File

@ -1,42 +1,90 @@
.container {
display: flex;
justify-content: center;
align-items: center;
height: 90vh;
.form-container {
height: 45px;
overflow: hidden;
border-bottom: 1px solid var(--ifm-color-secondary-light);
position: sticky;
top: var(--ifm-navbar-height);
padding: 0px 8px;
background: var(--ifm-color-secondary-contrast-background);
z-index: 2;
}
.form {
text-align: center;
padding: 50px;
display: flex;
height: 45px;
gap: 10px;
width: 50%;
margin-left: auto;
}
.link {
color: #16233f;
text-decoration: none;
color: white;
text-decoration: underline;
position: relative;
font-weight: bold;
transition: color 0.3s ease;
}
[data-theme='dark'] .link {
color: #a3c0f8;
&:hover {
color: #ddd;
}
}
.input {
padding: 6px;
margin: 20px 0 5px 0;
margin: 5px 0 5px 0;
max-width: 460px;
width: 100%;
box-sizing: border-box;
background-color: #f3f3f3;
border: 1px solid #ddd;
border-radius: 4px;
padding-left:30px;
height: 32px;
}
[data-theme='dark'] .input {
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 {
border: 1px solid #f83e3e;
}
@ -75,3 +123,18 @@
align-items: center;
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 { 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 tokenForm from '!css-loader!./token-form.css';
@ -7,21 +8,38 @@ import tokenForm from '!css-loader!./token-form.css';
export type TokenFormProps = {
setOpenApiJson?: (json: object) => void;
setToken?: (token: string) => void;
setBaseUrl?: (baseUrl: string) => void;
isTokenValid: boolean;
setIsTokenValid: (boolean) => void;
setLoadingState: (boolean) => void;
subDoc?: string;
};
const TokenForm = ({
setOpenApiJson,
setToken,
setBaseUrl: submitBaseUrl,
isTokenValid,
setIsTokenValid,
subDoc,
setLoadingState,
}: TokenFormProps) => {
const history = useHistory();
const location = useLocation();
const [isLoading, setIsLoading] = useState(false);
const [baseUrl, setBaseUrl] = useState(
parseJson(localStorage.getItem('baseUrl'))?.baseUrl ??
'https://api.twenty.com',
);
const token =
parseJson(localStorage.getItem('TryIt_securitySchemeValues'))?.bearerAuth ??
'';
const updateLoading = (loading: boolean) => {
setIsLoading(loading);
setLoadingState(loading);
};
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
localStorage.setItem(
'TryIt_securitySchemeValues',
@ -30,22 +48,33 @@ const TokenForm = ({
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) => {
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}` },
})
.then((res) => res.json())
.then((result) => {
validateToken(result);
setIsLoading(false);
updateLoading(false);
return result;
})
.catch(() => setIsLoading(false));
.catch(() => {
updateLoading(false);
setIsTokenValid(false);
});
};
const submitToken = async (token) => {
@ -60,6 +89,7 @@ const TokenForm = ({
useEffect(() => {
(async () => {
updateBaseUrl(baseUrl);
await submitToken(token);
})();
}, []);
@ -74,46 +104,61 @@ const TokenForm = ({
}, []);
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"
>
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 className="form-container">
<form className="form">
<div className="backButton" onClick={() => history.goBack()}>
<TbChevronLeft size={18} />
<span>Back</span>
</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 {
height: calc(100vh - var(--ifm-navbar-height));
height: calc(100vh - var(--ifm-navbar-height) - 45px);
}
.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 BrowserOnly from '@docusaurus/BrowserOnly';
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';
const RestApiComponent = ({openApiJson}) => {
const RestApiComponent = ({ openApiJson }) => {
// We load spotlightTheme style using useEffect as it breaks remaining docs style
useEffect(() => {
const styleElement = document.createElement('style');
@ -18,31 +18,38 @@ const RestApiComponent = ({openApiJson}) => {
}, []);
return (
<API
apiDescriptionDocument={JSON.stringify(openApiJson)}
router="hash"
/>
)
}
<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 [openApiJson, setOpenApiJson] = useState({});
const children = <RestApiComponent openApiJson={openApiJson} />
const children = <RestApiComponent openApiJson={openApiJson} />;
return (
<Layout
title="REST API Playground"
description="REST API Playground for Twenty"
>
<BrowserOnly>{
() => <Playground
children={children}
setOpenApiJson={setOpenApiJson}
/>
}</BrowserOnly>
<BrowserOnly>
{() => (
<Playground
children={children}
setOpenApiJson={setOpenApiJson}
subDoc="core"
/>
)}
</BrowserOnly>
</Layout>
)
);
};
export default restApi;

View File

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

View File

@ -8,9 +8,22 @@ import { OpenApiService } from 'src/core/open-api/open-api.service';
export class OpenApiController {
constructor(private readonly openApiService: OpenApiService) {}
@Get()
async generateOpenApiSchema(@Req() request: Request, @Res() res: Response) {
const data = await this.openApiService.generateSchema(request);
@Get('core')
async generateOpenApiSchemaCore(
@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);
}

View File

@ -24,7 +24,7 @@ export class OpenApiService {
private readonly objectMetadataService: ObjectMetadataService,
) {}
async generateSchema(request: Request): Promise<OpenAPIV3.Document> {
async generateCoreSchema(request: Request): Promise<OpenAPIV3.Document> {
const schema = baseSchema();
let objectMetadataItems;
@ -42,8 +42,8 @@ export class OpenApiService {
return schema;
}
schema.paths = objectMetadataItems.reduce((paths, item) => {
paths[`/rest/${item.namePlural}`] = computeManyResultPath(item);
paths[`/rest/${item.namePlural}/{id}`] = computeSingleResultPath(item);
paths[`/${item.namePlural}`] = computeManyResultPath(item);
paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(item);
return paths;
}, schema.paths as OpenAPIV3.PathsObject);
@ -62,4 +62,13 @@ export class OpenApiService {
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
servers: [
{
url: 'https://api.twenty.com/',
url: 'https://api.twenty.com/rest',
description: 'Production Development',
},
],