OpenAPI: Improve sample select UI

This commit is contained in:
Fuma Nama
2025-02-07 20:59:29 +08:00
parent c892bd9b6f
commit 31e7e3ea0b
14 changed files with 393 additions and 378 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Improve sample select UI
+4 -260
View File
@@ -1,13 +1,5 @@
import { Fragment, type ReactElement, type ReactNode } from 'react';
import {
generateSample,
type EndpointSample,
EndpointSamples,
} from '@/utils/generate-sample';
import * as CURL from '@/requests/curl';
import * as JS from '@/requests/javascript';
import * as Go from '@/requests/go';
import * as Python from '@/requests/python';
import { generateSample, type EndpointSample } from '@/utils/generate-sample';
import type {
CallbackObject,
MethodInformation,
@@ -15,20 +7,15 @@ import type {
RenderContext,
SecurityRequirementObject,
} from '@/types';
import { getPreferredType, NoReference } from '@/utils/schema';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { getPreferredType, type NoReference } from '@/utils/schema';
import { getSecurities, getSecurityPrefix } from '@/utils/get-security';
import { idToTitle } from '@/utils/id-to-title';
import { type ResponseTypeProps } from '@/render/renderer';
import { Markdown } from './markdown';
import { heading } from './heading';
import { Schema } from './schema';
import { createMethod } from '@/server/create-method';
import { methodKeys } from '@/build-routes';
interface CustomProperty {
'x-codeSamples'?: CodeSample[];
}
import { APIExample } from '@/render/operation/api-example';
export interface CodeSample {
lang: string;
@@ -39,12 +26,6 @@ export interface CodeSample {
| false;
}
interface CodeSampleCompiled {
lang: string;
label: string;
source: string;
}
export function Operation({
type = 'operation',
path,
@@ -52,8 +33,6 @@ export function Operation({
ctx,
hasHead,
headingLevel = 2,
selectedSampleKey,
exclusiveSampleKey,
}: {
type?: 'webhook' | 'operation';
path: string;
@@ -62,8 +41,6 @@ export function Operation({
hasHead?: boolean;
headingLevel?: number;
selectedSampleKey?: string;
exclusiveSampleKey?: string;
}): ReactElement {
const body = method.requestBody;
const security = method.security ?? ctx.schema.document.security;
@@ -238,13 +215,7 @@ export function Operation({
return (
<ctx.renderer.API>
{info}
<APIExample
method={method}
endpoint={endpoint}
ctx={ctx}
selectedSampleKey={selectedSampleKey}
exclusiveSampleKey={exclusiveSampleKey}
/>
<APIExample method={method} endpoint={endpoint} ctx={ctx} />
</ctx.renderer.API>
);
} else {
@@ -252,154 +223,6 @@ export function Operation({
}
}
const defaultSamples: CodeSample[] = [
{
label: 'cURL',
source: CURL.getSampleRequest,
lang: 'bash',
},
{
label: 'JavaScript',
source: JS.getSampleRequest,
lang: 'js',
},
{
label: 'Go',
source: Go.getSampleRequest,
lang: 'go',
},
{
label: 'Python',
source: Python.getSampleRequest,
lang: 'python',
},
];
async function APIExample({
method,
endpoint,
ctx,
selectedSampleKey,
exclusiveSampleKey,
}: {
method: MethodInformation;
endpoint: EndpointSample;
ctx: RenderContext;
selectedSampleKey?: string;
exclusiveSampleKey?: string;
}) {
const renderer = ctx.renderer;
const children: ReactNode[] = [];
// fallback for methods that have no request body, we also want to show examples for
const existingSamples: EndpointSamples = endpoint.body?.samples ?? {
_default: {},
};
const samples: Record<
string,
{ samples: CodeSampleCompiled[]; title: string; description?: string }
> = {};
for (const exampleKey in existingSamples) {
samples[exampleKey] = {
title: existingSamples[exampleKey]?.summary ?? exampleKey,
description: existingSamples[exampleKey]?.description,
samples: dedupe([
...defaultSamples,
...(ctx.generateCodeSamples
? await ctx.generateCodeSamples(endpoint)
: []),
...((method as CustomProperty)['x-codeSamples'] ?? []),
]).flatMap<CodeSampleCompiled>((sample) => {
if (sample.source === false) return [];
const result =
typeof sample.source === 'function'
? sample.source(endpoint, exampleKey)
: sample.source;
if (result === undefined) return [];
return {
...sample,
source: result,
};
}),
};
}
if (Object.keys(samples).length > 0) {
const sampleTabs: ReactNode[] = [];
const titles = [];
if (
(samples && Object.keys(samples).length === 1 && samples['_default']) ||
(exclusiveSampleKey && samples[exclusiveSampleKey])
) {
// if exclusiveSampleKey is present, we don't use tabs
// if only the fallback or non described openapi legacy example is present, we don't use tabs
const sampleKey = exclusiveSampleKey ?? '_default';
children.push(
<renderer.Requests
key={`requests-${sampleKey}`}
items={samples[sampleKey].samples.map((s) => s.label)}
>
{samples[sampleKey].samples.map((s) => (
<renderer.Request
key={`requests-${sampleKey}-${s.label}`}
name={s.label}
code={s.source}
language={s.lang}
/>
))}
</renderer.Requests>,
);
} else {
for (const sampleKey in samples) {
const title = samples[sampleKey].title;
titles.push(title);
sampleTabs.push(
<renderer.Sample key={sampleKey} value={title}>
{samples[sampleKey].description && (
<Markdown text={samples[sampleKey].description} />
)}
<renderer.Requests
key={`requests-${sampleKey}`}
items={samples[sampleKey].samples.map((s) => s.label)}
>
{samples[sampleKey].samples.map((s) => (
<renderer.Request
key={`requests-${sampleKey}-${s.label}`}
name={s.label}
code={s.source}
language={s.lang}
/>
))}
</renderer.Requests>
</renderer.Sample>,
);
}
children.push(
<renderer.Samples
items={titles}
key="samples"
defaultValue={selectedSampleKey}
>
{sampleTabs}
</renderer.Samples>,
);
}
}
children.push(
<ResponseTabs
key="responses"
operation={method}
ctx={ctx}
endpoint={endpoint}
/>,
);
return <renderer.APIExample>{children}</renderer.APIExample>;
}
function WebhookCallback({
callback,
ctx,
@@ -435,22 +258,6 @@ function WebhookCallback({
});
}
/**
* Remove duplicated labels
*/
function dedupe(samples: CodeSample[]): CodeSample[] {
const set = new Set<string>();
const out: CodeSample[] = [];
for (let i = samples.length - 1; i >= 0; i--) {
if (set.has(samples[i].label)) continue;
set.add(samples[i].label);
out.unshift(samples[i]);
}
return out;
}
function AuthSection({
ctx: {
schema: { document },
@@ -520,66 +327,3 @@ function AuthSection({
return info;
}
async function ResponseTabs({
endpoint,
operation,
ctx: { renderer, generateTypeScriptSchema, schema },
}: {
endpoint: EndpointSample;
operation: MethodInformation;
ctx: RenderContext;
}): Promise<ReactElement | null> {
const items: string[] = [];
const children: ReactNode[] = [];
if (!operation.responses) return null;
for (const code of Object.keys(operation.responses)) {
const types: ResponseTypeProps[] = [];
let description = operation.responses[code].description;
if (!description && code in endpoint.responses)
description = endpoint.responses[code].schema.description ?? '';
if (code in endpoint.responses) {
types.push({
lang: 'json',
label: 'Response',
code: JSON.stringify(endpoint.responses[code].sample, null, 2),
});
}
let ts: string | undefined;
if (generateTypeScriptSchema) {
ts = await generateTypeScriptSchema(endpoint, code);
} else if (generateTypeScriptSchema === undefined) {
ts = await getTypescriptSchema(endpoint, code, schema.dereferenceMap);
}
if (ts) {
types.push({
code: ts,
lang: 'ts',
label: 'TypeScript',
});
}
items.push(code);
children.push(
<renderer.Response key={code} value={code}>
<Markdown text={description} />
{types.length > 0 ? (
<renderer.ResponseTypes>
{types.map((type) => (
<renderer.ResponseType key={type.lang} {...type} />
))}
</renderer.ResponseTypes>
) : null}
</renderer.Response>,
);
}
if (items.length === 0) return null;
return <renderer.Responses items={items}>{children}</renderer.Responses>;
}
@@ -0,0 +1,238 @@
import * as CURL from '@/requests/curl';
import * as JS from '@/requests/javascript';
import * as Go from '@/requests/go';
import * as Python from '@/requests/python';
import type { MethodInformation, RenderContext } from '@/types';
import type { EndpointSample } from '@/utils/generate-sample';
import { type ReactNode } from 'react';
import { Markdown } from '@/render/markdown';
import { type CodeSample } from '@/render/operation';
import type { ResponseTypeProps } from '@/render/renderer';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
const defaultSamples: CodeSample[] = [
{
label: 'cURL',
source: CURL.getSampleRequest,
lang: 'bash',
},
{
label: 'JavaScript',
source: JS.getSampleRequest,
lang: 'js',
},
{
label: 'Go',
source: Go.getSampleRequest,
lang: 'go',
},
{
label: 'Python',
source: Python.getSampleRequest,
lang: 'python',
},
];
interface CustomProperty {
'x-codeSamples'?: CodeSample[];
'x-selectedCodeSample'?: string;
'x-exclusiveCodeSample'?: string;
}
interface CodeSampleCompiled {
lang: string;
label: string;
source: string;
}
interface CollectedSample {
samples: CodeSampleCompiled[];
title: string;
description?: string;
}
export async function APIExample({
method,
endpoint,
ctx,
}: {
method: MethodInformation & CustomProperty;
endpoint: EndpointSample;
ctx: RenderContext;
}) {
const renderer = ctx.renderer;
const samples = new Map<string, CollectedSample>();
let children: ReactNode;
for (const [key, sample] of Object.entries(
endpoint.body?.samples ?? {
// fallback for methods that have no request body, we also want to show examples for
_default: {},
},
)) {
samples.set(key, {
title: sample?.summary ?? key,
description: sample?.description,
samples: dedupe([
...defaultSamples,
...(ctx.generateCodeSamples
? await ctx.generateCodeSamples(endpoint)
: []),
...(method['x-codeSamples'] ?? []),
]).flatMap((sample) => {
if (sample.source === false) return [];
const result =
typeof sample.source === 'function'
? sample.source(endpoint, key)
: sample.source;
if (result === undefined) return [];
return {
...sample,
source: result,
};
}),
});
}
function renderRequest(sample: CollectedSample) {
return (
<renderer.Requests items={sample.samples.map((s) => s.label)}>
{sample.samples.map((s) => (
<renderer.Request
key={s.label}
name={s.label}
code={s.source}
language={s.lang}
/>
))}
</renderer.Requests>
);
}
if (
(samples.size === 1 && samples.has('_default')) ||
(method['x-exclusiveCodeSample'] &&
samples.has(method['x-exclusiveCodeSample']))
) {
// if exclusiveSampleKey is present, we don't use tabs
// if only the fallback or non described openapi legacy example is present, we don't use tabs
children = renderRequest(
samples.get(method['x-exclusiveCodeSample'] ?? '_default')!,
);
} else if (samples.size > 0) {
const entries = Array.from(samples.entries());
children = (
<renderer.Samples
items={entries.map(([key, sample]) => ({
title: sample.title,
description: sample.description ? (
<Markdown text={sample.description} />
) : null,
value: key,
}))}
defaultValue={method['x-selectedCodeSample']}
>
{entries.map(([key, sample]) => (
<renderer.Sample key={key} value={key}>
{renderRequest(sample)}
</renderer.Sample>
))}
</renderer.Samples>
);
}
return (
<renderer.APIExample>
{children}
<ResponseTabs operation={method} ctx={ctx} endpoint={endpoint} />
</renderer.APIExample>
);
}
/**
* Remove duplicated labels
*/
function dedupe(samples: CodeSample[]): CodeSample[] {
const set = new Set<string>();
const out: CodeSample[] = [];
for (let i = samples.length - 1; i >= 0; i--) {
if (set.has(samples[i].label)) continue;
set.add(samples[i].label);
out.unshift(samples[i]);
}
return out;
}
function ResponseTabs({
endpoint,
operation,
ctx: { renderer, generateTypeScriptSchema, schema },
}: {
endpoint: EndpointSample;
operation: MethodInformation;
ctx: RenderContext;
}) {
if (!operation.responses) return null;
async function renderResponse(code: string) {
const types: ResponseTypeProps[] = [];
let description = operation.responses?.[code].description;
if (!description && code in endpoint.responses)
description = endpoint.responses[code].schema.description ?? '';
if (code in endpoint.responses) {
types.push({
lang: 'json',
label: 'Response',
code: JSON.stringify(endpoint.responses[code].sample, null, 2),
});
}
let ts: string | undefined;
if (generateTypeScriptSchema) {
ts = await generateTypeScriptSchema(endpoint, code);
} else if (generateTypeScriptSchema === undefined) {
ts = await getTypescriptSchema(endpoint, code, schema.dereferenceMap);
}
if (ts) {
types.push({
code: ts,
lang: 'ts',
label: 'TypeScript',
});
}
return (
<renderer.Response value={code}>
{description ? <Markdown text={description} /> : null}
{types.length > 0 ? (
<renderer.ResponseTypes>
{types.map((type) => (
<renderer.ResponseType key={type.lang} {...type} />
))}
</renderer.ResponseTypes>
) : null}
</renderer.Response>
);
}
const codes = Object.keys(operation.responses);
if (codes.length === 0) return null;
return (
<renderer.Responses items={codes}>
{codes.map((code) => (
<renderer.Response key={code} value={code}>
{renderResponse(code)}
</renderer.Response>
))}
</renderer.Responses>
);
}
+11 -3
View File
@@ -12,6 +12,7 @@ import {
} from '@/ui';
import type { MethodInformation, RenderContext, ServerObject } from '@/types';
import { Playground } from '@/render/playground';
import { Sample, Samples } from '@/ui/client';
export interface ResponsesProps {
items: string[];
@@ -54,8 +55,13 @@ export interface SampleProps {
value: string;
children: ReactNode;
}
export interface SamplesProps {
items: string[];
items: {
title: string;
description?: ReactNode;
value: string;
}[];
children: ReactNode;
defaultValue?: string;
}
@@ -68,6 +74,8 @@ export interface ResponseTypeProps {
export interface RootProps {
baseUrl?: string;
shikiOptions?: RenderContext['shikiOptions'];
servers: ServerObject[];
children: ReactNode;
}
@@ -144,8 +152,8 @@ export function createRenders(
Requests: (props) => (
<Tabs groupId="fumadocs_openapi_requests" {...props} />
),
Samples: (props) => <Tabs {...props} />,
Sample: Tab,
Samples,
Sample,
Request: (props) => (
<Tab value={props.name}>
-10
View File
@@ -47,14 +47,6 @@ export interface WebhookItem {
export interface OperationItem {
path: string;
method: OpenAPIV3_1.HttpMethods;
/**
* The key of the sample to be selected
*/
selectedSampleKey?: string;
/**
* Only this sample will be shown
*/
exclusiveSampleKey?: string;
}
export async function APIPage(props: ApiPageProps) {
@@ -87,8 +79,6 @@ export async function APIPage(props: ApiPageProps) {
path={item.path}
ctx={ctx}
hasHead={hasHead}
selectedSampleKey={item.selectedSampleKey}
exclusiveSampleKey={item.exclusiveSampleKey}
/>
);
})}
+3 -20
View File
@@ -1,6 +1,5 @@
import type { BuildPageTreeOptions } from 'fumadocs-core/source';
import { cva } from 'class-variance-authority';
import { getBadgeColor } from '@/ui/components/variants';
import { MethodLabel } from '@/ui/components/method-label';
/**
* Source API Integration
@@ -21,31 +20,15 @@ export const attachFile: BuildPageTreeOptions['attachFile'] = (node, file) => {
}
if (method) {
const color = getBadgeColor(method);
node.name = (
<>
{node.name}{' '}
<span
className={badgeVariants({ className: 'ms-auto text-nowrap', color })}
>
<MethodLabel className="ms-auto text-xs text-nowrap">
{method}
</span>
</MethodLabel>
</>
);
}
return node;
};
const badgeVariants = cva('rounded-full border px-1.5 text-xs font-medium', {
variants: {
color: {
green: 'bg-green-400/20 text-green-600 dark:text-green-400',
yellow: 'bg-yellow-400/20 text-yellow-600 dark:text-yellow-400',
red: 'bg-red-400/20 text-red-600 dark:text-red-400',
blue: 'bg-blue-400/20 text-blue-600 dark:text-blue-400',
orange: 'bg-orange-400/20 text-orange-600 dark:text-orange-400',
},
},
});
+7 -33
View File
@@ -1,42 +1,16 @@
'use client';
import { type HTMLAttributes, type ReactNode } from 'react';
import { cn } from 'fumadocs-ui/components/api';
import dynamic from 'next/dynamic';
import { ApiProvider } from '@/ui/contexts/api';
import { type RootProps } from '@/render/renderer';
import type { RenderContext } from '@/types';
export const APIPlayground = dynamic(() =>
import('./playground').then((mod) => mod.APIPlayground),
);
export function Root({
children,
baseUrl,
className,
shikiOptions,
servers,
...props
}: RootProps & {
shikiOptions: RenderContext['shikiOptions'];
} & HTMLAttributes<HTMLDivElement>): ReactNode {
return (
<div
className={cn(
'flex flex-col gap-24 text-sm text-fd-muted-foreground',
className,
)}
{...props}
>
<ApiProvider
servers={servers}
shikiOptions={shikiOptions}
defaultBaseUrl={baseUrl}
>
{children}
</ApiProvider>
</div>
);
}
export const Samples = dynamic(() =>
import('./sample-select').then((mod) => mod.Samples),
);
export const Sample = dynamic(() =>
import('./sample-select').then((mod) => mod.Sample),
);
export { useSchemaContext } from './contexts/schema';
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import type { HTMLAttributes } from 'react';
import { cn } from 'fumadocs-ui/components/api';
const variants = cva('font-mono font-medium', {
export const badgeVariants = cva('font-mono font-medium', {
variants: {
color: {
green: 'text-green-600 dark:text-green-400',
@@ -14,7 +14,9 @@ const variants = cva('font-mono font-medium', {
},
});
function getBadgeColor(method: string): VariantProps<typeof variants>['color'] {
export function getBadgeColor(
method: string,
): VariantProps<typeof badgeVariants>['color'] {
switch (method.toUpperCase()) {
case 'PUT':
return 'yellow';
@@ -29,6 +31,27 @@ function getBadgeColor(method: string): VariantProps<typeof variants>['color'] {
}
}
export function Badge({
className,
color,
...props
}: Omit<HTMLAttributes<HTMLSpanElement>, 'color'> &
VariantProps<typeof badgeVariants>) {
return (
<span
className={cn(
badgeVariants({
color,
className,
}),
)}
{...props}
>
{props.children}
</span>
);
}
export function MethodLabel({
children,
...props
@@ -36,16 +59,8 @@ export function MethodLabel({
children: string;
}) {
return (
<span
{...props}
className={cn(
variants({
color: getBadgeColor(children),
}),
props.className,
)}
>
<Badge {...props} color={getBadgeColor(children)}>
{children.toUpperCase()}
</span>
</Badge>
);
}
@@ -16,7 +16,7 @@ const SelectTrigger = forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 items-center w-full rounded-md border px-3 py-2 text-start text-[13px] text-fd-foreground hover:bg-fd-accent focus:outline-none focus:ring-2 focus:ring-fd-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-10 items-center w-full rounded-md border px-3 py-2 text-start text-[13px] text-fd-foreground hover:bg-fd-accent focus:outline-none focus:ring-2 focus:ring-fd-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
@@ -1,33 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const badgeVariants = cva(
'rounded-xl border px-1.5 py-1 text-xs font-medium leading-[12px]',
{
variants: {
color: {
green: 'bg-green-400/20 text-green-600 dark:text-green-400',
yellow: 'bg-yellow-400/20 text-yellow-600 dark:text-yellow-400',
red: 'bg-red-400/20 text-red-600 dark:text-red-400',
blue: 'bg-blue-400/20 text-blue-600 dark:text-blue-400',
orange: 'bg-orange-400/20 text-orange-600 dark:text-orange-400',
},
},
},
);
export function getBadgeColor(
method: string,
): VariantProps<typeof badgeVariants>['color'] {
switch (method) {
case 'PUT':
return 'yellow';
case 'PATCH':
return 'orange';
case 'POST':
return 'blue';
case 'DELETE':
return 'red';
default:
return 'green';
}
}
+1
View File
@@ -1,3 +1,4 @@
'use client';
import {
createContext,
type ReactNode,
+36 -6
View File
@@ -1,15 +1,43 @@
import { buttonVariants, cn } from 'fumadocs-ui/components/api';
import { type HTMLAttributes, type ReactNode } from 'react';
import { badgeVariants } from '@/ui/components/variants';
import type { PropertyProps } from '@/render/renderer';
import { Badge } from '@/ui/components/method-label';
import type { PropertyProps, RootProps } from '@/render/renderer';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import { ApiProvider } from '@/ui/contexts/api';
export { Root, useSchemaContext, APIPlayground } from './client';
export { useSchemaContext, APIPlayground } from './client';
export function Root({
children,
baseUrl,
className,
shikiOptions,
servers,
...props
}: RootProps & HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex flex-col gap-24 text-sm text-fd-muted-foreground',
className,
)}
{...props}
>
<ApiProvider
servers={servers}
shikiOptions={shikiOptions}
defaultBaseUrl={baseUrl}
>
{children}
</ApiProvider>
</div>
);
}
export function APIInfo({
className,
@@ -55,12 +83,14 @@ export function Property({
<div className="flex flex-row flex-wrap items-center gap-4">
<code>{name}</code>
{required ? (
<div className={cn(badgeVariants({ color: 'red' }))}>Required</div>
<Badge color="red" className="text-xs">
Required
</Badge>
) : null}
{deprecated ? (
<div className={cn(badgeVariants({ color: 'yellow' }))}>
<Badge color="yellow" className="text-xs">
Deprecated
</div>
</Badge>
) : null}
<span className="ms-auto text-xs font-mono text-fd-muted-foreground">
{type}
+59
View File
@@ -0,0 +1,59 @@
import { createContext, use, useState } from 'react';
import type { SampleProps, SamplesProps } from '@/render/renderer';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
const ActiveSampleContext = createContext('');
export function Samples({
items,
defaultValue = items[0].value,
children,
}: SamplesProps) {
const [value, setValue] = useState('');
const active = value === '' ? defaultValue : value;
const defaultItem = items.find((item) => item.value === defaultValue);
return (
<>
<Select value={value} onValueChange={setValue}>
<SelectTrigger className="not-prose">
<SelectValue
placeholder={
defaultItem ? <SelectDisplay {...defaultItem} /> : null
}
/>
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<SelectDisplay {...item} />
</SelectItem>
))}
</SelectContent>
</Select>
<ActiveSampleContext value={active}>{children}</ActiveSampleContext>
</>
);
}
function SelectDisplay(item: SamplesProps['items'][number]) {
return (
<>
<span className="font-medium text-sm">{item.title}</span>
<span className="text-fd-muted-foreground">{item.description}</span>
</>
);
}
export function Sample({ value, children }: SampleProps) {
const active = use(ActiveSampleContext);
if (value !== active) return;
return children;
}
+1
View File
@@ -18,6 +18,7 @@
".source/**",
"!.contentlayer/.cache/**",
".next/**",
"content/openapi/**",
"!.next/cache/**",
"dist/**",
"out"