OpenAPI: improve Auth UI

This commit is contained in:
Fuma Nama
2026-04-04 22:42:40 +08:00
parent 5d00e08309
commit f15e322867
35 changed files with 807 additions and 554 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Improve auth UI for playground
+5
View File
@@ -0,0 +1,5 @@
---
'@fumari/stf': patch
---
Expose more utils
@@ -1,5 +1,5 @@
import { createFileRoute, notFound } from '@tanstack/react-router';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
import { createServerFn } from '@tanstack/react-start';
import { getPageMarkdownUrl, source } from '@/lib/source';
import browserCollections from 'collections/browser';
@@ -10,7 +10,7 @@ import {
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/docs/page';
} from 'fumadocs-ui/layouts/notebook/page';
import { baseOptions } from '@/lib/layout.shared';
import { gitConfig } from '@/lib/shared';
import { useFumadocsLoader } from 'fumadocs-core/source/client';
@@ -71,7 +71,7 @@ const clientLoader = browserCollections.docs.createClientLoader({
},
) {
return (
<DocsPage toc={toc}>
<DocsPage toc={toc} tableOfContent={{ style: 'clerk' }}>
<DocsTitle>{frontmatter.title}</DocsTitle>
<DocsDescription>{frontmatter.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b -mt-4 pb-6">
@@ -107,8 +107,9 @@ function Page() {
content = <Suspense>{clientLoader.useContent(page.path, page)}</Suspense>;
}
const base = baseOptions();
return (
<DocsLayout {...baseOptions()} tree={page.pageTree}>
<DocsLayout {...base} nav={{ ...base.nav, mode: 'top' }} tree={page.pageTree}>
{content}
</DocsLayout>
);
@@ -1,8 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:[email protected]&family=Geist:[email protected]&display=swap');
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
@theme {
--default-font-family: 'Geist';
--default-mono-font-family: 'Geist Mono';
}
html {
scrollbar-gutter: stable;
}
+3 -2
View File
@@ -7,6 +7,7 @@ import { createGetUrl, getSlugs, PathUtils } from 'fumadocs-core/source';
import { createAutoPreset, type SchemaToPagesOptions } from '@/utils/pages/preset-auto';
import { fromSchema, type OutputGroup, type OutputEntry } from '@/utils/pages/builder';
import type { DistributiveOmit } from './types';
import { doubleQuote } from './requests/string-utils';
export interface OutputFile {
path: string;
@@ -275,10 +276,10 @@ function writeIndexFiles(
// cannot link to groups
if (entry.type === 'group') continue;
const descriptionAttr = entry.info.description
? `description=${JSON.stringify(entry.info.description)} `
? `description=${doubleQuote(entry.info.description)} `
: '';
content.push(
`<Card href="${urlFn(entry.path)}" title=${JSON.stringify(entry.info.title)} ${descriptionAttr}/>`,
`<Card href="${urlFn(entry.path)}" title=${doubleQuote(entry.info.title)} ${descriptionAttr}/>`,
);
}
+1
View File
@@ -97,6 +97,7 @@ export const defaultTranslations = {
usernameField: 'Username',
passwordField: 'Password',
fetchingToken: 'Fetching token...',
fetchTokenError: 'Failed to fetch token',
// Server select
serverUrl: 'Server URL',
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useApiContext } from '@/ui/contexts/api';
import { useQuery } from '@/utils/use-query';
import { createContext, type ReactNode, use, useEffect, useMemo, useState } from 'react';
import type { AuthCodeState, ImplicitState } from './components/oauth-dialog';
/** scheme name -> token info */
type TokenStore = Record<string, TokenInfo | undefined>;
type TokenInfo =
| {
type: 'authorization_code';
redirect_uri: string;
client_id: string;
client_secret: string;
token: string;
}
| {
type: 'implicit';
client_id: string;
token: string;
};
interface AuthContextType {
store: TokenStore;
updatedSchemeId: string | null;
isLoading: boolean;
error?: unknown;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function useAuth() {
const ctx = use(AuthContext);
if (!ctx) throw new Error('must use this component under <AuthProvider />');
return ctx;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const { schemes } = useApiContext();
const [store, setStore] = useState<TokenStore>({});
const authCodeQuery = useQuery(async (code: string, state: AuthCodeState) => {
const scheme = schemes[state.scheme];
if (!scheme || scheme.type !== 'oauth2') return;
const value = scheme.flows?.authorizationCode;
if (!value) return;
const res = await fetch(value.tokenUrl!, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
// note: `state` could be invalid, but server will check it
redirect_uri: state.redirect_uri,
client_id: state.client_id,
client_secret: state.client_secret,
}),
});
if (!res.ok) throw new Error(await res.text());
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
access_token: string;
token_type?: string;
};
const info: TokenInfo = {
type: 'authorization_code',
...state,
token: `${token_type} ${access_token}`,
};
setStore((s) => ({
...s,
[state.scheme]: info,
}));
});
useEffect(() => {
function onQuery() {
const query = new URLSearchParams(window.location.search);
const state = query.get('state');
const code = query.get('code');
if (!state || !code) return;
const parsedState = JSON.parse(state) as AuthCodeState;
authCodeQuery.start(code, parsedState);
window.history.replaceState(null, '', window.location.pathname);
}
function onHash() {
const hash = new URLSearchParams(window.location.hash.slice(1));
const state = hash.get('state');
const token = hash.get('access_token');
const type = hash.get('token_type') ?? 'Bearer';
if (!state || !token) return;
const parsedState = JSON.parse(state) as ImplicitState;
const scheme = schemes[parsedState.scheme];
if (!scheme || scheme.type !== 'oauth2' || !scheme.flows?.implicit) return;
const info: TokenInfo = {
type: 'implicit',
client_id: parsedState.client_id,
token: `${type} ${token}`,
};
setStore((s) => ({
...s,
[parsedState.scheme]: info,
}));
window.history.replaceState(null, '', window.location.pathname);
}
if (window.location.search.length > 0) onQuery();
if (window.location.hash.length > 1) onHash();
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
}, []);
return (
<AuthContext
value={useMemo(
() => ({
store,
updatedSchemeId: Object.keys(store)[0] ?? null,
isLoading: authCodeQuery.isLoading,
error: authCodeQuery.error,
}),
[store, authCodeQuery.isLoading, authCodeQuery.error],
)}
>
{children}
</AuthContext>
);
}
+272 -204
View File
@@ -37,22 +37,25 @@ import { getPreferredType, type SecurityEntry, type ParsedSchema } from '@/utils
import ServerSelect from './components/server-select';
import { useStorageKey } from '@/ui/client/storage-key';
import {
type DataEngine,
FieldKey,
Stf,
StfProvider,
useDataEngine,
useFieldValue,
useListener,
useStf,
} from '@fumari/stf';
import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
import { arrayStartsWith, objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
import type { Document, HttpMethods, ParameterObject } from '@/types';
import { ClientCodeBlock } from '@/ui/components/codeblock';
import { useTranslations } from '@/ui/client/i18n';
import { useOperationContext } from '@/ui/operation/client';
import { OauthDialog, OauthDialogTrigger } from './components/oauth-dialog';
import { OAuthDialog, OAuthDialogContent, OAuthDialogTrigger } from './components/oauth-dialog';
import { dereferenceSwallow } from '@/utils/schema/dereference';
import { useAuth } from './auth';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { Spinner } from './components/spinner';
export interface FormValues extends Record<string, unknown> {
path: Record<string, unknown>;
@@ -160,7 +163,6 @@ export default function PlaygroundClient({
const { example: exampleId, examples, setExampleData } = useOperationContext();
const { server } = useServerContext();
const storageKeys = useStorageKey();
const {
mediaAdapters,
client: {
@@ -171,19 +173,10 @@ export default function PlaygroundClient({
} = {},
requestTimeout,
fetchOptions = { requestTimeout },
transformAuthInputs,
renderBodyField,
} = {},
},
} = useApiContext();
const [securityId, setSecurityId] = useState(() => {
const idx = securities.findIndex((s) => s.every((entry) => !entry.deprecated));
return idx === -1 ? 0 : idx;
});
const { inputs, mapInputs, initAuthValues } = useAuthInputs(
securities[securityId],
transformAuthInputs,
);
const defaultValues: FormValues = useMemo(() => {
const requestData = examples.find((example) => example.id === exampleId)?.data;
@@ -203,6 +196,11 @@ export default function PlaygroundClient({
defaultValues,
});
const { inputs, requirementId, setRequirementId, mapInputs, initAuthInputs } = useAuthInputs(
stf.dataEngine,
securities,
);
const testQuery = useQuery(async (input: FormValues) => {
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters, { proxyUrl, ...fetchOptions }),
@@ -226,30 +224,25 @@ export default function PlaygroundClient({
});
const timerRef = useRef<number | null>(null);
const stfSync = useRef(false);
function triggerExampleUpdate() {
const data = {
...mapInputs(stf.dataEngine.getData() as FormValues),
method,
bodyMediaType: body?.mediaType,
};
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
}
useListener({
stf,
onUpdate() {
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => {
const values = stf.dataEngine.getData() as FormValues;
for (const item of inputs) {
const value = stf.dataEngine.get(item.fieldName);
if (!stfSync.current) return;
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
if (value) {
localStorage.setItem(storageKeys.AuthField(item), JSON.stringify(value));
}
}
const data = {
...mapInputs(values),
method,
bodyMediaType: body?.mediaType,
};
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
},
timerRef.current ? 400 : 0,
);
timerRef.current = window.setTimeout(triggerExampleUpdate, 400);
},
});
@@ -262,7 +255,13 @@ export default function PlaygroundClient({
}, [defaultValues]);
useEffect(() => {
return initAuthValues(stf);
const reset = initAuthInputs();
triggerExampleUpdate();
stfSync.current = true;
return () => {
stfSync.current = false;
reset();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
}, [defaultValues, inputs]);
@@ -276,7 +275,7 @@ export default function PlaygroundClient({
rest.className,
)}
onSubmit={(e) => {
testQuery.start(mapInputs(stf.dataEngine.getData() as FormValues));
testQuery.start(stf.dataEngine.getData() as FormValues);
e.preventDefault();
}}
>
@@ -295,15 +294,15 @@ export default function PlaygroundClient({
{testQuery.data ? <ResultDisplay data={testQuery.data} reset={testQuery.reset} /> : null}
{securities.length > 0 && (
<SecurityTabs
<SecurityRequirements
securities={securities}
securityId={securityId}
setSecurityId={setSecurityId}
securityId={requirementId}
setSecurityId={setRequirementId}
>
{inputs.map((input) => (
<Fragment key={stringifyFieldKey(input.fieldName)}>{input.children}</Fragment>
))}
</SecurityTabs>
</SecurityRequirements>
)}
<ParametersForm parameters={parameters} />
{body && (
@@ -317,27 +316,7 @@ export default function PlaygroundClient({
);
}
function SecurityTabsSelectItem({ security }: { security: SecurityEntry[] }) {
return (
<div className="flex flex-col gap-2 max-w-[600px]">
{security.map((item) => (
<div key={item.id}>
<p
className={cn(
'font-mono font-medium',
item.deprecated && 'text-fd-muted-foreground line-through',
)}
>
{item.id}
</p>
<p className="text-fd-muted-foreground whitespace-pre-wrap">{item.description}</p>
</div>
))}
</div>
);
}
function SecurityTabs({
function SecurityRequirements({
securities,
setSecurityId,
securityId,
@@ -348,24 +327,49 @@ function SecurityTabs({
setSecurityId: (value: number) => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const engine = useDataEngine();
const t = useTranslations();
const { isLoading, error } = useAuth();
const defaultOpen = isLoading || error != null;
const [open, setOpen] = useState(defaultOpen);
const { CollapsiblePanel = DefaultCollapsiblePanel } =
useApiContext().client.playground?.components ?? {};
const result = (
<CollapsiblePanel title={t.authorization} data-type="authorization">
useOnChange(defaultOpen, () => {
if (defaultOpen) setOpen(true);
});
return (
<CollapsiblePanel
title={
<>
{t.authorization}
{isLoading && (
<span className="border-s ps-2 inline-flex items-center gap-1.5 text-fd-muted-foreground text-xs font-mono">
<Spinner /> {t.fetchingToken}
</span>
)}
</>
}
data-type="authorization"
open={open}
onOpenChange={setOpen}
>
{error != null && (
<div className="p-2 border rounded-lg bg-fd-secondary">
<p className="text-fd-muted-foreground font-medium mb-1">{t.fetchTokenError}</p>
<p>{String(error)}</p>
</div>
)}
<Select value={securityId.toString()} onValueChange={(v) => setSecurityId(Number(v))}>
<SelectTrigger>
<SelectValue>
<SecurityTabsSelectItem security={securities[securityId]} />
<SecurityRequirement requirement={securities[securityId]} />
</SelectValue>
</SelectTrigger>
<SelectContent>
{securities.map((security, i) => (
<SelectItem key={i} value={i.toString()}>
<SecurityTabsSelectItem security={security} />
<SecurityRequirement requirement={security} />
</SelectItem>
))}
</SelectContent>
@@ -373,33 +377,32 @@ function SecurityTabs({
{children}
</CollapsiblePanel>
);
}
for (let i = 0; i < securities.length; i++) {
const security = securities[i];
function SecurityRequirement({ requirement }: { requirement: SecurityEntry[] }) {
const { schemes } = useApiContext();
return (
<div className="flex flex-col gap-2 max-w-[600px]">
{requirement.map((item) => {
const scheme = schemes[item.id];
for (const item of security) {
if (item.type === 'oauth2') {
return (
<OauthDialog
scheme={item}
scopes={item.scopes}
open={open}
setOpen={(v) => {
setOpen(v);
if (v) {
setSecurityId(i);
}
}}
setToken={(token) => engine.update(['header', 'Authorization'], token)}
>
{result}
</OauthDialog>
<div key={item.id}>
<p
className={cn(
'font-mono font-medium',
scheme.deprecated && 'text-fd-muted-foreground line-through',
)}
>
{item.id}
</p>
<p className="text-fd-muted-foreground whitespace-pre-wrap">{scheme.description}</p>
</div>
);
}
}
}
return result;
})}
</div>
);
}
const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
@@ -509,41 +512,51 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
export interface AuthField {
fieldName: FieldKey;
schemeId: string;
storageKey: string;
defaultValue: unknown;
original?: SecurityEntry;
children: ReactNode;
mapOutput?: (values: unknown) => unknown;
}
function useAuthInputs(
securities?: SecurityEntry[],
transform?: (fields: AuthField[]) => AuthField[],
) {
function useAuthInputs(engine: DataEngine, requirements: SecurityEntry[][]) {
const authCtx = useAuth();
const storageKeys = useStorageKey();
const t = useTranslations();
const inputs = useMemo(() => {
const result: AuthField[] = [];
if (!securities) return result;
const {
schemes,
client: { playground: { transformAuthInputs } = {} },
} = useApiContext();
const [requirementId, setRequirementId] = useState(() => {
if (requirements.length === 0) return -1;
for (const security of securities) {
if (security.type === 'http' && security.scheme === 'basic') {
const idx = requirements.findIndex((s) => s.every((item) => !schemes[item.id].deprecated));
return idx !== -1 ? idx : 0;
});
const requirement = requirementId === -1 ? null : requirements[requirementId];
let inputs = useMemo<AuthField[]>(() => {
if (!requirement) return [];
return requirement.map((item) => {
const scheme = schemes[item.id];
if (scheme.type === 'http' && scheme.scheme === 'basic') {
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
return {
fieldName,
original: security,
schemeId: item.id,
storageKey: storageKeys.AuthField(item.id),
defaultValue: {
username: '',
password: '',
},
mapOutput(out) {
mapOutput(out: unknown) {
if (out && typeof out === 'object') {
const obj = out as Record<string, unknown>;
return `Basic ${btoa(`${obj.username ?? ''}:${obj.password ?? ''}`)}`;
}
return out;
},
children: (
@@ -562,49 +575,24 @@ function useAuthInputs(
fieldName={fieldName}
/>
),
});
} else if (security.type === 'oauth2') {
};
}
if (scheme.type === 'oauth2') {
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
return {
fieldName,
original: security,
schemeId: item.id,
storageKey: storageKeys.AuthField(item.id),
defaultValue: 'Bearer ',
children: (
<fieldset className="flex flex-col gap-2">
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
{t.accessToken}
</label>
<div className="flex gap-2">
<FieldInput
fieldName={fieldName}
field={{
type: 'string',
}}
className="flex-1"
/>
<OauthDialogTrigger
type="button"
className={cn(
buttonVariants({
size: 'sm',
color: 'secondary',
}),
)}
>
{t.authorize}
</OauthDialogTrigger>
</div>
</fieldset>
),
});
} else if (security.type === 'http') {
children: <OAuth2Input fieldName={fieldName} security={item} />,
};
}
if (scheme.type === 'http') {
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
return {
fieldName,
original: security,
schemeId: item.id,
storageKey: storageKeys.AuthField(item.id),
defaultValue: 'Bearer ',
children: (
<FieldSet
@@ -615,86 +603,166 @@ function useAuthInputs(
}}
/>
),
});
} else if (security.type === 'apiKey') {
const fieldName: FieldKey = [security.in!, security.name!];
result.push({
};
}
if (scheme.type === 'apiKey') {
const fieldName: FieldKey = [scheme.in!, scheme.name!];
return {
fieldName,
schemeId: item.id,
defaultValue: '',
original: security,
storageKey: storageKeys.AuthField(item.id),
children: (
<FieldSet
fieldName={fieldName}
name={`${security.name} (${security.in})`}
name={`${scheme.name} (${scheme.in})`}
field={{
type: 'string',
}}
/>
),
});
} else {
const fieldName: FieldKey = ['header', 'Authorization'];
result.push({
fieldName,
defaultValue: '',
original: security,
children: (
<>
<FieldSet
name={`${t.authorization} (${t.header})`}
fieldName={fieldName}
field={{
type: 'string',
}}
/>
<p className="text-fd-muted-foreground text-xs">{t.openIdUnsupported}</p>
</>
),
});
};
}
}
// fallback: openid or unknown
const fieldName: FieldKey = ['header', 'Authorization'];
return {
fieldName,
schemeId: item.id,
defaultValue: '',
storageKey: storageKeys.AuthField(item.id),
children: (
<>
<FieldSet
name={`${t.authorization} (${t.header})`}
fieldName={fieldName}
field={{
type: 'string',
}}
/>
<p className="text-fd-muted-foreground text-xs">{t.openIdUnsupported}</p>
</>
),
};
});
}, [requirement, storageKeys, schemes, t]);
if (transformAuthInputs) inputs = transformAuthInputs(inputs);
return transform ? transform(result) : result;
}, [securities, transform, t]);
useListener({
stf: engine,
onUpdate(key) {
for (const item of inputs) {
if (!arrayStartsWith(item.fieldName, key)) continue;
const value = engine.get(item.fieldName);
const mapInputs = (values: FormValues) => {
const cloned = structuredClone(values);
for (const item of inputs) {
if (!item.mapOutput) continue;
objectSet(cloned, item.fieldName, item.mapOutput(objectGet(cloned, item.fieldName)));
}
return cloned;
};
const initAuthValues = (stf: Stf) => {
const { dataEngine } = stf;
for (const item of inputs) {
const stored = localStorage.getItem(storageKeys.AuthField(item));
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === typeof item.defaultValue) {
dataEngine.init(item.fieldName, parsed);
continue;
if (value != null) {
localStorage.setItem(item.storageKey, JSON.stringify(value));
}
}
},
});
dataEngine.init(item.fieldName, item.defaultValue);
useOnChange(authCtx.updatedSchemeId, () => {
const { updatedSchemeId } = authCtx;
if (!updatedSchemeId) return;
const { token } = authCtx.store[updatedSchemeId]!;
const input = inputs.find((input) => input.schemeId === updatedSchemeId);
if (input) {
// update current value
engine.update(input.fieldName, token);
return;
}
// reset
return () => {
for (const item of inputs) {
stf.dataEngine.delete(item.fieldName);
}
};
};
const idx = requirements.findIndex((requirement) =>
requirement.some((item) => item.id === updatedSchemeId),
);
if (idx !== -1) {
// persisted value
localStorage.setItem(storageKeys.AuthField(updatedSchemeId), JSON.stringify(token));
setRequirementId(idx);
}
});
return { inputs, mapInputs, initAuthValues };
return {
inputs,
requirementId,
setRequirementId,
mapInputs(values: FormValues) {
const cloned = structuredClone(values);
for (const item of inputs) {
if (!item.mapOutput) continue;
objectSet(cloned, item.fieldName, item.mapOutput(objectGet(cloned, item.fieldName)));
}
return cloned;
},
initAuthInputs() {
for (const item of inputs) {
const stored = localStorage.getItem(item.storageKey);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === typeof item.defaultValue) {
engine.init(item.fieldName, parsed);
continue;
}
}
engine.init(item.fieldName, item.defaultValue);
}
// reset
return () => {
for (const item of inputs) {
engine.delete(item.fieldName);
}
};
},
};
}
function OAuth2Input({ fieldName, security }: { fieldName: FieldKey; security: SecurityEntry }) {
const [open, setOpen] = useState(false);
const engine = useDataEngine();
const t = useTranslations();
return (
<fieldset className="flex flex-col gap-2">
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
{t.accessToken}
</label>
<div className="flex gap-2">
<FieldInput
fieldName={fieldName}
field={{
type: 'string',
}}
className="flex-1"
/>
<OAuthDialog open={open} onOpenChange={setOpen}>
<OAuthDialogTrigger
type="button"
className={cn(
buttonVariants({
size: 'sm',
color: 'secondary',
}),
)}
>
{t.authorize}
</OAuthDialogTrigger>
<OAuthDialogContent
setOpen={setOpen}
schemeId={security.id}
scopes={security.scopes}
setToken={(token) => engine.update(['header', 'Authorization'], token)}
></OAuthDialogContent>
</OAuthDialog>
</div>
</fieldset>
);
}
function Route({ route, ...props }: ComponentProps<'div'> & { route: string }) {
@@ -9,7 +9,7 @@ import {
import { useForm } from 'react-hook-form';
import { Input, labelVariants } from '@/ui/components/input';
import { useQuery } from '@/utils/use-query';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import {
@@ -21,17 +21,17 @@ import {
} from '@/ui/components/select';
import type { OAuth2SecurityScheme } from '@/types';
import { useTranslations } from '@/ui/client/i18n';
import { useAuth } from '../auth';
import { useApiContext } from '@/ui/contexts/api';
type FlowType = keyof NonNullable<OAuth2SecurityScheme['flows']>;
export interface AuthDialogProps {
scheme: OAuth2SecurityScheme;
export interface AuthDialogContentProps {
schemeId: string;
scopes: string[];
open: boolean;
setOpen: (v: boolean) => void;
setToken: (token: string) => void;
children: ReactNode;
}
interface FormValues {
@@ -41,15 +41,19 @@ interface FormValues {
password: string;
}
interface AuthCodeState {
export interface AuthCodeState {
redirect_uri: string;
client_id: string;
client_secret: string;
/** name of the source security scheme */
scheme: string;
}
interface ImplicitState {
export interface ImplicitState {
redirect_uri: string;
client_id: string;
/** name of the source security scheme */
scheme: string;
}
interface FlowInfo {
@@ -58,17 +62,33 @@ interface FlowInfo {
supported: boolean;
}
export function OauthDialog({
scheme,
scopes,
setToken,
children,
open,
setOpen,
}: AuthDialogProps) {
const [type, setType] = useState(() => {
export const OAuthDialog = Dialog;
export function OAuthDialogContent(props: AuthDialogContentProps) {
const t = useTranslations();
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t.authorization}</DialogTitle>
<DialogDescription>{t.obtainAccessToken}</DialogDescription>
</DialogHeader>
<Content {...props} />
</DialogContent>
);
}
function Content({ schemeId, scopes, setToken, setOpen }: AuthDialogContentProps) {
const { schemes } = useApiContext();
const tokenInfo = useAuth().store[schemeId];
const scheme = schemes[schemeId];
if (!scheme || scheme.type !== 'oauth2')
throw new Error('unexpected schemaId: must be type oauth2');
const [type, setType] = useState<FlowType | null>(() => {
return Object.keys(scheme.flows!)[0] as FlowType;
});
const t = useTranslations();
const allFlows: Record<FlowType, FlowInfo> = useMemo(
() => ({
@@ -101,78 +121,19 @@ export function OauthDialog({
[t],
);
const form = useForm<FormValues>({
defaultValues: {
clientId: '',
clientSecret: '',
const defaultValues = useMemo<FormValues>(() => {
return {
clientId: tokenInfo?.client_id ?? '',
clientSecret: tokenInfo?.type === 'authorization_code' ? tokenInfo.client_secret : '',
username: '',
password: '',
},
});
const authCodeCallback = useQuery(async (code: string, state: AuthCodeState) => {
const value = scheme.flows!.authorizationCode!;
const res = await fetch(value.tokenUrl!, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
// note: `state` could be invalid, but server will check it
redirect_uri: state.redirect_uri,
client_id: state.client_id,
client_secret: state.client_secret,
}),
});
if (!res.ok) throw new Error(await res.text());
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
access_token: string;
token_type?: string;
};
}, [tokenInfo]);
setToken(`${token_type} ${access_token}`);
setOpen(false);
const form = useForm<FormValues>({
defaultValues,
});
useEffect(() => {
if (scheme.flows!.authorizationCode) {
const params = new URLSearchParams(window.location.search);
const state = params.get('state');
const code = params.get('code');
if (state && code) {
const parsedState = JSON.parse(state) as AuthCodeState;
setOpen(true);
form.setValue('clientId', parsedState.client_id);
form.setValue('clientSecret', parsedState.client_secret);
authCodeCallback.start(code, parsedState);
window.history.replaceState(null, '', window.location.pathname);
return;
}
}
if (scheme.flows!.implicit && window.location.hash.length > 1) {
const params = new URLSearchParams(window.location.hash.slice(1));
const state = params.get('state');
const token = params.get('access_token');
const type = params.get('token_type') ?? 'Bearer';
if (state && token) {
const parsedState = JSON.parse(state) as ImplicitState;
form.setValue('clientId', parsedState.client_id);
setToken(`${type} ${token}`);
window.history.replaceState(null, '', window.location.pathname);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
}, []);
const authorize = useQuery(async (values: FormValues) => {
if (type === 'implicit') {
const value = scheme.flows![type]!;
@@ -185,6 +146,7 @@ export function OauthDialog({
params.set(
'state',
JSON.stringify({
scheme: schemeId,
client_id: values.clientId,
redirect_uri: window.location.href,
} satisfies ImplicitState),
@@ -207,6 +169,7 @@ export function OauthDialog({
client_id: values.clientId,
client_secret: values.clientSecret,
redirect_uri: window.location.href,
scheme: schemeId,
} satisfies AuthCodeState),
);
@@ -262,134 +225,122 @@ export function OauthDialog({
}
});
const isLoading = authorize.isLoading;
const onSubmit = form.handleSubmit((values) => {
return authorize.start(values);
});
const isLoading = authorize.isLoading || authCodeCallback.isLoading;
const error = authCodeCallback.error ?? authorize.error;
return (
<Dialog open={open} onOpenChange={setOpen}>
{children}
<DialogContent>
<DialogHeader>
<DialogTitle>{t.authorization}</DialogTitle>
<DialogDescription>{t.obtainAccessToken}</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-6"
onSubmit={(e) => {
void onSubmit(e);
e.stopPropagation();
}}
>
<Select value={type} onValueChange={setType as (s: string) => void}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(scheme.flows!).map((key) => {
const { name, description } = allFlows[key as FlowType];
<form
className="flex flex-col gap-6"
onSubmit={(e) => {
void onSubmit(e);
e.stopPropagation();
}}
>
<Select value={type ?? ''} onValueChange={setType as (s: string) => void}>
<SelectTrigger>
<SelectValue placeholder="Select a flow" />
</SelectTrigger>
<SelectContent>
{Object.keys(scheme.flows!).map((key) => {
const { name, description } = allFlows[key as FlowType];
return (
<SelectItem key={key} value={key}>
<p className="font-medium">{name}</p>
<p className="text-fd-muted-foreground">{description}</p>
</SelectItem>
);
})}
</SelectContent>
</Select>
return (
<SelectItem key={key} value={key}>
<p className="font-medium">{name}</p>
<p className="text-fd-muted-foreground">{description}</p>
</SelectItem>
);
})}
</SelectContent>
</Select>
{(type === 'authorizationCode' ||
type === 'clientCredentials' ||
type === 'implicit') && (
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_id" className={cn(labelVariants())}>
{t.clientId}
</label>
<p className="text-fd-muted-foreground text-sm">{t.clientIdHint}</p>
<Input
id="client_id"
placeholder={t.inputPlaceholder}
type="text"
autoComplete="off"
disabled={isLoading}
{...form.register('clientId', { required: true })}
/>
</fieldset>
)}
{(type === 'authorizationCode' || type === 'clientCredentials') && (
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_secret" className={cn(labelVariants())}>
{t.clientSecret}
</label>
<p className="text-fd-muted-foreground text-sm">{t.clientSecretHint}</p>
<Input
id="client_secret"
placeholder={t.inputPlaceholder}
type="password"
autoComplete="off"
disabled={isLoading}
{...form.register('clientSecret', { required: true })}
/>
</fieldset>
)}
{type === 'password' && (
<>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="username" className={cn(labelVariants())}>
{t.usernameField}
</label>
<Input
id="username"
placeholder={t.inputPlaceholder}
type="text"
disabled={isLoading}
autoComplete="off"
{...form.register('username', { required: true })}
/>
</fieldset>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="password" className={cn(labelVariants())}>
{t.clientSecret}
</label>
<Input
id="password"
placeholder={t.inputPlaceholder}
type="password"
autoComplete="off"
disabled={isLoading}
{...form.register('password', { required: true })}
/>
</fieldset>
</>
)}
{allFlows[type].supported ? (
<>
{error ? <p className="text-red-400 font-medium text-sm">{String(error)}</p> : null}
<button
type="submit"
disabled={isLoading}
className={cn(
buttonVariants({
color: 'primary',
}),
)}
>
{authCodeCallback.isLoading ? t.fetchingToken : t.submit}
</button>
</>
) : (
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
{t.unsupported}
</p>
)}
</form>
</DialogContent>
</Dialog>
{(type === 'authorizationCode' || type === 'clientCredentials' || type === 'implicit') && (
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_id" className={cn(labelVariants())}>
{t.clientId}
</label>
<p className="text-fd-muted-foreground text-sm">{t.clientIdHint}</p>
<Input
id="client_id"
placeholder={t.inputPlaceholder}
type="text"
autoComplete="off"
disabled={isLoading}
{...form.register('clientId', { required: true })}
/>
</fieldset>
)}
{(type === 'authorizationCode' || type === 'clientCredentials') && (
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_secret" className={cn(labelVariants())}>
{t.clientSecret}
</label>
<p className="text-fd-muted-foreground text-sm">{t.clientSecretHint}</p>
<Input
id="client_secret"
placeholder={t.inputPlaceholder}
type="password"
autoComplete="off"
disabled={isLoading}
{...form.register('clientSecret', { required: true })}
/>
</fieldset>
)}
{type === 'password' && (
<>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="username" className={cn(labelVariants())}>
{t.usernameField}
</label>
<Input
id="username"
placeholder={t.inputPlaceholder}
type="text"
autoComplete="off"
disabled={isLoading}
{...form.register('username', { required: true })}
/>
</fieldset>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="password" className={cn(labelVariants())}>
{t.clientSecret}
</label>
<Input
id="password"
placeholder={t.inputPlaceholder}
type="password"
autoComplete="off"
disabled={isLoading}
{...form.register('password', { required: true })}
/>
</fieldset>
</>
)}
{type && allFlows[type].supported ? (
<>
{authorize.error ? (
<p className="text-red-400 font-medium text-sm">{String(authorize.error)}</p>
) : null}
<button
type="submit"
className={cn(
buttonVariants({
color: 'primary',
}),
)}
>
{t.submit}
</button>
</>
) : (
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
{t.unsupported}
</p>
)}
</form>
);
}
export const OauthDialogTrigger = DialogTrigger;
export const OAuthDialogTrigger = DialogTrigger;
@@ -0,0 +1,13 @@
import { cn } from '@/utils/cn';
import { Loader2Icon } from 'lucide-react';
export function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn('size-4 animate-spin', className)}
{...props}
/>
);
}
@@ -1,5 +1,6 @@
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
import { doubleQuote } from '../string-utils';
export const csharp: CodeUsageGenerator = {
label: 'C#',
@@ -35,7 +36,7 @@ export const csharp: CodeUsageGenerator = {
const headerLines: string[] = [];
function addHeader(key: string, value: string) {
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`);
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${doubleQuote(value)});`);
}
for (const k in headers) {
addHeader(k, headers[k].value);
@@ -1,4 +1,4 @@
import { escapeString, indent, inputToString } from '@/requests/string-utils';
import { doubleQuote, indent, inputToString, singleQuote } from '@/requests/string-utils';
import type { CodeUsageGenerator } from '@/requests/generators';
export const curl: CodeUsageGenerator = {
@@ -6,7 +6,7 @@ export const curl: CodeUsageGenerator = {
lang: 'bash',
generate(url, data) {
const s: string[] = [];
s.push(`curl -X ${data.method} "${url}"`);
s.push(`curl -X ${data.method.toUpperCase()} "${url}"`);
for (const header in data.header) {
const value = `${header}: ${data.header[header].value}`;
@@ -17,23 +17,22 @@ export const curl: CodeUsageGenerator = {
for (const k in data.cookie) {
const param = data.cookie[k];
s.push(`--cookie ${JSON.stringify(`${k}=${param.value}`)}`);
s.push(`--cookie ${doubleQuote(`${k}=${param.value}`)}`);
}
if (data.body && data.bodyMediaType === 'multipart/form-data') {
if (typeof data.body !== 'object') throw new Error('[CURL] request body must be an object.');
for (const [key, value] of Object.entries(data.body)) {
s.push(`-F ${key}=${JSON.stringify(inputToString(value))}`);
s.push(`-F ${key}=${doubleQuote(inputToString(value))}`);
}
} else if (data.body && data.bodyMediaType) {
const escaped = escapeString(
const escaped = singleQuote(
inputToString(
data.body,
// @ts-expect-error -- assume the body media type is supported
data.bodyMediaType,
),
"'",
);
s.push(`-H "Content-Type: ${data.bodyMediaType}"`);
@@ -1,4 +1,4 @@
import { indent } from '@/requests/string-utils';
import { doubleQuote, indent } from '@/requests/string-utils';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
@@ -9,17 +9,17 @@ export const go: CodeUsageGenerator = {
const imports = ['fmt', 'net/http', 'io/ioutil'];
const headers = new Map<string, string>();
const variables = new Map<string, string>();
variables.set('url', JSON.stringify(url));
variables.set('url', doubleQuote(url));
for (const header in data.header) {
headers.set(header, JSON.stringify(data.header[header].value));
headers.set(header, doubleQuote(data.header[header].value));
}
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers.set(
'Cookie',
JSON.stringify(cookies.map(([k, param]) => `${k}=${param.value}`).join('; ')),
doubleQuote(cookies.map(([k, param]) => `${k}=${param.value}`).join('; ')),
);
}
@@ -47,7 +47,7 @@ ${Array.from(variables.entries())
.map(([k, v]) => indent(`${k} := ${v}`))
.join('\n')}
${body ? indent(body) : ''}
req, _ := http.NewRequest("${data.method}", url, ${body ? 'body' : 'nil'})
req, _ := http.NewRequest("${data.method.toUpperCase()}", url, ${body ? 'body' : 'nil'})
${indent(
Array.from(headers.entries())
.map(([key, value]) => `req.Header.Add("${key}", ${value})`)
@@ -1,4 +1,4 @@
import { indent } from '@/requests/string-utils';
import { doubleQuote, indent } from '@/requests/string-utils';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
@@ -46,11 +46,11 @@ export const java: CodeUsageGenerator = {
// Build request
s.push('HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()');
s.push(indent(`.uri(URI.create(${JSON.stringify(url)}))`));
s.push(indent(`.uri(URI.create(${doubleQuote(url)}))`));
// Add headers
for (const [key, param] of Object.entries(headers)) {
s.push(indent(`.header(${JSON.stringify(key)}, ${JSON.stringify(param.value)})`));
s.push(indent(`.header(${doubleQuote(key)}, ${doubleQuote(param.value)})`));
}
if (data.bodyMediaType) {
@@ -63,7 +63,7 @@ export const java: CodeUsageGenerator = {
if (cookies.length > 0) {
const cookieString = cookies.map(([key, param]) => `${key}=${param.value}`).join('; ');
s.push(indent(`.header("Cookie", ${JSON.stringify(cookieString)})`));
s.push(indent(`.header("Cookie", ${doubleQuote(cookieString)})`));
}
const arg = body ? 'body' : '';
@@ -1,4 +1,4 @@
import { indent } from '@/requests/string-utils';
import { doubleQuote, indent } from '@/requests/string-utils';
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
@@ -10,7 +10,7 @@ export const javascript: CodeUsageGenerator = {
const options = new Map<string, string>();
const headers: Record<string, string> = {};
options.set('method', JSON.stringify(data.method));
options.set('method', `"${data.method.toUpperCase()}"`);
if (data.bodyMediaType) {
headers['Content-Type'] = data.bodyMediaType;
}
@@ -44,7 +44,7 @@ export const javascript: CodeUsageGenerator = {
options.set('body', 'body');
}
const params = [JSON.stringify(url)];
const params = [doubleQuote(url)];
if (options.size > 0) {
const str = Array.from(options.entries())
.map(([k, v]) => indent(k === v ? k : `${k}: ${v}`))
@@ -1,5 +1,6 @@
import type { CodeUsageGenerator } from '@/requests/generators';
import { resolveMediaAdapter } from '@/requests/media/adapter';
import { doubleQuote } from '../string-utils';
export const python: CodeUsageGenerator = {
label: 'Python',
@@ -7,7 +8,7 @@ export const python: CodeUsageGenerator = {
generate(url, data, { mediaAdapters }) {
const headers: Record<string, string> = {};
const imports = new Set<string>();
const params = [`"${data.method}"`, 'url'];
const params = [`"${data.method.toUpperCase()}"`, 'url'];
let body: string | undefined;
imports.add('requests');
@@ -48,7 +49,7 @@ export const python: CodeUsageGenerator = {
.map((name) => 'import ' + name)
.join('\n')}
url = ${JSON.stringify(url)}
url = ${doubleQuote(url)}
${body ?? ''}
response = requests.request(${params.join(', ')})
@@ -62,7 +63,7 @@ export function generatePythonObject(v: unknown, imports: Set<string>): string {
} else if (typeof v === 'boolean') {
return v ? 'True' : 'False';
} else if (typeof v === 'string') {
return JSON.stringify(v);
return doubleQuote(v);
} else if (typeof v === 'number') {
return v.toString();
} else if (Array.isArray(v)) {
@@ -73,7 +74,7 @@ export function generatePythonObject(v: unknown, imports: Set<string>): string {
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)}`,
([key, value]) => ` ${doubleQuote(key)}: ${generatePythonObject(value, imports)}`,
);
return `{\n${entries.join(', \n')}\n}`;
} else {
@@ -1,4 +1,4 @@
import { escapeString, inputToString } from '@/requests/string-utils';
import { backtickQuote, inputToString, tripleDoubleQuote } from '@/requests/string-utils';
export { resolveMediaAdapter, isMediaTypeSupported } from './resolve-adapter';
// @ts-expect-error -- untyped
import js2xml from 'xml-js/lib/js2xml';
@@ -187,7 +187,7 @@ export const defaultAdapters = {
for (const [key, value] of Object.entries(data.body as object)) {
if (!value) continue;
const escaped = escapeString(inputToString(value, 'application/json'), '`');
const escaped = backtickQuote(inputToString(value, 'application/json'));
s.push(`mp.WriteField("${key}", ${escaped})`);
}
@@ -231,28 +231,28 @@ function str(
if (mediaType === 'application/json') {
return `const body = JSON.stringify(${JSON.stringify(init, null, 2)})`;
}
return `const body = ${escapeString(inputToString(init, mediaType), '`')}`;
return `const body = ${backtickQuote(inputToString(init, mediaType))}`;
}
if (ctx.lang === 'python') {
return `body = ${escapeString(inputToString(init, mediaType), '"""')}`;
return `body = ${tripleDoubleQuote(inputToString(init, mediaType))}`;
}
if (ctx.lang === 'go') {
const { addImport } = ctx as GoContext;
addImport('strings');
return `body := strings.NewReader(${escapeString(inputToString(init, mediaType), '`')})`;
return `body := strings.NewReader(${backtickQuote(inputToString(init, mediaType))})`;
}
if (ctx.lang === 'java') {
const { addImport } = ctx as JavaContext;
addImport('java.net.http.HttpRequest.BodyPublishers');
return `var body = BodyPublishers.ofString(${escapeString(inputToString(init, mediaType), '"""')});`;
return `var body = BodyPublishers.ofString(${tripleDoubleQuote(inputToString(init, mediaType))});`;
}
if (ctx.lang === 'csharp') {
const input = `\n${inputToString(init, mediaType)}\n`;
return `var body = new StringContent(${escapeString(input, '"""')}, Encoding.UTF8, "${mediaType}");`;
return `var body = new StringContent(${tripleDoubleQuote(input)}, Encoding.UTF8, "${mediaType}");`;
}
}
+27 -3
View File
@@ -41,10 +41,34 @@ export function inputToString(
return js2xml(value, { compact: true, spaces: 2 });
}
export function escapeString(str: string, delimit?: string): string {
if (!delimit) return JSON.stringify(str);
/**
* Returns the input string wrapped in single quotes, escaping internal single quotes and backslashes.
*/
export function singleQuote(str: string): string {
return `'${str.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}
return `${delimit}${str.replaceAll(delimit, `\\${delimit}`)}${delimit}`;
/**
* Returns the input string wrapped in double quotes, escaping internal double quotes and backslashes.
*/
export function doubleQuote(str: string): string {
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
/**
* Returns the input string wrapped in Python triple double-quotes,
* escaping embedded triple quotes and backslashes.
*/
export function tripleDoubleQuote(str: string): string {
// Escape \ first, then triple double quotes
return `"""${str.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"')}"""`;
}
/**
* Returns the input string wrapped in backticks, escaping internal backticks and backslashes.
*/
export function backtickQuote(str: string): string {
return `\`${str.replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``;
}
export function indent(code: string, tab: number = 1) {
+3 -2
View File
@@ -1,7 +1,8 @@
import type { EncodedParameter, EncodedParameterMultiple } from '@/requests/media/encode';
import type { HttpMethods } from '@/types';
export interface RawRequestData {
method: string;
method: HttpMethods;
path: Record<string, unknown>;
query: Record<string, unknown>;
@@ -13,7 +14,7 @@ export interface RawRequestData {
}
export interface RequestData {
method: string;
method: HttpMethods;
path: Record<string, EncodedParameter>;
query: Record<string, EncodedParameterMultiple>;
+10 -2
View File
@@ -52,7 +52,7 @@ export function APIPage({
</div>
);
const content = renderPageLayout(
let content = renderPageLayout(
{
operations: operations?.map((item) => {
const pathItem = dereferenced.paths?.[item.path];
@@ -111,8 +111,16 @@ export function APIPage({
ctx,
);
if (ctx.playground?.enabled !== false && ctx.playground?.provider) {
content = ctx.playground.provider({ children: content });
}
return (
<ctx.clientBoundary.ApiProvider shikiOptions={ctx.shikiOptions} client={ctx.client ?? {}}>
<ctx.clientBoundary.ApiProvider
schemes={dereferenced.components?.securitySchemes ?? {}}
shikiOptions={ctx.shikiOptions}
client={ctx.client ?? {}}
>
<ctx.clientBoundary.ServerProvider servers={dereferenced.servers as ServerObject[]}>
{content}
</ctx.clientBoundary.ServerProvider>
+13 -7
View File
@@ -27,6 +27,8 @@ import type { ExampleRequestItem } from './operation/get-example-requests';
import { compile } from '@fumari/json-schema-ts';
import { pickSchema } from '@/utils/schema/pick';
import { encodeInternalRef } from '@/utils/schema/ref';
import type { RequestTabsRenderContext } from './operation/request-tabs';
import { PlaygroundAuthProvider } from '@/ui/client/boundary.lazy';
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
operation: NoReference<MethodInformation>;
@@ -108,13 +110,7 @@ export interface CreateAPIPageOptions {
content?: {
renderResponseTabs?: (tabs: ResponseTab[], ctx: RenderContext) => ReactNode;
renderRequestTabs?: (
items: ExampleRequestItem[],
ctx: RenderContext & {
route: string;
operation: NoReference<MethodInformation>;
},
) => ReactNode;
renderRequestTabs?: (items: ExampleRequestItem[], ctx: RequestTabsRenderContext) => ReactNode;
renderAPIExampleLayout?: (
slots: {
@@ -201,6 +197,11 @@ export interface CreateAPIPageOptions {
* @defaultValue true
*/
enabled?: boolean;
/**
* render a page-level provider (useful for handling auth)
*/
provider?: (props: { children: ReactNode }) => ReactNode;
/**
* replace the server-side renderer
*/
@@ -247,6 +248,10 @@ export function createAPIPage(
.use(rehypeReact);
}
function renderPlaygroundProviderDefault({ children }: { children: ReactNode }) {
return <PlaygroundAuthProvider>{children}</PlaygroundAuthProvider>;
}
function renderPlaygroundDefault({ path, method, ctx }: APIPlaygroundProps) {
return (
<ctx.clientBoundary.PlaygroundClient
@@ -291,6 +296,7 @@ export function createAPIPage(
},
playground: {
...options.playground,
provider: options.playground?.provider ?? renderPlaygroundProviderDefault,
render: options.playground?.render ?? renderPlaygroundDefault,
},
renderHeading(depth, text, props) {
@@ -26,6 +26,10 @@ export const SchemaUI = wrapLazy(() =>
export const PlaygroundClient = wrapLazy(() => import('@/playground/client'));
export const PlaygroundAuthProvider = wrapLazy(() =>
import('@/playground/auth').then((mod) => ({ default: mod.AuthProvider })),
);
// Waku wraps all export functions of a "use client" file in `React.lazy`, but `lazy(lazy(() => ...))` causes error.
// we wrap another layer of component such that it is valid
// TODO: perhaps we can remove it once Waku migrated to vite-rsc
@@ -1,4 +1,3 @@
import type { AuthField } from '@/playground/client';
import { useApiContext } from '../contexts/api';
import { useMemo } from 'react';
@@ -10,8 +9,7 @@ export function useStorageKey() {
return useMemo(
() => ({
of: (name: KeyName) => getStorageKey(storageKeyPrefix, name),
AuthField: (field: AuthField) =>
getStorageKey(storageKeyPrefix, `auth-${field.original?.id ?? field.fieldName}`),
AuthField: (schemeId: string) => getStorageKey(storageKeyPrefix, `auth-${schemeId}`),
}),
[storageKeyPrefix],
);
+8 -3
View File
@@ -1,6 +1,6 @@
'use client';
import { createContext, type ReactNode, use, useEffect, useMemo, useState } from 'react';
import type { RenderContext, ServerObject } from '@/types';
import type { RenderContext, SecuritySchemeObject, ServerObject } from '@/types';
import { defaultAdapters, type MediaAdapter } from '@/requests/media/adapter';
import { useStorageKey } from '../client/storage-key';
import type { APIPageClientOptions } from '../client';
@@ -21,7 +21,9 @@ interface ServerContextType {
setServerVariables: (value: Record<string, string>) => void;
}
export type ApiProviderProps = InheritFromContext;
export interface ApiProviderProps extends InheritFromContext {
schemes: Record<string, SecuritySchemeObject>;
}
export interface SelectedServer {
url: string;
@@ -32,6 +34,7 @@ export interface SelectedServer {
interface ApiContextType extends InheritFromContext {
mediaAdapters: Record<string, MediaAdapter>;
codeUsages: CodeUsageGeneratorRegistry;
schemes: Record<string, SecuritySchemeObject>;
}
const ApiContext = createContext<ApiContextType | null>(null);
@@ -55,6 +58,7 @@ export function ApiProvider({
children,
shikiOptions,
client,
schemes,
}: ApiProviderProps & { children: ReactNode }) {
return (
<ApiContext
@@ -71,12 +75,13 @@ export function ApiProvider({
shikiOptions,
client,
codeUsages,
schemes,
mediaAdapters: {
...defaultAdapters,
...client.mediaAdapters,
},
};
}, [client, shikiOptions])}
}, [client, schemes, shikiOptions])}
>
{children}
</ApiContext>
@@ -25,6 +25,7 @@ import { slug } from 'github-slugger';
import * as ClientBoundary from '@/ui/client/boundary';
import { dereferenceDocument } from '@/utils/document/dereference';
import { parseSecurities } from '@/utils/schema';
import { AuthProvider } from '@/playground/auth';
export interface ClientApiPageProps extends Omit<ApiPageProps, 'document'> {
payload: ClientApiPagePayload;
@@ -98,6 +99,10 @@ export function createClientAPIPage({
);
}
function renderPlaygroundProviderDefault({ children }: { children: ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
return function ClientAPIPage({ payload, ...props }) {
const processed = useMemo(() => dereferenceDocument(payload.bundled), [payload.bundled]);
@@ -116,6 +121,7 @@ export function createClientAPIPage({
},
playground: {
...options.playground,
provider: options.playground?.provider ?? renderPlaygroundProviderDefault,
render: options.playground?.render ?? renderPlaygroundDefault,
},
renderHeading(depth, text, props) {
@@ -15,6 +15,11 @@ import { resolveRequestData } from '@/utils/url';
import { MethodLabel } from '../components/method-label';
import type { ExampleRequestItem } from './get-example-requests';
export interface RequestTabsRenderContext extends RenderContext {
route: string;
operation: NoReference<MethodInformation>;
}
export function RequestTabs({
path,
operation,
@@ -36,59 +41,7 @@ export function RequestTabs({
});
}
function renderRequestTabsDefault(
items: ExampleRequestItem[],
ctx: RenderContext & {
route: string;
operation: NoReference<MethodInformation>;
},
) {
function renderItem(item: ExampleRequestItem) {
const requestData = item.data;
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
body: (
<>
<I18nLabel label="titleRequestBody" />
<code className="text-xs text-fd-muted-foreground ms-auto">
{requestData.bodyMediaType}
</code>
</>
),
cookie: <I18nLabel label="cookieParameters" />,
header: <I18nLabel label="headerParameters" />,
query: <I18nLabel label="queryParameters" />,
path: <I18nLabel label="pathParameters" />,
};
return (
<>
{item.description && ctx.renderMarkdown(item.description)}
<div className="flex flex-row gap-2 items-center justify-between">
<MethodLabel>{requestData.method}</MethodLabel>
<code>{resolveRequestData(ctx.route, item.encoded)}</code>
</div>
<Accordions type="multiple" className="mt-2">
{Object.entries(displayNames).map(([k, v]) => {
const data = requestData[k as keyof RawRequestData];
if (!data || Object.keys(data).length === 0) return;
return (
<AccordionItem key={k} value={k}>
<AccordionHeader>
<AccordionTrigger>{v}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{ctx.renderCodeBlock('json', JSON.stringify(data, null, 2))}
</AccordionContent>
</AccordionItem>
);
})}
</Accordions>
</>
);
}
function renderRequestTabsDefault(items: ExampleRequestItem[], ctx: RequestTabsRenderContext) {
let children: ReactNode;
if (items.length > 1) {
children = (
@@ -102,13 +55,13 @@ function renderRequestTabsDefault(
</TabsList>
{items.map((item) => (
<TabsContent key={item.id} value={item.id}>
{renderItem(item)}
<RequestTabsItem item={item} ctx={ctx} />
</TabsContent>
))}
</Tabs>
);
} else if (items.length === 1) {
children = renderItem(items[0]);
children = <RequestTabsItem item={items[0]} ctx={ctx} />;
} else {
children = (
<p className="text-fd-muted-foreground text-xs">
@@ -126,3 +79,55 @@ function renderRequestTabsDefault(
</div>
);
}
function RequestTabsItem({
item,
ctx,
}: {
item: ExampleRequestItem;
ctx: RequestTabsRenderContext;
}) {
const requestData = item.data;
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
body: (
<>
<I18nLabel label="titleRequestBody" />
<code className="text-xs text-fd-muted-foreground ms-auto">
{requestData.bodyMediaType}
</code>
</>
),
cookie: <I18nLabel label="cookieParameters" />,
header: <I18nLabel label="headerParameters" />,
query: <I18nLabel label="queryParameters" />,
path: <I18nLabel label="pathParameters" />,
};
return (
<>
{item.description && ctx.renderMarkdown(item.description)}
<div className="flex flex-row gap-2 items-center justify-between">
<MethodLabel>{requestData.method}</MethodLabel>
<code>{resolveRequestData(ctx.route, item.encoded)}</code>
</div>
<Accordions type="multiple" className="mt-2">
{Object.entries(displayNames).map(([k, v]) => {
const data = requestData[k as keyof RawRequestData];
if (!data || Object.keys(data).length === 0) return;
return (
<AccordionItem key={k} value={k}>
<AccordionHeader>
<AccordionTrigger>{v}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{ctx.renderCodeBlock('json', JSON.stringify(data, null, 2))}
</AccordionContent>
</AccordionItem>
);
})}
</Accordions>
</>
);
}
+5 -4
View File
@@ -6,6 +6,7 @@ import { removeUndefined } from '@/utils/remove-undefined';
import type { OperationOutput, PageOutput, WebhookOutput } from '@/utils/pages/builder';
import type { InternalOpenAPIMeta } from '@/server/source-api';
import { toStaticData } from '@/utils/pages/to-static-data';
import { doubleQuote } from '@/requests/string-utils';
export interface PagesToTextOptions {
/**
@@ -133,7 +134,7 @@ export function generateDocument(
if (imports) {
out.push(
...imports
.map((item) => `import { ${item.names.join(', ')} } from ${JSON.stringify(item.from)};`)
.map((item) => `import { ${item.names.join(', ')} } from ${doubleQuote(item.from)};`)
.join('\n'),
);
}
@@ -217,7 +218,7 @@ function pageContent({
webhooks,
operations,
}: ApiPageProps): string {
const propStrs: string[] = [`document={${JSON.stringify(document)}}`];
const propStrs: string[] = [`document={${doubleQuote(document)}}`];
// filter extra properties in props
if (webhooks) {
@@ -247,10 +248,10 @@ function pageContent({
);
}
if (showTitle) {
propStrs.push(`showTitle={${JSON.stringify(showTitle)}}`);
propStrs.push(`showTitle`);
}
if (showDescription) {
propStrs.push(`showDescription={${JSON.stringify(showDescription)}}`);
propStrs.push(`showDescription`);
}
return `<APIPage ${propStrs.join(' ')} />`;
+3 -9
View File
@@ -8,7 +8,6 @@ import type {
OperationObject,
PathItemObject,
ReferenceObject,
SecuritySchemeObject,
TagObject,
} from '@/types';
import { idToTitle } from '@/utils/id-to-title';
@@ -96,11 +95,10 @@ export function pickExample(value: ExampleLike): unknown | undefined {
}
}
/** parsed security scheme objects */
export type SecurityEntry = SecuritySchemeObject & {
export interface SecurityEntry {
scopes: string[];
id: string;
};
}
export function parseSecurities(
method: MethodInformation,
@@ -114,13 +112,9 @@ export function parseSecurities(
const list: SecurityEntry[] = [];
for (const [key, scopes] of Object.entries(map)) {
const scheme = dereferenced.components?.securitySchemes?.[key];
if (!scheme) continue;
list.push({
...scheme,
scopes,
id: key,
scopes,
});
}
+3 -2
View File
@@ -3,7 +3,7 @@ import { useMemo, useRef, useState } from 'react';
export function useQuery<I extends unknown[], T>(
fn: (...input: I) => Promise<T>,
): {
start: (...input: I) => void;
start: (...input: I) => Promise<T | void>;
reset: () => void;
data?: T;
error?: unknown;
@@ -23,11 +23,12 @@ export function useQuery<I extends unknown[], T>(
start(...input) {
setLoading(true);
void fnRef
return fnRef
.current(...input)
.then((res) => {
setData(res);
setError(undefined);
return res;
})
.catch((err) => {
setData(undefined);
+1 -1
View File
@@ -17,7 +17,7 @@ describe('Code Sample Generators', () => {
id: 'id',
},
bodyMediaType: 'application/json',
method: 'GET',
method: 'get',
cookie: {
mode: { value: 'light' },
},
+2 -2
View File
@@ -74,7 +74,7 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"},{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"},{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle={true} />
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"},{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"},{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle />
```
```mdx title="petstore.mdx"
@@ -105,5 +105,5 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"petstore"} webhooks={[]} operations={[{"path":"/pets","method":"get"},{"path":"/pets","method":"post"},{"path":"/pets/{petId}","method":"get"}]} showTitle={true} />
<APIPage document={"petstore"} webhooks={[]} operations={[{"path":"/pets","method":"get"},{"path":"/pets","method":"post"},{"path":"/pets/{petId}","method":"get"}]} showTitle />
```
+3 -3
View File
@@ -49,7 +49,7 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"}]} showTitle={true} />
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/special-events","method":"get"},{"path":"/special-events","method":"post"},{"path":"/special-events/{eventId}","method":"get"},{"path":"/special-events/{eventId}","method":"patch"},{"path":"/special-events/{eventId}","method":"delete"}]} showTitle />
```
```mdx title="operations.mdx"
@@ -74,7 +74,7 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"}]} showTitle={true} />
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/museum-hours","method":"get"}]} showTitle />
```
```mdx title="tickets.mdx"
@@ -107,5 +107,5 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle={true} />
<APIPage document={"museum"} webhooks={[]} operations={[{"path":"/tickets","method":"post"},{"path":"/tickets/{ticketId}/qr","method":"get"}]} showTitle />
```
+1 -1
View File
@@ -71,5 +71,5 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"unkey"} webhooks={[]} operations={[{"path":"/v1/liveness","method":"get"},{"path":"/v1/keys.getKey","method":"get"},{"path":"/v1/keys.deleteKey","method":"post"},{"path":"/v1/keys.createKey","method":"post"},{"path":"/v1/keys.verifyKey","method":"post"},{"path":"/v1/keys.updateKey","method":"post"},{"path":"/v1/keys.updateRemaining","method":"post"},{"path":"/v1/keys.getVerifications","method":"get"},{"path":"/v1/apis.getApi","method":"get"},{"path":"/v1/apis.createApi","method":"post"},{"path":"/v1/apis.listKeys","method":"get"},{"path":"/v1/apis.deleteApi","method":"post"}]} showTitle={true} />
<APIPage document={"unkey"} webhooks={[]} operations={[{"path":"/v1/liveness","method":"get"},{"path":"/v1/keys.getKey","method":"get"},{"path":"/v1/keys.deleteKey","method":"post"},{"path":"/v1/keys.createKey","method":"post"},{"path":"/v1/keys.verifyKey","method":"post"},{"path":"/v1/keys.updateKey","method":"post"},{"path":"/v1/keys.updateRemaining","method":"post"},{"path":"/v1/keys.getVerifications","method":"get"},{"path":"/v1/apis.getApi","method":"get"},{"path":"/v1/apis.createApi","method":"post"},{"path":"/v1/apis.listKeys","method":"get"},{"path":"/v1/apis.deleteApi","method":"post"}]} showTitle />
```
+14 -14
View File
@@ -419,7 +419,7 @@ describe('URL utilities', () => {
describe('resolveRequestData', () => {
test('basic path parameter substitution', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {
id: { value: '123' },
},
@@ -433,7 +433,7 @@ describe('URL utilities', () => {
test('multiple path parameters', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {
userId: { value: '123' },
postId: { value: '456' },
@@ -450,7 +450,7 @@ describe('URL utilities', () => {
test('array path parameter', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {
segments: { value: 'api/v1/users' },
},
@@ -464,7 +464,7 @@ describe('URL utilities', () => {
test('adds query parameters to clean path', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
limit: { values: ['10'] },
@@ -479,7 +479,7 @@ describe('URL utilities', () => {
test('handles array query parameters', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
tags: { values: ['javascript', 'typescript'] },
@@ -496,7 +496,7 @@ describe('URL utilities', () => {
// Core test cases for paths with existing query parameters (legacy API support)
test('path with existing query parameter - verbose example', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
limit: { values: ['5'] },
@@ -512,7 +512,7 @@ describe('URL utilities', () => {
test('path with existing query parameter - search example', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
limit: { values: ['10'] },
@@ -529,7 +529,7 @@ describe('URL utilities', () => {
test('path with existing query parameters and path parameters', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {
userId: { value: '123' },
},
@@ -547,7 +547,7 @@ describe('URL utilities', () => {
test('overrides existing query parameter with new value', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
verbose: { values: ['false'] },
@@ -563,7 +563,7 @@ describe('URL utilities', () => {
test('handles multiple existing query parameters', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
newParam: { values: ['value'] },
@@ -579,7 +579,7 @@ describe('URL utilities', () => {
test('handles array parameters with existing query string', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
categories: { values: ['tech', 'science'] },
@@ -595,7 +595,7 @@ describe('URL utilities', () => {
test('replaces existing array parameter', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {
tags: { values: ['new', 'updated'] },
@@ -611,7 +611,7 @@ describe('URL utilities', () => {
test('handles empty query parameters object', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {},
query: {},
header: {},
@@ -625,7 +625,7 @@ describe('URL utilities', () => {
test('complex scenario with path params, existing query, and new query params', () => {
const requestData: RequestData = {
method: 'GET',
method: 'get',
path: {
orgId: { value: 'acme' },
projectId: { value: 'web-app' },
+12
View File
@@ -89,6 +89,18 @@ export function fieldKeyStartsWith(a: string, b: string): boolean {
return b.length === 0 || a === b || a.startsWith(b + '.');
}
/**
* @returns if `a` starts with `b`.
*/
export function arrayStartsWith(a: FieldKey, b: FieldKey): boolean {
if (b.length === 0) return true;
if (a.length < b.length) return false;
for (let i = 0; i < b.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;