Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

poc(api-docs): swagger ui api docs #10579

Draft
wants to merge 23 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions app/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import {Metadata} from 'next';
import {notFound} from 'next/navigation';

import {apiCategories} from 'sentry-docs/build/resolveOpenAPI';
import {ApiCategoryPage} from 'sentry-docs/components/apiCategoryPage';
import {ApiPage} from 'sentry-docs/components/apiPage';
import {ApiDocsPage} from 'sentry-docs/components/api-docs';
import {DocPage} from 'sentry-docs/components/docPage';
import {Home} from 'sentry-docs/components/home';
import {Include} from 'sentry-docs/components/include';
Expand Down Expand Up @@ -74,16 +73,20 @@ export default async function Page({params}: {params: {path?: string[]}}) {
return <Home />;
}

if (params.path[0] === 'api-docs' && params.path.length === 1) {
return <ApiDocsPage />;
}

if (params.path[0] === 'api' && params.path.length > 1) {
const categories = await apiCategories();
const category = categories.find(c => c.slug === params?.path?.[1]);
if (category) {
if (params.path.length === 2) {
return <ApiCategoryPage category={category} />;
return <ApiDocsPage category={category} />;
}
const api = category.apis.find(a => a.slug === params.path?.[2]);
if (api) {
return <ApiPage api={api} />;
return <ApiDocsPage api={api} />;
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions docs/api-docs/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
title:
---
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"framer-motion": "^10.12.16",
"gray-matter": "^4.0.3",
"hastscript": "^8.0.0",
"httpsnippet": "3.0.6",
"image-size": "^1.1.1",
"js-cookie": "^3.0.5",
"js-yaml": "^4.1.0",
Expand Down Expand Up @@ -98,6 +99,7 @@
"sass": "^1.69.5",
"search-insights": "^2.2.3",
"server-only": "^0.0.1",
"swagger-ui-react": "^5.17.14",
"sharp": "^0.33.4",
"tailwindcss-scoped-preflight": "^3.0.4",
"textarea-markdown-editor": "^1.0.4"
Expand All @@ -108,9 +110,11 @@
"@spotlightjs/spotlight": "^2.0.0-alpha.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@types/har-format": "^1.2.15",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/swagger-ui-react": "^4.18.3",
"@types/ws": "^8.5.10",
"@untitaker/quicktype-core-with-markdown": "^6.0.71",
"autoprefixer": "^10.4.17",
Expand All @@ -122,6 +126,7 @@
"jest": "^29.5.0",
"jest-dom": "^4.0.0",
"jest-environment-jsdom": "^29.5.0",
"openapi-types": "^12.1.3",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"prisma": "^5.8.1",
Expand Down
10 changes: 2 additions & 8 deletions src/build/resolveOpenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
import {promises as fs} from 'fs';

import {DeRefedOpenAPI} from './open-api/types';

// SENTRY_API_SCHEMA_SHA is used in the sentry-docs GHA workflow in getsentry/sentry-api-schema.
// DO NOT change variable name unless you change it in the sentry-docs GHA workflow in getsentry/sentry-api-schema.
const SENTRY_API_SCHEMA_SHA = 'aee3c08319966d0f5381ce7d083cd81e4fdb00f9';
import {resolveRemoteApiSpec} from './shared';

const activeEnv = process.env.GATSBY_ENV || process.env.NODE_ENV || 'development';

Expand All @@ -25,10 +22,7 @@ async function resolveOpenAPI(): Promise<DeRefedOpenAPI> {
);
}
}
const response = await fetch(
`https://raw.githubusercontent.com/getsentry/sentry-api-schema/${SENTRY_API_SCHEMA_SHA}/openapi-derefed.json`
);
return await response.json();
return resolveRemoteApiSpec();
}

export type APIParameter = {
Expand Down
10 changes: 10 additions & 0 deletions src/build/shared.ts
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
import {DeRefedOpenAPI} from './open-api/types';

export const BASE_REGISTRY_URL = 'https://release-registry.services.sentry.io';
const SENTRY_API_SCHEMA_SHA = 'aee3c08319966d0f5381ce7d083cd81e4fdb00f9';

export const resolveRemoteApiSpec = async (): Promise<DeRefedOpenAPI> => {
const response = await fetch(
`https://raw.githubusercontent.com/getsentry/sentry-api-schema/${SENTRY_API_SCHEMA_SHA}/openapi-derefed.json`
);
return response.json() as Promise<DeRefedOpenAPI>;
};
120 changes: 120 additions & 0 deletions src/components/api-docs/apiDocs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use client';

import './swagger-ui.css';

import {Fragment, useEffect, useState} from 'react';
import {LoadingArticle} from 'apps/changelog/src/client/components/article';
import {OpenAPIV3_1} from 'openapi-types';
import {API, APICategory} from 'src/build/resolveOpenAPI';
// import {resolveRemoteApiSpec} from 'src/build/shared';
import SwaggerUI, {SwaggerUIProps} from 'swagger-ui-react';

import {resolveRemoteApiSpec} from 'sentry-docs/build/shared';

import {HTTPSnippetGenerators} from './plugins';
import {getSnippetConfig} from './settings';

type OpenApiSpec = OpenAPIV3_1.Document;

type Props = {
api?: API;
category?: APICategory;
};

const filteredSpecByEndpoint = (
originalSpec: OpenApiSpec | null,
endpoint?: string,
method?: string
) => {
if (!originalSpec) {
return null;
}

const filteredSpec: OpenApiSpec = {
info: originalSpec.info,
openapi: originalSpec.openapi,
components: {},
paths: {},
};

if (endpoint) {
Object.entries(originalSpec?.paths || {}).forEach(([path, methods]) => {
if (filteredSpec.paths && methods && path === endpoint) {
if (method) {
const filteredMethods = {...methods};
Object.keys(methods).forEach(methodName => {
if (methodName !== method) {
delete filteredMethods[methodName];
}
});
filteredSpec.paths[path] = filteredMethods;
} else {
filteredSpec.paths[path] = methods;
}
}
});
} else {
filteredSpec.paths = originalSpec.paths;
}

return filteredSpec;
};

/**
* Removes information that we don't want rendered in swagger
* @param spec The openapi spec
*/
const removeUnrenderedInfo = (originalSpec: OpenApiSpec | null) => {
if (!originalSpec) {
return;
}

originalSpec.info.title = '';
originalSpec.info.description = '';
originalSpec.info.version = '';
};

export function ApiDocs({api}: Props) {
const [loading, setLoading] = useState(true);
const [apiSpec, setApiSpec] = useState<OpenApiSpec | null>(null);

useEffect(() => {
const fetchApiSpec = async () => {
// this is temporary, for demo purposed
const spec = (await resolveRemoteApiSpec()) as any as OpenApiSpec;

// eslint-disable-next-line no-console
console.log('resolving spec', spec);
setApiSpec(spec);
};

fetchApiSpec();
}, []);

const renderedSpec = api
? filteredSpecByEndpoint(apiSpec, api?.apiPath, api?.method)
: apiSpec;

removeUnrenderedInfo(renderedSpec);

// const specUrl = `https://raw.githubusercontent.com/getsentry/sentry-api-schema/${SENTRY_API_SCHEMA_SHA}/openapi-derefed.json`;

const snippetConfig = getSnippetConfig(['curl_bash', 'node_axios', 'python', 'php']);
const plugins: SwaggerUIProps['plugins'] = [HTTPSnippetGenerators];

return (
<Fragment>
{loading && <LoadingArticle />}
{renderedSpec && (
<SwaggerUI
spec={renderedSpec}
requestSnippetsEnabled
requestSnippets={snippetConfig}
plugins={plugins}
docExpansion={api ? 'full' : 'none'}
onComplete={() => setLoading(false)}
/>
)}
</Fragment>
);
}
23 changes: 23 additions & 0 deletions src/components/api-docs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {API, APICategory} from 'sentry-docs/build/resolveOpenAPI';

import {DocPage} from '../docPage';

import {ApiDocs} from './apiDocs';

type Props = {
api?: API;
category?: APICategory;
};

export function ApiDocsPage({api, category}: Props) {
const frontMatter: React.ComponentProps<typeof DocPage>['frontMatter'] = {
title: 'API Reference',
description: 'Sentry API Reference',
};

return (
<DocPage frontMatter={frontMatter} fullWidth>
<ApiDocs api={api} category={category} />
</DocPage>
);
}
84 changes: 84 additions & 0 deletions src/components/api-docs/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {ClientId, HarRequest, HTTPSnippet, TargetId} from 'httpsnippet';

import {GeneratorNames} from './settings';

interface SnippetOptions {
headers: HarRequest['headers'];
method: HarRequest['method'];
targetId: TargetId;
url: string;
body?: string;
clientId?: ClientId;
params?: HarRequest['queryString'];
}

const generateRequestSnippet = (options: SnippetOptions): string => {
const postData: HarRequest['postData'] = options.body
? {
mimeType: 'application/json',
text: options.body,
}
: {
mimeType: '',
};

const snippet = new HTTPSnippet({
method: options.method.toUpperCase(),
url: options.url,
log: undefined,
bodySize: -1,
cookies: [],
headers: options.headers,
headersSize: -1,
httpVersion: '',
postData,
queryString: options.params || [],
});

return snippet.convert(options.targetId, options.clientId).toString();
};

const generateSnippetFromRequest = (
req: any,
targetId: TargetId, // targetIds are the name of the folder found here https://github.com/Kong/httpsnippet/tree/master/src/targets
clientId?: ClientId // clientIds are the name of the subfolder under the appropriate targetId
) => {
const headers = req.get('headers');
const url = new URL(req.get('url'));

return generateRequestSnippet({
headers: Array.from(headers.entries()).map(([key, val]) => ({
name: key,
value: val,
})),
method: req.get('method'),
url: url.href.split('?')[0],
params: Array.from(url.searchParams.entries()).map(([key, val]) => ({
name: key,
value: val,
})),
body: req.get('body'),
targetId,
clientId,
});
};

type CustomGeneratorNames = Exclude<
GeneratorNames,
'curl_bash' | 'curl_powershell' | 'curl_cmd'
>;

type HTTPGenerators = {
[K in `requestSnippetGenerator_${CustomGeneratorNames}`]: (req: any) => void;
};

export const HTTPSnippetGenerators: {fn: HTTPGenerators} = {
fn: {
// use `requestSnippetGenerator_` + key from config (node_native) for generator fn
requestSnippetGenerator_node_axios: req =>
generateSnippetFromRequest(req, 'node', 'axios'),
requestSnippetGenerator_python: req => generateSnippetFromRequest(req, 'python'),
requestSnippetGenerator_php: req => generateSnippetFromRequest(req, 'php'),
requestSnippetGenerator_csharp: req => generateSnippetFromRequest(req, 'csharp'),
},
};
48 changes: 48 additions & 0 deletions src/components/api-docs/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
interface SnippetConfig {
defaultExpanded: boolean;
generators: Record<string, {syntax: string; title: string}>;
languages: GeneratorNames[] | null;
}

export type GeneratorNames = keyof typeof SNIPPET_GENERATORS;

const SNIPPET_GENERATORS = {
curl_bash: {
title: 'cURL (bash)',
syntax: 'bash',
},
curl_powershell: {
title: 'cURL (PowerShell)',
syntax: 'powershell',
},
curl_cmd: {
title: 'cURL (CMD)',
syntax: 'bash',
},
csharp: {
title: 'C#',
syntax: 'csharp',
},
node_axios: {
title: 'NodeJs (axios)',
syntax: 'javascript',
},
python: {
title: 'Python',
syntax: 'python',
},
php: {
title: 'PHP',
syntax: 'php',
},
};

export const getSnippetConfig = (
languages: SnippetConfig['languages'] = null
): SnippetConfig => {
return {
defaultExpanded: true,
generators: SNIPPET_GENERATORS,
languages,
};
};
Loading
Loading