fumadocs-openapi
Version:
Generate MDX docs for your OpenAPI spec
333 lines (332 loc) • 18.4 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { Fragment, lazy, useEffect, useMemo, useState, } from 'react';
import { Controller, FormProvider, useForm, useFormContext, } from 'react-hook-form';
import { useApiContext, useServerSelectContext } from '../ui/contexts/api.js';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './inputs.js';
import { getStatusInfo } from './status-info.js';
import { joinURL, resolveRequestData, resolveServerUrl, withBase, } from '../utils/url.js';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { MethodLabel } from '../ui/components/method-label.js';
import { useQuery } from '../utils/use-query.js';
import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown, LoaderCircle } from '../icons.js';
import { encodeRequestData } from '../requests/_shared.js';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import { SchemaProvider, useResolvedSchema, } from '../playground/schema.js';
import { useRequestDataUpdater, useRequestInitialData, } from '../ui/contexts/code-example.js';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../ui/components/select.js';
import { labelVariants } from '../ui/components/input.js';
const AuthPrefix = '__fumadocs_auth';
const ServerSelect = lazy(() => import('../ui/server-select.js'));
const OauthDialog = lazy(() => import('./auth/oauth-dialog.js').then((mod) => ({
default: mod.OauthDialog,
})));
const OauthDialogTrigger = lazy(() => import('./auth/oauth-dialog.js').then((mod) => ({
default: mod.OauthDialogTrigger,
})));
export default function Client({ route, method = 'GET', securities, parameters = [], body, fields, references, proxyUrl, components: { ResultDisplay = DefaultResultDisplay } = {}, ...rest }) {
const { server } = useServerSelectContext();
const requestData = useRequestInitialData();
const updater = useRequestDataUpdater();
const fieldInfoMap = useMemo(() => new Map(), []);
const { mediaAdapters } = useApiContext();
const [securityId, setSecurityId] = useState(0);
const { inputs, mapInputs } = useAuthInputs(securities[securityId]);
const defaultValues = useMemo(() => ({
path: requestData.path,
query: requestData.query,
header: requestData.header,
body: requestData.body,
cookie: requestData.cookie,
}), [requestData]);
const form = useForm({
defaultValues,
});
const testQuery = useQuery(async (input) => {
const fetcher = await import('./fetcher.js').then((mod) => mod.createBrowserFetcher(mediaAdapters));
input._encoded ?? (input._encoded = encodeRequestData({ ...mapInputs(input), method, bodyMediaType: body?.mediaType }, mediaAdapters, parameters));
return fetcher.fetch(joinURL(withBase(server ? resolveServerUrl(server.url, server.variables) : '/', window.location.origin), resolveRequestData(route, input._encoded)), {
proxyUrl,
...input._encoded,
});
});
function initAuthValues(values, inputs) {
for (const item of inputs) {
manipulateValues(values, item.fieldName, () => {
const stored = localStorage.getItem(AuthPrefix + item.original.id);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === typeof item.defaultValue)
return parsed;
}
return item.defaultValue;
});
}
return values;
}
useOnChange(defaultValues, () => {
fieldInfoMap.clear();
form.reset(initAuthValues(defaultValues, inputs));
});
useOnChange(inputs, (current, previous) => {
form.reset((values) => {
for (const item of previous) {
if (current.some(({ original }) => original.id === item.original.id)) {
continue;
}
manipulateValues(values, item.fieldName, () => undefined);
}
return initAuthValues(values, current);
});
});
const onUpdateDebounced = useEffectEvent((values) => {
for (const item of inputs) {
const value = item.fieldName
.split('.')
.reduce((v, seg) => v[seg], values);
if (value) {
localStorage.setItem(AuthPrefix + item.original.id, JSON.stringify(value));
}
}
const data = {
...mapInputs(values),
method,
bodyMediaType: body?.mediaType,
};
values._encoded ?? (values._encoded = encodeRequestData(data, mediaAdapters, parameters));
updater.setData(data, values._encoded);
});
useEffect(() => {
let timer = null;
const subscription = form.subscribe({
formState: {
values: true,
},
callback({ values }) {
// remove cached encoded request data
delete values._encoded;
if (timer)
window.clearTimeout(timer);
timer = window.setTimeout(() => onUpdateDebounced(values), timer ? 400 : 0);
},
});
form.reset((values) => initAuthValues(values, inputs));
return () => subscription();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mounted once only
}, []);
const onSubmit = form.handleSubmit((value) => {
testQuery.start(mapInputs(value));
});
return (_jsx(FormProvider, { ...form, children: _jsx(SchemaProvider, { fieldInfoMap: fieldInfoMap, references: references, children: _jsxs("form", { ...rest, className: cn('not-prose flex flex-col rounded-xl border shadow-md overflow-hidden bg-fd-card text-fd-card-foreground', rest.className), onSubmit: onSubmit, children: [_jsx(ServerSelect, {}), _jsxs("div", { className: "flex flex-row items-center gap-2 text-sm p-3 pb-0", children: [_jsx(MethodLabel, { children: method }), _jsx(Route, { route: route, className: "flex-1" }), _jsx("button", { type: "submit", className: cn(buttonVariants({ color: 'primary', size: 'sm' }), 'px-3 py-1.5'), disabled: testQuery.isLoading, children: testQuery.isLoading ? (_jsx(LoaderCircle, { className: "size-4 animate-spin" })) : ('Send') })] }), securities.length > 0 && (_jsx(SecurityTabs, { securities: securities, securityId: securityId, setSecurityId: setSecurityId, children: inputs.map((input) => (_jsx(Fragment, { children: input.children }, input.fieldName))) })), _jsx(FormBody, { body: body, fields: fields, parameters: parameters }), testQuery.data ? _jsx(ResultDisplay, { data: testQuery.data }) : null] }) }) }));
}
function SecurityTabs({ securities, setSecurityId, securityId, children, }) {
const [open, setOpen] = useState(false);
const form = useFormContext();
const result = (_jsxs(CollapsiblePanel, { title: "Authorization", children: [_jsxs(Select, { value: securityId.toString(), onValueChange: (v) => setSecurityId(Number(v)), children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: securities.map((security, i) => (_jsx(SelectItem, { value: i.toString(), children: security.map((item) => (_jsxs("div", { className: "max-w-[600px]", children: [_jsx("p", { className: "font-mono font-medium", children: item.id }), _jsx("p", { className: "text-fd-muted-foreground whitespace-pre-wrap", children: item.description })] }, item.id))) }, i))) })] }), children] }));
for (let i = 0; i < securities.length; i++) {
const security = securities[i];
for (const item of security) {
if (item.type === 'oauth2') {
return (_jsx(OauthDialog, { scheme: item, scopes: item.scopes, open: open, setOpen: (v) => {
setOpen(v);
if (v) {
setSecurityId(i);
}
}, setToken: (token) => form.setValue('header.Authorization', token), children: result }));
}
}
}
return result;
}
const ParamTypes = ['path', 'header', 'cookie', 'query'];
function FormBody({ parameters = [], fields = {}, body, }) {
const panels = useMemo(() => {
return ParamTypes.map((type) => {
const items = parameters.filter((v) => v.in === type);
if (items.length === 0)
return;
return (_jsx(CollapsiblePanel, { title: {
header: 'Header',
cookie: 'Cookies',
query: 'Query',
path: 'Path',
}[type], children: items.map((field) => {
const fieldName = `${type}.${field.name}`;
const schema = (field.content
? field.content[Object.keys(field.content)[0]].schema
: field.schema);
if (fields?.parameter) {
return renderCustomField(fieldName, schema, fields.parameter, field.name);
}
return (_jsx(FieldSet, { name: field.name, fieldName: fieldName, field: schema }, fieldName));
}) }, type));
});
}, [fields.parameter, parameters]);
return (_jsxs(_Fragment, { children: [panels, body && (_jsx(CollapsiblePanel, { title: "Body", children: fields.body ? (renderCustomField('body', body.schema, fields.body)) : (_jsx(BodyInput, { field: body.schema })) }))] }));
}
function BodyInput({ field: _field }) {
const field = useResolvedSchema(_field);
const [isJson, setIsJson] = useState(false);
if (field.format === 'binary')
return _jsx(FieldSet, { field: field, fieldName: "body" });
if (isJson)
return (_jsxs(_Fragment, { children: [_jsx("button", { className: cn(buttonVariants({
color: 'secondary',
size: 'sm',
className: 'w-fit font-mono p-2',
})), onClick: () => setIsJson(false), type: "button", children: "Close JSON Editor" }), _jsx(JsonInput, { fieldName: "body" })] }));
return (_jsx(FieldSet, { field: field, fieldName: "body", collapsible: false, name: _jsx("button", { type: "button", className: cn(buttonVariants({
color: 'secondary',
size: 'sm',
className: 'p-2',
})), onClick: () => setIsJson(true), children: "Open JSON Editor" }) }));
}
/**
* manipulate values without mutating the original object
*
* @returns a new manipulated object
*/
function manipulateValues(values, fieldName, update, clone = false) {
const root = clone ? { ...values } : values;
let current = root;
const segments = fieldName.split('.');
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (i !== segments.length - 1) {
let v = current[segment];
if (clone)
v = { ...v };
current[segment] = v;
current = v;
continue;
}
const updated = update(current[segment]);
if (updated === undefined) {
delete current[segment];
}
else {
current[segment] = updated;
}
}
return root;
}
function useAuthInputs(securities) {
const inputs = useMemo(() => {
const result = [];
if (!securities)
return result;
for (const security of securities) {
if (security.type === 'http' && security.scheme === 'basic') {
const fieldName = `header.Authorization`;
result.push({
fieldName,
original: security,
defaultValue: {
username: '',
password: '',
},
mapOutput(out) {
if (out && typeof out === 'object') {
return `Basic ${btoa(`${'username' in out ? out.username : ''}:${'password' in out ? out.password : ''}`)}`;
}
return out;
},
children: (_jsx(ObjectInput, { field: {
type: 'object',
properties: {
username: {
type: 'string',
},
password: {
type: 'string',
},
},
required: ['username', 'password'],
}, fieldName: fieldName })),
});
}
else if (security.type === 'oauth2') {
const fieldName = 'header.Authorization';
result.push({
fieldName: fieldName,
original: security,
defaultValue: 'Bearer ',
children: (_jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("label", { htmlFor: fieldName, className: cn(labelVariants()), children: "Access Token" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(FieldInput, { fieldName: fieldName, isRequired: true, field: {
type: 'string',
}, className: "flex-1" }), _jsx(OauthDialogTrigger, { type: "button", className: cn(buttonVariants({
size: 'sm',
color: 'secondary',
})), children: "Authorize" })] })] })),
});
}
else if (security.type === 'http') {
const fieldName = 'header.Authorization';
result.push({
fieldName: fieldName,
original: security,
defaultValue: 'Bearer ',
children: (_jsx(FieldSet, { name: "Authorization (header)", fieldName: fieldName, isRequired: true, field: {
type: 'string',
} })),
});
}
else if (security.type === 'apiKey') {
const fieldName = `${security.in}.${security.name}`;
result.push({
fieldName,
defaultValue: '',
original: security,
children: (_jsx(FieldSet, { fieldName: fieldName, name: `${security.name} (${security.in})`, isRequired: true, field: {
type: 'string',
} })),
});
}
else {
const fieldName = 'header.Authorization';
result.push({
fieldName,
defaultValue: '',
original: security,
children: (_jsxs(_Fragment, { children: [_jsx(FieldSet, { name: "Authorization (header)", isRequired: true, fieldName: fieldName, field: {
type: 'string',
} }), _jsx("p", { className: "text-fd-muted-foreground text-xs", children: "OpenID Connect is not supported at the moment, you can still set an access token here." })] })),
});
}
}
return result;
}, [securities]);
const mapInputs = useEffectEvent((values) => {
for (const item of inputs) {
if (!item.mapOutput)
continue;
values = manipulateValues(values, item.fieldName, item.mapOutput, true);
}
return values;
});
return { inputs, mapInputs };
}
function renderCustomField(fieldName, info, field, key) {
return (_jsx(Controller, {
// @ts-expect-error we use string here
render: (props) => field.render({ ...props, info }), name: fieldName }, key));
}
function Route({ route, ...props }) {
const segments = route.split('/').filter((part) => part.length > 0);
return (_jsx("div", { ...props, className: cn('flex flex-row items-center gap-0.5 overflow-auto text-nowrap', props.className), children: segments.map((part, index) => (_jsxs(Fragment, { children: [_jsx("span", { className: "text-fd-muted-foreground", children: "/" }), part.startsWith('{') && part.endsWith('}') ? (_jsx("code", { className: "bg-fd-primary/10 text-fd-primary", children: part })) : (_jsx("code", { className: "text-fd-foreground", children: part }))] }, index))) }));
}
function DefaultResultDisplay({ data }) {
const statusInfo = useMemo(() => getStatusInfo(data.status), [data.status]);
const { shikiOptions } = useApiContext();
return (_jsxs("div", { className: "flex flex-col gap-3 p-3", children: [_jsxs("div", { className: "inline-flex items-center gap-1.5 text-sm font-medium text-fd-foreground", children: [_jsx(statusInfo.icon, { className: cn('size-4', statusInfo.color) }), statusInfo.description] }), _jsx("p", { className: "text-sm text-fd-muted-foreground", children: data.status }), data.data ? (_jsx(DynamicCodeBlock, { lang: typeof data.data === 'string' && data.data.length > 50000
? 'text'
: data.type, code: typeof data.data === 'string'
? data.data
: JSON.stringify(data.data, null, 2), options: shikiOptions })) : null] }));
}
function CollapsiblePanel({ title, children, ...props }) {
return (_jsxs(Collapsible, { ...props, className: "border-b last:border-b-0", children: [_jsxs(CollapsibleTrigger, { className: "group w-full flex items-center gap-2 p-3 text-sm font-medium", children: [title, _jsx(ChevronDown, { className: "ms-auto size-3.5 text-fd-muted-foreground group-data-[state=open]:rotate-180" })] }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "flex flex-col gap-3 p-3 pt-1", children: children }) })] }));
}