OpenAPI: improve performance

This commit is contained in:
Fuma Nama
2026-03-26 18:02:53 +08:00
parent 7aa317f0e8
commit b8a95e8afe
30 changed files with 490 additions and 539 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Improve performance
-1
View File
@@ -61,7 +61,6 @@
"@scalar/openapi-upgrader": "^0.2.2",
"ajv": "^8.18.0",
"class-variance-authority": "^0.7.1",
"dereference-json-schema": "^0.2.2",
"github-slugger": "^2.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"js-yaml": "^4.1.1",
+4 -5
View File
@@ -38,7 +38,6 @@ import { labelVariants } from '@/ui/components/input';
import type { ParsedSchema } from '@/utils/schema';
import ServerSelect from './components/server-select';
import { useStorageKey } from '@/ui/client/storage-key';
import { useExampleRequests } from '@/ui/operation/usage-tabs/client';
import {
FieldKey,
Stf,
@@ -53,6 +52,7 @@ import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/input
import type { ParameterObject } from '@/types';
import { ClientCodeBlock } from '@/ui/components/codeblock';
import { useTranslations } from '@/ui/client/i18n';
import { useOperationContext } from '@/ui/operation/client';
export interface FormValues extends Record<string, unknown> {
path: Record<string, unknown>;
@@ -141,7 +141,8 @@ export default function PlaygroundClient({
...rest
}: PlaygroundClientProps) {
const t = useTranslations();
const { example: exampleId, examples, setExampleData } = useExampleRequests();
const { example: exampleId, examples, setExampleData } = useOperationContext();
const { server } = useServerContext();
const storageKeys = useStorageKey();
const {
mediaAdapters,
@@ -153,7 +154,6 @@ export default function PlaygroundClient({
} = {},
},
} = useApiContext();
const { serverRef } = useServerContext();
const [securityId, setSecurityId] = useState(() => {
const idx = securities.findIndex((s) => s.every((entry) => !entry.deprecated));
return idx === -1 ? 0 : idx;
@@ -182,7 +182,6 @@ export default function PlaygroundClient({
});
const testQuery = useQuery(async (input: FormValues) => {
const targetServer = serverRef.current;
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters, requestTimeout),
);
@@ -194,7 +193,7 @@ export default function PlaygroundClient({
return fetcher.fetch(
joinURL(
withBase(
targetServer ? resolveServerUrl(targetServer.url, targetServer.variables) : '/',
server ? resolveServerUrl(server.url, server.variables) : '/',
window.location.origin,
),
resolveRequestData(route, encoded),
@@ -12,7 +12,7 @@ import {
import { Input, labelVariants } from '@/ui/components/input';
import { cn } from '@/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { FormatFlags } from '@/utils/schema-to-string';
import { FormatFlags } from '@/utils/schema/to-string';
import {
anyFields,
useFieldInfo,
@@ -1,5 +1,5 @@
'use client';
import { useServerContext, useServerSelectContext } from '@/ui/contexts/api';
import { useServerContext } from '@/ui/contexts/api';
import {
Select,
SelectContent,
@@ -26,8 +26,7 @@ import { EditIcon } from 'lucide-react';
import { useTranslations } from '@/ui/client/i18n';
export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>) {
const { servers } = useServerContext();
const { server, setServer, setServerVariables } = useServerSelectContext();
const { servers, server, setServer, setServerVariables } = useServerContext();
const [open, setOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const t = useTranslations();
@@ -36,7 +35,7 @@ export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>
setIsMounted(true);
}, []);
if (servers.length <= 0) return;
if (!servers || servers.length <= 0) return;
const serverSchema = server ? servers.find((obj) => obj.url === server.url) : null;
return (
+1 -1
View File
@@ -26,7 +26,7 @@ export type SecurityEntry = SecuritySchemeObject & {
id: string;
};
export async function APIPlayground({ path, method, ctx }: APIPlaygroundProps) {
export function APIPlayground({ path, method, ctx }: APIPlaygroundProps) {
if (ctx.playground?.render) {
return ctx.playground.render({ path, method, ctx });
}
+4 -8
View File
@@ -5,7 +5,8 @@ import { mergeAllOf } from '@/utils/merge-schema';
import { FieldKey, useDataEngine, useFieldValue, useNamespace } from '@fumari/stf';
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
import { sample } from 'openapi-sampler';
import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
import { FormatFlags, schemaToString } from '@/utils/schema/to-string';
import { resolveRefSync } from '@/utils/schema/resolve-ref';
interface SchemaContextType extends SchemaScope {
references: Record<string, ParsedSchema>;
@@ -201,13 +202,8 @@ export function useResolvedSchema(raw: ParsedSchema): Exclude<ParsedSchema, bool
function dereference(schema: ParsedSchema, references: Record<string, ParsedSchema>): ParsedSchema {
if (typeof schema === 'boolean') return schema;
let ref = schema.$ref;
if (ref) {
// use swallow resolution as it is already preprocessed in `playground/index.tsx`
const prefix = '#/';
if (ref.startsWith(prefix)) ref = ref.slice(prefix.length);
if (ref in references) return references[ref];
if (schema.$ref) {
return resolveRefSync(schema.$ref, references) as ParsedSchema;
}
return schema;
@@ -1,5 +1,4 @@
import type { CodeUsageGenerator } from '@/requests/generators';
import { generatePythonObject } from '@/requests/to-python-object';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const python: CodeUsageGenerator = {
@@ -56,3 +55,28 @@ response = requests.request(${params.join(', ')})
print(response.text)`;
},
};
export function generatePythonObject(v: unknown, imports: Set<string>): string {
if (v === null) {
return 'None';
} else if (typeof v === 'boolean') {
return v ? 'True' : 'False';
} else if (typeof v === 'string') {
return JSON.stringify(v);
} else if (typeof v === 'number') {
return v.toString();
} else if (Array.isArray(v)) {
const items = v.map((item) => generatePythonObject(item, imports));
return `[${items.join(', ')}]`;
} else if (v instanceof Date) {
imports.add('datetime');
return `datetime.datetime(${v.getFullYear()}, ${v.getMonth() + 1}, ${v.getDate()}, ${v.getHours()}, ${v.getMinutes()}, ${v.getSeconds()}, ${v.getMilliseconds()})`;
} else if (typeof v === 'object') {
const entries = Object.entries(v).map(
([key, value]) => ` ${JSON.stringify(key)}: ${generatePythonObject(value, imports)}`,
);
return `{\n${entries.join(', \n')}\n}`;
} else {
throw new Error(`Unsupported type: ${typeof v}`);
}
}
@@ -1,24 +0,0 @@
export function generatePythonObject(v: unknown, imports = new Set()): string {
if (v === null) {
return 'None';
} else if (typeof v === 'boolean') {
return v ? 'True' : 'False';
} else if (typeof v === 'string') {
return JSON.stringify(v);
} else if (typeof v === 'number') {
return v.toString();
} else if (Array.isArray(v)) {
const items = v.map((item) => generatePythonObject(item, imports));
return `[${items.join(', ')}]`;
} else if (v instanceof Date) {
imports.add('datetime');
return `datetime.datetime(${v.getFullYear()}, ${v.getMonth() + 1}, ${v.getDate()}, ${v.getHours()}, ${v.getMinutes()}, ${v.getSeconds()}, ${v.getMilliseconds()})`;
} else if (typeof v === 'object') {
const entries = Object.entries(v).map(
([key, value]) => ` ${JSON.stringify(key)}: ${generatePythonObject(value, imports)}`,
);
return `{\n${entries.join(', \n')}\n}`;
} else {
throw new Error(`Unsupported type: ${typeof v}`);
}
}
+7 -13
View File
@@ -37,24 +37,18 @@ export function createOpenAPI(options: OpenAPIOptions = {}): OpenAPIServer {
const { input = [], disableCache = false } = options;
let schemas: Promise<ProcessedSchemaMap> | undefined;
async function getSchemas() {
const out: ProcessedSchemaMap = {};
async function getSchemas(): Promise<ProcessedSchemaMap> {
if (Array.isArray(input)) {
await Promise.all(
input.map(async (item) => {
out[item] = await processDocument(item);
}),
const entries = await Promise.all(
input.map(async (item) => [item, await processDocument(item)]),
);
return Object.fromEntries(entries);
} else {
await Promise.all(
Object.entries(await input()).map(async ([k, v]) => {
out[k] = await processDocument(v);
}),
const entries = await Promise.all(
Object.entries(await input()).map(async ([k, v]) => [k, await processDocument(v)]),
);
return Object.fromEntries(entries);
}
return out;
}
return {
+6 -6
View File
@@ -1,5 +1,5 @@
import { Operation } from '@/ui/operation';
import type { HttpMethods, RenderContext } from '@/types';
import type { HttpMethods, RenderContext, ServerObject } from '@/types';
import { createMethod } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import { ApiProviderLazy, ServerProviderLazy } from './contexts/api.lazy';
@@ -36,7 +36,7 @@ export interface OperationItem {
method: HttpMethods;
}
export async function APIPage({
export function APIPage({
showTitle: hasHead = false,
showDescription,
operations,
@@ -54,7 +54,7 @@ export async function APIPage({
</div>
);
const content = await renderPageLayout(
const content = renderPageLayout(
{
operations: operations?.map((item) => {
const pathItem = dereferenced.paths?.[item.path];
@@ -112,12 +112,12 @@ export async function APIPage({
},
ctx,
);
let servers = ctx.schema.dereferenced.servers;
if (!servers || servers.length === 0) servers = [{ url: '/' }];
return (
<ApiProviderLazy shikiOptions={ctx.shikiOptions} client={ctx.client ?? {}}>
<ServerProviderLazy servers={servers}>{content}</ServerProviderLazy>
<ServerProviderLazy servers={dereferenced.servers as ServerObject[]}>
{content}
</ServerProviderLazy>
</ApiProviderLazy>
);
}
+10 -13
View File
@@ -18,12 +18,12 @@ import * as JsxRuntime from 'react/jsx-runtime';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
import type { SchemaUIOptions } from './schema';
import type { ResponseTab } from './operation/response-tabs';
import type { ExampleRequestItem } from './operation/request-tabs';
import { APIPage, type ApiPageProps, type OperationItem, type WebhookItem } from './api-page';
import type { CodeUsageGeneratorRegistry, InlineCodeUsageGenerator } from '@/requests/generators';
import type { JSONSchema } from 'json-schema-typed';
import type { BundledTheme, CodeOptionsThemes, CodeToHastOptionsCommon } from 'shiki';
import { highlightHast, type ShikiFactory } from 'fumadocs-core/highlight/shiki';
import type { ExampleRequestItem } from './operation/get-example-requests';
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
operation: NoReference<MethodInformation>;
@@ -95,7 +95,7 @@ export interface CreateAPIPageOptions {
* Customise page content
*/
content?: {
renderResponseTabs?: (tabs: ResponseTab[], ctx: RenderContext) => Awaitable<ReactNode>;
renderResponseTabs?: (tabs: ResponseTab[], ctx: RenderContext) => ReactNode;
renderRequestTabs?: (
items: ExampleRequestItem[],
@@ -103,7 +103,7 @@ export interface CreateAPIPageOptions {
route: string;
operation: NoReference<MethodInformation>;
},
) => Awaitable<ReactNode>;
) => ReactNode;
renderAPIExampleLayout?: (
slots: {
@@ -112,7 +112,7 @@ export interface CreateAPIPageOptions {
responseTabs: ReactNode;
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
) => ReactNode;
/**
* @param generators - codegens for API example usages
@@ -120,7 +120,7 @@ export interface CreateAPIPageOptions {
renderAPIExampleUsageTabs?: (
generators: CodeUsageGeneratorRegistry,
ctx: RenderContext,
) => Awaitable<ReactNode>;
) => ReactNode;
/**
* renderer of the entire page's layout (containing all operations & webhooks UI)
@@ -137,7 +137,7 @@ export interface CreateAPIPageOptions {
}[];
},
ctx: RenderContext,
) => Awaitable<ReactNode>;
) => ReactNode;
renderOperationLayout?: (
slots: {
@@ -154,7 +154,7 @@ export interface CreateAPIPageOptions {
},
ctx: RenderContext,
method: NoReference<MethodInformation>,
) => Awaitable<ReactNode>;
) => ReactNode;
renderWebhookLayout?: (slots: {
header: ReactNode;
@@ -165,7 +165,7 @@ export interface CreateAPIPageOptions {
requests: ReactNode;
responses: ReactNode;
callbacks: ReactNode;
}) => Awaitable<ReactNode>;
}) => ReactNode;
};
/**
@@ -200,10 +200,7 @@ export interface CreateAPIPageOptions {
}) => Awaitable<ReactNode>;
};
renderHeading?: (
props: HTMLAttributes<HTMLHeadingElement>,
depth: number,
) => Awaitable<ReactNode>;
renderHeading?: (props: HTMLAttributes<HTMLHeadingElement>, depth: number) => ReactNode;
renderCodeBlock?: (props: { lang: string; code: string }) => Awaitable<ReactNode>;
client?: APIPageClientOptions;
@@ -258,7 +255,7 @@ export function createAPIPage(
...options.mediaAdapters,
},
slugger,
async renderHeading(depth, text, props) {
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slugger.slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
+1 -1
View File
@@ -2,8 +2,8 @@
import type { PlaygroundClientOptions } from '@/playground/client';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { FC } from 'react';
import type { ExampleRequestItem } from '../operation/request-tabs';
import type { CodeUsageGeneratorRegistry } from '@/requests/generators';
import type { ExampleRequestItem } from '../operation/get-example-requests';
export interface APIPageClientOptions {
playground?: PlaygroundClientOptions;
@@ -17,6 +17,6 @@ export function useStorageKey() {
);
}
export function getStorageKey(prefix = 'fumadocs-openapi-', name: KeyName) {
function getStorageKey(prefix = 'fumadocs-openapi-', name: KeyName) {
return prefix + name;
}
@@ -0,0 +1,62 @@
'use client';
import { cn } from '@/utils/cn';
import { SelectTrigger, Select, SelectValue, SelectContent, SelectItem } from './select';
import { type ReactNode, useState, useMemo, type ComponentProps, createContext, use } from 'react';
const Context = createContext<{
type: string | null;
setType: (type: string) => void;
} | null>(null);
export function SelectTabs({
defaultValue,
children,
}: {
defaultValue?: string;
children: ReactNode;
}) {
const [type, setType] = useState<string | null>(defaultValue ?? null);
return <Context value={useMemo(() => ({ type, setType }), [type])}>{children}</Context>;
}
export function SelectTab({
value,
...props
}: ComponentProps<'div'> & {
value: string;
}) {
const ctx = use(Context);
if (value !== ctx?.type) return;
return <div {...props}>{props.children}</div>;
}
export function SelectTabTrigger({
items,
className,
...props
}: ComponentProps<typeof SelectTrigger> & {
items: {
label: ReactNode;
value: string;
}[];
}) {
const { type, setType } = use(Context)!;
return (
<Select value={type ?? ''} onValueChange={setType}>
<SelectTrigger className={cn('not-prose w-fit min-w-0 *:min-w-0', className)} {...props}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+28 -76
View File
@@ -1,17 +1,7 @@
'use client';
import {
createContext,
type ReactNode,
type RefObject,
use,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createContext, type ReactNode, use, useEffect, useMemo, useState } from 'react';
import type { RenderContext, ServerObject } from '@/types';
import { defaultAdapters, type MediaAdapter } from '@/requests/media/adapter';
import type { NoReference } from '@/utils/schema';
import { useStorageKey } from '../client/storage-key';
import type { APIPageClientOptions } from '../client';
import {
@@ -24,24 +14,13 @@ interface InheritFromContext extends Pick<RenderContext, 'shikiOptions'> {
client: APIPageClientOptions;
}
export interface ServerProviderProps {
/**
* Base URL for API requests
*/
defaultBaseUrl?: string;
servers: NoReference<ServerObject>[];
interface ServerContextType {
servers?: ServerObject[];
server: SelectedServer | null;
setServer: (value: string) => void;
setServerVariables: (value: Record<string, string>) => void;
}
interface ServerContextType extends ServerProviderProps {
/**
* ref to selected API server (to query)
*/
serverRef: RefObject<SelectedServer | null>;
}
const ServerContext = createContext<ServerContextType | null>(null);
export type ApiProviderProps = InheritFromContext;
export interface SelectedServer {
@@ -55,14 +34,8 @@ interface ApiContextType extends InheritFromContext {
codeUsages: CodeUsageGeneratorRegistry;
}
interface ServerSelectType {
server: SelectedServer | null;
setServer: (value: string) => void;
setServerVariables: (value: Record<string, string>) => void;
}
const ApiContext = createContext<ApiContextType | null>(null);
const ServerSelectContext = createContext<ServerSelectType | null>(null);
const ServerContext = createContext<ServerContextType | null>(null);
export function useApiContext(): ApiContextType {
const ctx = use(ApiContext);
@@ -72,11 +45,7 @@ export function useApiContext(): ApiContextType {
}
export function useServerContext() {
return use(ServerContext)!;
}
export function useServerSelectContext(): ServerSelectType {
const ctx = use(ServerSelectContext);
const ctx = use(ServerContext);
if (!ctx) throw new Error('Component must be used under <ApiProvider />');
return ctx;
@@ -116,43 +85,25 @@ export function ApiProvider({
export function ServerProvider({
servers,
defaultBaseUrl,
children,
}: ServerProviderProps & { children: ReactNode }) {
const serverRef = useRef<SelectedServer | null>(null);
return (
<ServerContext value={useMemo(() => ({ servers, serverRef }), [servers])}>
<ServerSelectProvider defaultBaseUrl={defaultBaseUrl}>{children}</ServerSelectProvider>
</ServerContext>
);
}
function ServerSelectProvider({
defaultBaseUrl,
children,
}: Pick<ServerProviderProps, 'defaultBaseUrl'> & {
}: {
servers?: ServerObject[];
children: ReactNode;
}) {
const { servers, serverRef } = use(ServerContext)!;
const storageKeys = useStorageKey();
const storageKey = useStorageKey().of('server-url');
const [server, setServer] = useState<SelectedServer | null>(() => {
const defaultItem = defaultBaseUrl
? servers.find((item) => item.url === defaultBaseUrl)
: servers[0];
if (!servers || servers.length === 0) return null;
const defaultItem = servers[0];
return defaultItem
? {
name: defaultItem.name,
url: defaultItem.url!,
variables: getDefaultValues(defaultItem),
}
: null;
return {
name: defaultItem.name,
url: defaultItem.url!,
variables: getDefaultValues(defaultItem),
};
});
serverRef.current = server;
useEffect(() => {
const cached = localStorage.getItem(storageKeys.of('server-url'));
const cached = localStorage.getItem(storageKey);
if (!cached) return;
try {
@@ -171,24 +122,25 @@ function ServerSelectProvider({
} catch {
// ignore
}
}, [storageKeys]);
}, [storageKey]);
return (
<ServerSelectContext
<ServerContext
value={useMemo(
() => ({
servers,
server,
setServerVariables(variables) {
setServer((prev) => {
if (!prev) return null;
const updated = { ...prev, variables };
localStorage.setItem(storageKeys.of('server-url'), JSON.stringify(updated));
localStorage.setItem(storageKey, JSON.stringify(updated));
return updated;
});
},
setServer(value) {
const obj = servers.find((item) => item.url === value);
const obj = servers?.find((item) => item.url === value);
if (!obj) return;
const result: SelectedServer = {
@@ -197,19 +149,19 @@ function ServerSelectProvider({
variables: getDefaultValues(obj),
};
localStorage.setItem(storageKeys.of('server-url'), JSON.stringify(result));
localStorage.setItem(storageKey, JSON.stringify(result));
setServer(result);
},
}),
[server, servers, storageKeys],
[server, servers, storageKey],
)}
>
{children}
</ServerSelectContext>
</ServerContext>
);
}
function getDefaultValues(server: NoReference<ServerObject>): Record<string, string> {
function getDefaultValues(server: ServerObject): Record<string, string> {
const out: Record<string, string> = {};
if (!server.variables) return out;
+84 -65
View File
@@ -4,15 +4,91 @@ import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from '@/utils/cn';
import { Check, Copy } from 'lucide-react';
import { type ComponentProps, createContext, type ReactNode, use, useMemo, useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
import { createContext, type ReactNode, use, useMemo, useRef, useState } from 'react';
import { useTranslations } from '@/ui/client/i18n';
import type { ExampleRequestItem } from './get-example-requests';
import type { RawRequestData, RequestData } from '@/requests/types';
export type ExampleUpdateListener = (data: RawRequestData, encoded: RequestData) => void;
const OperationContext = createContext<{
route: string;
examples: ExampleRequestItem[];
example: string | undefined;
setExample: (id: string) => void;
setExampleData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: ExampleUpdateListener) => void;
removeListener: (listener: ExampleUpdateListener) => void;
} | null>(null);
export function OperationProvider({
route,
examples,
defaultExampleId,
children,
}: {
route: string;
examples: ExampleRequestItem[];
defaultExampleId?: string;
children: ReactNode;
}) {
const [example, setExample] = useState(() => defaultExampleId ?? examples.at(0)?.id);
const listeners = useRef<ExampleUpdateListener[]>([]);
return (
<OperationContext
value={useMemo(
() => ({
example,
route,
setExample(newKey: string) {
const example = examples.find((example) => example.id === newKey);
if (!example) return;
setExample(newKey);
for (const listener of listeners.current) {
listener(example.data, example.encoded);
}
},
examples,
setExampleData(data, encoded) {
for (const item of examples) {
if (item.id === example) {
// persistent changes
item.data = data;
item.encoded = encoded;
break;
}
}
for (const listener of listeners.current) {
listener(data, encoded);
}
},
removeListener(listener) {
listeners.current = listeners.current.filter((item) => item !== listener);
},
addListener(listener) {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
const active = examples.find((item) => item.id === example)!;
listener(active.data, active.encoded);
listeners.current.push(listener);
},
}),
[example, route, examples],
)}
>
{children}
</OperationContext>
);
}
export function useOperationContext() {
return use(OperationContext)!;
}
export function CopyTypeScriptPanel({
name,
@@ -56,60 +132,3 @@ export function CopyTypeScriptPanel({
</div>
);
}
const Context = createContext<{
type: string | null;
setType: (type: string) => void;
} | null>(null);
export function SelectTabs({
defaultValue,
children,
}: {
defaultValue?: string;
children: ReactNode;
}) {
const [type, setType] = useState<string | null>(defaultValue ?? null);
return <Context value={useMemo(() => ({ type, setType }), [type])}>{children}</Context>;
}
export function SelectTab({
value,
...props
}: ComponentProps<'div'> & {
value: string;
}) {
const ctx = use(Context);
if (value !== ctx?.type) return;
return <div {...props}>{props.children}</div>;
}
export function SelectTabTrigger({
items,
className,
...props
}: ComponentProps<typeof SelectTrigger> & {
items: {
label: ReactNode;
value: string;
}[];
}) {
const { type, setType } = use(Context)!;
return (
<Select value={type ?? ''} onValueChange={setType}>
<SelectTrigger className={cn('not-prose w-fit min-w-0 *:min-w-0', className)} {...props}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,124 @@
import { encodeRequestData } from '@/requests/media/encode';
import type { RawRequestData, RequestData } from '@/requests/types';
import type { MethodInformation, RenderContext } from '@/types';
import { type NoReference, getPreferredType, pickExample } from '@/utils/schema';
import { sample } from 'openapi-sampler';
export interface ExampleRequestItem {
id: string;
name: string;
description?: string;
data: RawRequestData;
encoded: RequestData;
}
export function getExampleRequests(
path: string,
operation: NoReference<MethodInformation>,
ctx: RenderContext,
): ExampleRequestItem[] {
const requestBody = operation.requestBody;
const media = requestBody?.content ? getPreferredType(requestBody.content) : null;
const bodyOfType = media ? requestBody!.content![media] : null;
if (bodyOfType?.examples) {
const result: ExampleRequestItem[] = [];
for (const [key, value] of Object.entries(bodyOfType.examples)) {
const data = getRequestData(path, operation, key, ctx);
result.push({
id: key,
name: value.summary || key,
description: value.description,
data,
encoded: encodeRequestData(data, ctx.mediaAdapters, operation.parameters ?? []),
});
}
if (result.length > 0) return result;
}
const data = getRequestData(path, operation, null, ctx);
return [
{
id: '_default',
name: 'Default',
description:
typeof bodyOfType?.schema === 'object' ? bodyOfType.schema.description : undefined,
data,
encoded: encodeRequestData(data, ctx.mediaAdapters, operation.parameters ?? []),
},
];
}
function getRequestData(
path: string,
method: NoReference<MethodInformation>,
sampleKey: string | null,
_ctx: RenderContext,
): RawRequestData {
const result: RawRequestData = {
path: {},
cookie: {},
header: {},
query: {},
method: method.method,
};
for (const param of method.parameters ?? []) {
let value = pickExample(param as never);
if (value === undefined && param.required) {
if (param.schema) {
value = sample(param.schema as object);
} else if (param.content) {
const type = getPreferredType(param.content);
const content = type ? param.content[type] : undefined;
if (!content || !content.schema)
throw new Error(
`Cannot find "${param.name}" parameter info for media type "${type}" in ${path} ${method.method}`,
);
value = sample(content.schema as object);
}
}
switch (param.in) {
case 'cookie':
result.cookie[param.name!] = value;
break;
case 'header':
result.header[param.name!] = value;
break;
case 'query':
result.query[param.name!] = value;
break;
default:
result.path[param.name!] = value;
}
}
if (method.requestBody?.content) {
const body = method.requestBody.content;
const type = getPreferredType(body);
if (!type)
throw new Error(`Cannot find body schema for ${path} ${method.method}: missing media type`);
result.bodyMediaType = type as RawRequestData['bodyMediaType'];
const bodyOfType = body[type];
if (bodyOfType.examples && sampleKey) {
result.body = bodyOfType.examples[sampleKey].value;
} else if (bodyOfType.example) {
result.body = bodyOfType.example;
} else {
result.body = sample((bodyOfType?.schema ?? {}) as object, {
skipReadOnly: method.method !== 'GET',
skipWriteOnly: method.method === 'GET',
skipNonRequired: true,
});
}
}
return result;
}
+19 -11
View File
@@ -4,13 +4,14 @@ import type {
MethodInformation,
RenderContext,
SecuritySchemeObject,
ServerObject,
} from '@/types';
import { createMethod, methodKeys, type NoReference, type ResolvedSchema } from '@/utils/schema';
import { idToTitle } from '@/utils/id-to-title';
import { Schema } from '../schema';
import { UsageTabs } from '@/ui/operation/usage-tabs';
import { Badge, MethodLabel } from '@/ui/components/method-label';
import { CopyTypeScriptPanel, SelectTab, SelectTabs, SelectTabTrigger } from './client';
import { CopyTypeScriptPanel, OperationProvider } from './client';
import { I18nLabel } from '@/ui/client/i18n';
import {
AccordionContent,
@@ -21,14 +22,15 @@ import {
} from '@/ui/components/accordion';
import { isMediaTypeSupported } from '@/requests/media/adapter';
import { APIPlayground } from '@/playground';
import { getExampleRequests, RequestTabs } from './request-tabs';
import { UsageTabsProviderLazy } from './usage-tabs/lazy';
import { RequestTabs } from './request-tabs';
import { ServerProviderLazy } from '../contexts/api.lazy';
import { cn } from '@/utils/cn';
import { getExampleRequests } from './get-example-requests';
import { SelectTabs, SelectTabTrigger, SelectTab } from '../components/server-tab';
const paramTypeKeys = ['path', 'query', 'header', 'cookie'] as const;
export async function Operation({
export function Operation({
type = 'operation',
path,
method,
@@ -261,6 +263,8 @@ export async function Operation({
}
let { renderOperationLayout, renderWebhookLayout } = ctx.content ?? {};
const exampleRequests = getExampleRequests(path, method, ctx);
if (type === 'operation') {
renderOperationLayout ??= (slots) => {
return (
@@ -283,7 +287,7 @@ export async function Operation({
};
const playgroundEnabled = ctx.playground?.enabled ?? true;
let content = await renderOperationLayout(
let content = renderOperationLayout(
{
header: headNode,
description: descriptionNode,
@@ -309,16 +313,20 @@ export async function Operation({
);
content = (
<UsageTabsProviderLazy
<OperationProvider
defaultExampleId={method['x-exclusiveCodeSample'] ?? method['x-selectedCodeSample']}
route={path}
examples={getExampleRequests(path, method, ctx)}
examples={exampleRequests}
>
{content}
</UsageTabsProviderLazy>
</OperationProvider>
);
if (method.servers) {
content = <ServerProviderLazy servers={method.servers}>{content}</ServerProviderLazy>;
content = (
<ServerProviderLazy servers={method.servers as ServerObject[]}>
{content}
</ServerProviderLazy>
);
}
return content;
@@ -347,12 +355,12 @@ export async function Operation({
callbacks: callbacksNode,
parameters: parameterNode,
responses: responseNode,
requests: <RequestTabs path={path} operation={method} ctx={ctx} />,
requests: <RequestTabs examples={exampleRequests} path={path} operation={method} ctx={ctx} />,
});
}
}
async function ResponseAccordion({
function ResponseAccordion({
status,
operation,
ctx,
@@ -1,5 +1,5 @@
import type { MethodInformation, RenderContext } from '@/types';
import { getPreferredType, type NoReference, pickExample } from '@/utils/schema';
import type { NoReference } from '@/utils/schema';
import { I18nLabel } from '@/ui/client/i18n';
import {
AccordionContent,
@@ -8,146 +8,28 @@ import {
Accordions,
AccordionTrigger,
} from '@/ui/components/accordion';
import { sample } from 'openapi-sampler';
import type { ReactNode } from 'react';
import type { RawRequestData, RequestData } from '@/requests/types';
import { encodeRequestData } from '@/requests/media/encode';
import type { RawRequestData } from '@/requests/types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
import { resolveRequestData } from '@/utils/url';
import { MethodLabel } from '../components/method-label';
import type { ExampleRequestItem } from './get-example-requests';
export interface ExampleRequestItem {
id: string;
name: string;
description?: string;
data: RawRequestData;
encoded: RequestData;
}
export function getExampleRequests(
path: string,
operation: NoReference<MethodInformation>,
ctx: RenderContext,
): ExampleRequestItem[] {
const requestBody = operation.requestBody;
const media = requestBody?.content ? getPreferredType(requestBody.content) : null;
const bodyOfType = media ? requestBody!.content![media] : null;
if (bodyOfType?.examples) {
const result: ExampleRequestItem[] = [];
for (const [key, value] of Object.entries(bodyOfType.examples)) {
const data = getRequestData(path, operation, key, ctx);
result.push({
id: key,
name: value.summary || key,
description: value.description,
data,
encoded: encodeRequestData(data, ctx.mediaAdapters, operation.parameters ?? []),
});
}
if (result.length > 0) return result;
}
const data = getRequestData(path, operation, null, ctx);
return [
{
id: '_default',
name: 'Default',
description:
typeof bodyOfType?.schema === 'object' ? bodyOfType.schema.description : undefined,
data,
encoded: encodeRequestData(data, ctx.mediaAdapters, operation.parameters ?? []),
},
];
}
function getRequestData(
path: string,
method: NoReference<MethodInformation>,
sampleKey: string | null,
_ctx: RenderContext,
): RawRequestData {
const result: RawRequestData = {
path: {},
cookie: {},
header: {},
query: {},
method: method.method,
};
for (const param of method.parameters ?? []) {
let value = pickExample(param as never);
if (value === undefined && param.required) {
if (param.schema) {
value = sample(param.schema as object);
} else if (param.content) {
const type = getPreferredType(param.content);
const content = type ? param.content[type] : undefined;
if (!content || !content.schema)
throw new Error(
`Cannot find "${param.name}" parameter info for media type "${type}" in ${path} ${method.method}`,
);
value = sample(content.schema as object);
}
}
switch (param.in) {
case 'cookie':
result.cookie[param.name!] = value;
break;
case 'header':
result.header[param.name!] = value;
break;
case 'query':
result.query[param.name!] = value;
break;
default:
result.path[param.name!] = value;
}
}
if (method.requestBody?.content) {
const body = method.requestBody.content;
const type = getPreferredType(body);
if (!type)
throw new Error(`Cannot find body schema for ${path} ${method.method}: missing media type`);
result.bodyMediaType = type as RawRequestData['bodyMediaType'];
const bodyOfType = body[type];
if (bodyOfType.examples && sampleKey) {
result.body = bodyOfType.examples[sampleKey].value;
} else if (bodyOfType.example) {
result.body = bodyOfType.example;
} else {
result.body = sample((bodyOfType?.schema ?? {}) as object, {
skipReadOnly: method.method !== 'GET',
skipWriteOnly: method.method === 'GET',
skipNonRequired: true,
});
}
}
return result;
}
export async function RequestTabs({
export function RequestTabs({
path,
operation,
examples,
ctx,
}: {
path: string;
examples: ExampleRequestItem[];
operation: NoReference<MethodInformation>;
ctx: RenderContext;
}) {
if (!operation.requestBody) return null;
const { renderRequestTabs = renderRequestTabsDefault } = ctx.content ?? {};
return renderRequestTabs(getExampleRequests(path, operation, ctx), {
return renderRequestTabs(examples, {
...ctx,
route: path,
operation,
@@ -1,5 +1,5 @@
'use client';
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import { useApiContext, useServerContext } from '@/ui/contexts/api';
import { joinURL, withBase, resolveServerUrl, resolveRequestData } from '@/utils/url';
import {
Select,
@@ -8,95 +8,14 @@ import {
SelectContent,
SelectItem,
} from '@/ui/components/select';
import { useState, useEffect, useMemo, createContext, ReactNode, useRef, use } from 'react';
import type { ExampleRequestItem } from '../request-tabs';
import type { RawRequestData, RequestData } from '@/requests/types';
import { useState, useEffect, useMemo } from 'react';
import type { CodeUsageGenerator } from '@/requests/generators';
import { ClientCodeBlock } from '@/ui/components/codeblock';
export type ExampleUpdateListener = (data: RawRequestData, encoded: RequestData) => void;
const Context = createContext<{
route: string;
examples: ExampleRequestItem[];
example: string | undefined;
setExample: (id: string) => void;
setExampleData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: ExampleUpdateListener) => void;
removeListener: (listener: ExampleUpdateListener) => void;
} | null>(null);
export function UsageTabsProvider({
route,
examples,
defaultExampleId,
children,
}: {
route: string;
examples: ExampleRequestItem[];
defaultExampleId?: string;
children: ReactNode;
}) {
const [example, setExample] = useState(() => defaultExampleId ?? examples.at(0)?.id);
const listeners = useRef<ExampleUpdateListener[]>([]);
return (
<Context
value={useMemo(
() => ({
example,
route,
setExample(newKey: string) {
const example = examples.find((example) => example.id === newKey);
if (!example) return;
setExample(newKey);
for (const listener of listeners.current) {
listener(example.data, example.encoded);
}
},
examples,
setExampleData(data, encoded) {
for (const item of examples) {
if (item.id === example) {
// persistent changes
item.data = data;
item.encoded = encoded;
break;
}
}
for (const listener of listeners.current) {
listener(data, encoded);
}
},
removeListener(listener) {
listeners.current = listeners.current.filter((item) => item !== listener);
},
addListener(listener) {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
const active = examples.find((item) => item.id === example)!;
listener(active.data, active.encoded);
listeners.current.push(listener);
},
}),
[example, route, examples],
)}
>
{children}
</Context>
);
}
export function useExampleRequests() {
return use(Context)!;
}
import { type ExampleUpdateListener, useOperationContext } from '../client';
import type { ExampleRequestItem } from '../get-example-requests';
export function UsageTabsSelector() {
const { example: key, setExample: setKey, examples } = useExampleRequests();
const { example: key, setExample: setKey, examples } = useOperationContext();
const { APIExampleSelector: Override } = useApiContext().client.operation ?? {};
if (Override) {
@@ -142,8 +61,8 @@ export function UsageTab({
route,
addListener,
removeListener,
} = useExampleRequests();
const { server } = useServerSelectContext();
} = useOperationContext();
const { server } = useServerContext();
const codegen = codeUsages.get(id);
const [data, setData] = useState(
() => examples.find((example) => example.id === selectedExampleId)?.encoded,
@@ -1,5 +1,4 @@
import type { MethodInformation, RenderContext } from '@/types';
import type { CodeUsageGeneratorFn } from '@/requests/generators';
import {
type CodeUsageGeneratorRegistry,
createCodeUsageGeneratorRegistry,
@@ -14,27 +13,6 @@ import { UsageTabsSelectorLazy, UsageTabLazy } from './lazy';
import { ResponseTabs } from '../response-tabs';
import { registerDefault } from '@/requests/generators/all';
/**
* Generate code example for given programming language
*/
export interface CodeUsageGenerator<T = unknown> {
id: string;
lang: string;
label?: string;
/**
* either:
* - code
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | CodeUsageGeneratorFn<T> | false;
/**
* Pass extra context to client-side source generator
*/
serverContext?: T;
}
export async function UsageTabs({
method,
ctx,
@@ -97,7 +75,7 @@ export async function UsageTabs({
return renderAPIExampleLayout(
{
selector: method['x-exclusiveCodeSample'] ? null : <UsageTabsSelectorLazy />,
usageTabs: await renderAPIExampleUsageTabs(registry, ctx),
usageTabs: renderAPIExampleUsageTabs(registry, ctx),
responseTabs: <ResponseTabs operation={method} ctx={ctx} />,
},
ctx,
@@ -8,7 +8,3 @@ export const UsageTabsSelectorLazy = wrapLazy(() =>
export const UsageTabLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTab })),
);
export const UsageTabsProviderLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTabsProvider })),
);
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import type { ResolvedSchema } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
import { FormatFlags, schemaToString } from '@/utils/schema/to-string';
import { mergeAllOf } from '@/utils/merge-schema';
import type { SchemaUIProps } from '@/ui/schema/client';
import { SchemaUILazy } from '@/ui/schema/lazy';
+1 -58
View File
@@ -4,7 +4,7 @@ import { bundle } from '@scalar/json-magic/bundle';
import { upgrade } from '@scalar/openapi-upgrader';
import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node';
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import dereference from 'dereference-json-schema';
import { dereferenceSync } from './schema/dereference';
export interface ProcessedDocument {
/**
@@ -55,60 +55,3 @@ export async function processDocument(input: string | Document): Promise<Process
bundled,
};
}
/**
* Resolves all $ref pointers in a schema and returns a new schema without any $ref pointers.
*/
function dereferenceSync(
schema: JSONSchema,
setOriginalRef: (schema: JSONSchema, ref: string) => void,
): JSONSchema {
if (typeof schema === 'boolean') return schema;
const visitedNodes = new Set<unknown>();
const cloned = structuredClone(schema);
function resolve(current: unknown): JSONSchema {
if (typeof current === 'object' && current !== null) {
// make sure we don't visit the same node twice
if (visitedNodes.has(current)) {
return current;
}
visitedNodes.add(current);
if (Array.isArray(current)) {
// array
for (let index = 0; index < current.length; index++) {
current[index] = resolve(current[index]);
}
return current as JSONSchema;
}
const obj = current as Record<string, unknown>;
// object
if ('$ref' in current && typeof current['$ref'] === 'string') {
const ref = current['$ref'];
delete current['$ref'];
const resolved = resolve(dereference.resolveRefSync(cloned as never, ref) as JSONSchema);
setOriginalRef(resolved, ref);
setOriginalRef(current as JSONSchema, ref);
if (typeof resolved === 'boolean') throw new Error('invalid schema');
for (const k in resolved) {
if (!(k in current)) {
obj[k] = resolved[k as never];
}
}
}
for (const key in current) {
obj[key] = resolve(obj[key]);
}
}
return current as JSONSchema;
}
return resolve(cloned);
}
@@ -0,0 +1,59 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { resolveRefSync } from './resolve-ref';
/**
* Resolves all $ref pointers in a schema and returns a new schema without any $ref pointers.
*/
export function dereferenceSync(
schema: JSONSchema,
setOriginalRef: (schema: JSONSchema, ref: string) => void,
): JSONSchema {
if (typeof schema === 'boolean') return schema;
const visitedNodes = new Set<unknown>();
const cloned = structuredClone(schema);
function resolve(current: unknown): JSONSchema {
if (typeof current === 'object' && current !== null) {
// make sure we don't visit the same node twice
if (visitedNodes.has(current)) {
return current;
}
visitedNodes.add(current);
if (Array.isArray(current)) {
// array
for (let index = 0; index < current.length; index++) {
current[index] = resolve(current[index]);
}
return current as JSONSchema;
}
const obj = current as Record<string, unknown>;
// object
if ('$ref' in current && typeof current['$ref'] === 'string') {
const ref = current['$ref'];
delete current['$ref'];
const resolved = resolve(resolveRefSync(ref, cloned) as JSONSchema);
setOriginalRef(resolved, ref);
setOriginalRef(current as JSONSchema, ref);
if (typeof resolved === 'boolean') throw new Error('invalid schema');
for (const k in resolved) {
if (!(k in current)) {
obj[k] = resolved[k as never];
}
}
}
for (const key in current) {
obj[key] = resolve(obj[key]);
}
}
return current as JSONSchema;
}
return resolve(cloned);
}
@@ -0,0 +1,29 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
export function resolveRefSync(ref: string, schema: JSONSchema): unknown | undefined {
if (!ref.startsWith('#')) return;
const segments = ref.slice(1).split('/');
let current: unknown = schema;
for (const seg of segments) {
if (seg.length === 0) continue;
if (isPlainObject(current)) current = current[seg];
else return;
}
return current;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null
);
}
-9
View File
@@ -2445,9 +2445,6 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
dereference-json-schema:
specifier: ^0.2.2
version: 0.2.2
github-slugger:
specifier: ^2.0.0
version: 2.0.0
@@ -7562,7 +7559,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-AvXWYFO6j/ZQ7bhGm4X6eilq2JHsDVC90ZM32k2B7/srhC2gs3Sdki1QTbwrdRCo8o7eT+167vcB1yzOvPdbjA==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -8230,9 +8226,6 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-w8dUsJyrzH4Zsj8W/tKcjLsmcTKXfdNf+n3BBm1SAfnqpaCodgEUWqQGJ+pNb9NOqPwYMGvUnZZ8nQfeFjJlbQ==}
[email protected]:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@@ -18105,8 +18098,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}