OpenAPI: Support variable server url

This commit is contained in:
Fuma Nama
2025-01-07 20:20:03 +08:00
parent 03763d941a
commit 60fe635168
20 changed files with 281 additions and 107 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Support variable server url
+13
View File
@@ -12,6 +12,19 @@
{
"url": "https://example.com/api/v1",
"description": "Example API"
},
{
"url": "{protocol}://{domain}/api",
"description": "The API is running at `/api` of your Fumadocs.",
"variables": {
"protocol": {
"enum": ["http", "https"],
"default": "https"
},
"domain": {
"default": "fumadocs.example.com"
}
}
}
],
"x-speakeasy-retries": {
+1 -7
View File
@@ -54,7 +54,6 @@ export function Operation({
headingLevel?: number;
}): ReactElement {
const { baseUrls } = ctx;
const body = method.requestBody;
const security = method.security ?? ctx.document.security;
let headNode: ReactNode = null;
@@ -183,12 +182,7 @@ export function Operation({
}
const info = (
<ctx.renderer.APIInfo
head={headNode}
method={method.method}
route={path}
baseUrls={type === 'operation' ? baseUrls : []}
>
<ctx.renderer.APIInfo head={headNode} method={method.method} route={path}>
{type === 'operation' ? (
<Playground path={path} method={method} ctx={ctx} />
) : null}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
ObjectCollapsible,
APIPlayground,
} from '@/ui';
import type { RenderContext } from '@/types';
import type { RenderContext, ServerObject } from '@/types';
export interface ResponsesProps {
items: string[];
@@ -27,7 +27,6 @@ export interface ResponseProps {
export interface APIInfoProps {
method: string;
route: string;
baseUrls: string[];
head: ReactNode;
children: ReactNode;
@@ -60,6 +59,7 @@ export interface ResponseTypeProps {
export interface RootProps {
baseUrl?: string;
servers: ServerObject[];
children: ReactNode;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/schema';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
const s: string[] = [];
+1 -1
View File
@@ -1,5 +1,5 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/schema';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
const imports = ['fmt', 'net/http', 'io/ioutil'];
+1 -1
View File
@@ -1,5 +1,5 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/schema';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
const s: string[] = [];
+1 -1
View File
@@ -1,5 +1,5 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/schema';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
const headers = new Map<string, unknown>();
+2 -2
View File
@@ -62,7 +62,7 @@ export async function APIPage(props: ApiPageProps) {
const ctx = await getContext(processed, props);
const { document } = processed;
return (
<ctx.renderer.Root baseUrl={ctx.baseUrl}>
<ctx.renderer.Root baseUrl={ctx.baseUrl} servers={ctx.servers}>
{operations?.map((item) => {
const pathItem = document.paths?.[item.path];
if (!pathItem) return null;
@@ -127,7 +127,7 @@ export async function getContext(
generateTypeScriptSchema: options.generateTypeScriptSchema,
generateCodeSamples: options.generateCodeSamples,
baseUrl: document.servers?.[0].url ?? 'https://example.com',
baseUrls: document.servers?.map((s) => s.url) ?? [],
servers: document.servers ?? [],
slugger: new Slugger(),
};
}
+2 -8
View File
@@ -18,13 +18,7 @@ export type SecuritySchemeObject = V3_1.SecuritySchemeObject;
export type ReferenceObject = V3_1.ReferenceObject;
export type PathItemObject = V3_1.PathItemObject;
export type TagObject = V3_1.TagObject;
export interface RouteInformation {
path: string;
summary?: string;
description?: string;
methods: MethodInformation[];
}
export type ServerObject = NoReference<V3_1.ServerObject>;
export type MethodInformation = NoReference<OperationObject> & {
method: string;
@@ -51,7 +45,7 @@ export interface RenderContext {
document: NoReference<Document>;
baseUrl: string;
baseUrls: string[];
servers: ServerObject[];
slugger: Slugger;
+102 -24
View File
@@ -7,9 +7,23 @@ import {
import { Check, Copy } from 'lucide-react';
import { cn, useCopyButton, buttonVariants } from 'fumadocs-ui/components/api';
import dynamic from 'next/dynamic';
import { ApiProvider, useApiContext } from '@/ui/contexts/api';
import {
ApiProvider,
useApiContext,
useServerSelectContext,
} from '@/ui/contexts/api';
import { type RootProps } from '@/render/renderer';
import { RenderContext } from '@/types';
import type { RenderContext } from '@/types';
import { labelVariants } from '@/ui/components/form';
import { Input } from '@/ui/components/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
import { getUrl } from '@/utils/server-url';
export const APIPlayground = dynamic(() =>
import('./playground').then((mod) => mod.APIPlayground),
@@ -20,6 +34,7 @@ export function Root({
baseUrl,
className,
shikiOptions,
servers,
...props
}: RootProps & {
shikiOptions: RenderContext['shikiOptions'];
@@ -32,7 +47,11 @@ export function Root({
)}
{...props}
>
<ApiProvider shikiOptions={shikiOptions} defaultBaseUrl={baseUrl}>
<ApiProvider
servers={servers}
shikiOptions={shikiOptions}
defaultBaseUrl={baseUrl}
>
{children}
</ApiProvider>
</div>
@@ -46,10 +65,12 @@ export function CopyRouteButton({
}: ButtonHTMLAttributes<HTMLButtonElement> & {
route: string;
}): ReactNode {
const { baseUrl } = useApiContext();
const { serverRef } = useApiContext();
const [checked, onCopy] = useCopyButton(() => {
void navigator.clipboard.writeText(`${baseUrl ?? ''}${route}`);
void navigator.clipboard.writeText(
`${serverRef.current ? getUrl(serverRef.current.url, serverRef.current.variables) : ''}${route}`,
);
});
return (
@@ -74,28 +95,85 @@ export function CopyRouteButton({
);
}
export function BaseUrlSelect({ baseUrls }: { baseUrls: string[] }) {
const { baseUrl, setBaseUrl } = useApiContext();
export function ServerSelect() {
const { servers } = useApiContext();
const { server, setServer, setServerVariables } = useServerSelectContext();
if (baseUrls.length <= 1) return null;
if (servers.length <= 1) return null;
const schema = server
? servers.find((item) => item.url === server.url)
: undefined;
return (
<div className="mt-2 flex flex-row items-center gap-1 px-1">
<span className="p-0.5 text-xs font-medium text-fd-muted-foreground">
Server
</span>
<select
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-xs text-fd-foreground outline-none"
>
{baseUrls.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</select>
</div>
<>
<div className="not-prose mt-4 flex flex-row items-center gap-2">
<span className="text-xs font-medium">Server</span>
<select
value={server?.url}
onChange={(e) => setServer(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-xs text-fd-foreground outline-none"
>
{servers.map((item) => (
<option key={item.url} value={item.url}>
{item.url}
</option>
))}
</select>
</div>
{schema && schema.variables && server?.variables ? (
<div className="not-prose mt-2 flex flex-col gap-4">
{schema.description ? (
<p className="text-xs">{schema.description}</p>
) : null}
{Object.entries(schema.variables).map(([key, variable]) => {
const id = `fd_server_select_${key}`;
return (
<fieldset key={key} className="flex flex-col gap-1">
<label className={cn(labelVariants())} htmlFor={id}>
{key}
</label>
<p className="text-xs">{variable.description}</p>
{variable.enum ? (
<Select
value={server.variables[key]}
onValueChange={(v) =>
setServerVariables({
...server?.variables,
[key]: v,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{variable.enum.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={id}
value={server.variables[key]}
onChange={(e) =>
setServerVariables({
...server?.variables,
[key]: e.target.value,
})
}
/>
)}
</fieldset>
);
})}
</div>
) : null}
</>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ const FormItem = forwardRef<
FormItem.displayName = 'FormItem';
export const labelVariants = cva(
'text-xs font-medium text-fd-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'font-mono text-xs text-fd-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const FormLabel = forwardRef<
+97 -19
View File
@@ -1,31 +1,47 @@
import {
createContext,
type ReactNode,
type RefObject,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import type { RenderContext } from '@/types';
import type { RenderContext, ServerObject } from '@/types';
export interface ApiProviderProps {
/**
* Base URL for API requests
*/
defaultBaseUrl?: string;
servers: ServerObject[];
shikiOptions: RenderContext['shikiOptions'];
children?: ReactNode;
}
interface ApiContextType {
baseUrl?: string;
setBaseUrl: (value: string) => void;
shikiOptions: RenderContext['shikiOptions'];
interface SelectedServer {
url: string;
variables: Record<string, string>;
}
interface ApiContextType
extends Omit<ApiProviderProps, 'children' | 'defaultBaseUrl'> {
serverRef: RefObject<SelectedServer | null>;
}
interface ServerSelectType {
server: SelectedServer | null;
setServer: (value: string) => void;
setServerVariables: (value: Record<string, string>) => void;
}
const ApiContext = createContext<ApiContextType | undefined>(undefined);
const ServerSelectContext = createContext<ServerSelectType | undefined>(
undefined,
);
export function useApiContext(): ApiContextType {
const ctx = useContext(ApiContext);
@@ -34,33 +50,95 @@ export function useApiContext(): ApiContextType {
return ctx;
}
export function useServerSelectContext(): ServerSelectType {
const ctx = useContext(ServerSelectContext);
if (!ctx) throw new Error('Component must be used under <ApiProvider />');
return ctx;
}
export function ApiProvider({
defaultBaseUrl,
shikiOptions,
children,
}: ApiProviderProps): React.ReactElement {
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
...props
}: ApiProviderProps) {
const [server, setServer] = useState<SelectedServer | null>(() => {
const defaultItem = defaultBaseUrl
? props.servers.find((item) => item.url === defaultBaseUrl)
: undefined;
return defaultItem
? {
url: defaultItem.url,
variables: getDefaultValues(defaultItem),
}
: null;
});
const serverRef = useRef<SelectedServer | null>(server);
serverRef.current = server;
useEffect(() => {
setBaseUrl((prev) => localStorage.getItem('apiBaseUrl') ?? prev);
}, []);
const cached = localStorage.getItem('apiBaseUrl');
if (!cached) return;
useOnChange(baseUrl, () => {
if (baseUrl) localStorage.setItem('apiBaseUrl', baseUrl);
});
try {
const obj = JSON.parse(cached);
if (!obj || typeof obj !== 'object') return;
setServer(obj);
} catch {
// ignore
}
}, []);
return (
<ApiContext.Provider
value={useMemo(
() => ({
baseUrl,
setBaseUrl,
shikiOptions,
...props,
serverRef,
}),
[baseUrl, shikiOptions],
[props],
)}
>
{children}
<ServerSelectContext.Provider
value={useMemo(
() => ({
server,
setServerVariables(variables) {
setServer((prev) => {
if (!prev) return null;
const updated = { ...prev, variables };
localStorage.setItem('apiBaseUrl', JSON.stringify(updated));
return updated;
});
},
setServer(value) {
const obj = props.servers.find((item) => item.url === value);
if (!obj) return;
const result: SelectedServer = {
url: value,
variables: getDefaultValues(obj),
};
localStorage.setItem('apiBaseUrl', JSON.stringify(result));
setServer(result);
},
}),
[server, props.servers],
)}
>
{children}
</ServerSelectContext.Provider>
</ApiContext.Provider>
);
}
function getDefaultValues(server: ServerObject): Record<string, string> {
return Object.fromEntries(
Object.entries(server.variables ?? {}).map(([k, v]) => [k, v.default]),
);
}
+4 -5
View File
@@ -3,7 +3,7 @@ import { Fragment, type HTMLAttributes, type ReactNode } from 'react';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { badgeVariants, getBadgeColor } from '@/ui/components/variants';
import type { APIInfoProps, PropertyProps } from '@/render/renderer';
import { BaseUrlSelect, CopyRouteButton } from '@/ui/client';
import { ServerSelect, CopyRouteButton } from '@/ui/client';
export { Root, useSchemaContext, APIPlayground } from './client';
@@ -30,7 +30,6 @@ export function APIInfo({
className,
route,
badgeClassname,
baseUrls,
method = 'GET',
head,
...props
@@ -41,8 +40,8 @@ export function APIInfo({
return (
<div className={cn('min-w-0 flex-1', className)} {...props}>
{head}
<div className="sticky top-[var(--fd-api-info-top)] z-[4] mb-4 border-b border-fd-foreground/10 bg-fd-card/50 px-4 py-1.5 shadow-lg backdrop-blur-lg max-lg:-mx-3 max-md:-mx-4 md:rounded-xl md:border md:px-1.5">
<div className="flex flex-row items-center gap-1.5">
<div className="mb-4 border-b border-fd-foreground/10 bg-fd-card/50 px-4 py-1.5 shadow-lg md:rounded-xl md:border md:p-3">
<div className="-mx-1 flex flex-row items-center gap-1.5">
<span
className={cn(
badgeVariants({ color: getBadgeColor(method) }),
@@ -54,7 +53,7 @@ export function APIInfo({
<Route route={route} />
<CopyRouteButton className="ms-auto size-6 p-1.5" route={route} />
</div>
<BaseUrlSelect baseUrls={baseUrls} />
<ServerSelect />
</div>
<div className="prose-no-margin">{props.children}</div>
</div>
+6 -2
View File
@@ -32,6 +32,7 @@ import type {
import { CodeBlock } from '@/ui/components/codeblock';
import { type DynamicField, SchemaContext } from '../contexts/schema';
import { getStatusInfo } from '@/ui/playground/status-info';
import { getUrl } from '@/utils/server-url';
interface FormValues {
authorization: string;
@@ -73,7 +74,7 @@ export function APIPlayground({
body?: CustomField<'body', RequestSchema>;
};
} & HTMLAttributes<HTMLFormElement>) {
const { baseUrl } = useApiContext();
const { serverRef } = useApiContext();
const dynamicRef = useRef(new Map<string, DynamicField>());
const form = useForm<FormValues>({
defaultValues: {
@@ -89,7 +90,10 @@ export function APIPlayground({
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(body, schemas),
);
const targetUrl = `${baseUrl ?? window.location.origin}${createPathnameFromInput(route, input.path, input.query)}`;
const serverUrl = serverRef.current
? getUrl(serverRef.current.url, serverRef.current.variables)
: window.location.origin;
const targetUrl = `${serverUrl}${createPathnameFromInput(route, input.path, input.query)}`;
let url: URL;
if (proxyUrl) {
@@ -87,7 +87,7 @@ function InputContainer({
...props
}: InputContainerProps): React.ReactElement {
return (
<div {...props} className={cn('flex flex-col gap-2', props.className)}>
<div {...props} className={cn('flex flex-col gap-1', props.className)}>
<div className={cn(labelVariants(), 'inline-flex items-center gap-1')}>
{name}
{required ? <span className="text-red-500">*</span> : null}
@@ -0,0 +1,31 @@
import { type ElementCompact, js2xml } from 'xml-js';
/**
* Convert input value to hardcoded string (with quotes)
*/
export function inputToString(
value: unknown,
mediaType = 'application/json',
multiLine: 'single-quote' | 'backtick' | 'none' = 'none',
): string {
const getStr = (v: string) => {
if (multiLine === 'none') return JSON.stringify(v);
const delimit = multiLine === 'backtick' ? `\`` : `'`;
return `${delimit}${v.replaceAll(delimit, `\\${delimit}`)}${delimit}`;
};
if (typeof value === 'string') return getStr(value);
if (mediaType === 'application/json' || mediaType === 'multipart/form-data') {
return getStr(JSON.stringify(value, null, 2));
}
if (mediaType === 'application/xml') {
return getStr(
js2xml(value as ElementCompact, { compact: true, spaces: 2 }),
);
}
throw new Error(`Unsupported media type: ${mediaType}`);
}
-31
View File
@@ -1,6 +1,5 @@
import type { ReferenceObject } from '@/types';
import type { OpenAPIV3_1 } from 'openapi-types';
import { js2xml, type ElementCompact } from 'xml-js';
export type NoReference<T> = T extends (infer I)[]
? NoReference<I>[]
@@ -22,36 +21,6 @@ export function getPreferredType<B extends Record<string, unknown>>(
return Object.keys(body)[0];
}
/**
* Convert input to string (with quotes)
*/
export function inputToString(
value: unknown,
mediaType = 'application/json',
multiLine: 'single-quote' | 'backtick' | 'none' = 'none',
): string {
const getStr = (v: string) => {
if (multiLine === 'none') return JSON.stringify(v);
const delimit = multiLine === 'backtick' ? `\`` : `'`;
return `${delimit}${v.replaceAll(delimit, `\\${delimit}`)}${delimit}`;
};
if (typeof value === 'string') return getStr(value);
if (mediaType === 'application/json' || mediaType === 'multipart/form-data') {
return getStr(JSON.stringify(value, null, 2));
}
if (mediaType === 'application/xml') {
return getStr(
js2xml(value as ElementCompact, { compact: true, spaces: 2 }),
);
}
throw new Error(`Unsupported media type: ${mediaType}`);
}
export function isNullable(
schema: NoReference<ParsedSchema>,
includeOneOf = true,
+9
View File
@@ -0,0 +1,9 @@
export function getUrl(url: string, variables: Record<string, string>): string {
let out = url;
for (const [key, value] of Object.entries(variables)) {
out = out.replaceAll(`{${key}}`, value);
}
return out;
}
+1 -1
View File
@@ -269,7 +269,7 @@ function SidebarHeaderItems({
{nav.title ? (
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 py-1 font-medium"
className="inline-flex items-center gap-2.5 py-1 font-medium md:px-2"
>
{nav.title}
</Link>