OpenAPI: add built-in UI to basic auth

This commit is contained in:
Fuma Nama
2025-02-01 00:11:48 +08:00
parent 6a1dfc84eb
commit 9e02460795
5 changed files with 181 additions and 111 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Add built-in UI to basic auth input (username:password)
+62 -58
View File
@@ -6,7 +6,6 @@ import {
type ReactNode,
type TextareaHTMLAttributes,
use,
useCallback,
useEffect,
useMemo,
useRef,
@@ -70,45 +69,51 @@ const Context = createContext<{
setLoading: () => undefined,
});
const listeners: (() => void)[] = [];
function onUpdate() {
for (const listener of listeners) listener();
}
function AIDialog() {
const [_, update] = useState(0);
const shouldFocus = useRef(false); // should focus on input on next render
const { loading, setLoading, engine } = use(Context);
const onTry = useCallback(() => {
const onTry = () => {
if (!engine) return;
setLoading(true);
void engine
.regenerateLast(() => {
update((prev) => prev + 1);
})
.finally(() => {
setLoading(false);
});
}, [engine, setLoading]);
void engine.regenerateLast(onUpdate).finally(() => {
setLoading(false);
});
};
const onClear = useCallback(() => {
const onClear = () => {
engine?.clearHistory();
update((prev) => prev + 1);
}, [engine]);
onUpdate();
};
const onSubmit = useCallback(
(message: string) => {
if (!engine || message.length === 0) return;
const onSubmit = (message: string) => {
if (!engine || message.length === 0) return;
setLoading(true);
void engine
.prompt(message, () => {
update((prev) => prev + 1);
})
.finally(() => {
setLoading(false);
shouldFocus.current = true;
});
},
[engine, setLoading],
);
setLoading(true);
void engine.prompt(message, onUpdate).finally(() => {
setLoading(false);
shouldFocus.current = true;
});
};
useEffect(() => {
const listener = () => {
update((prev) => prev + 1);
};
listeners.push(listener);
return () => {
listeners.splice(listeners.indexOf(listener), 1);
};
}, []);
useEffect(() => {
if (shouldFocus.current) {
@@ -118,35 +123,6 @@ function AIDialog() {
});
const messages = engine?.getHistory() ?? [];
const activeBar = (
<div className="flex flex-row shrink-0 items-center gap-2 border-t py-1 px-3">
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
}),
'gap-1.5 rounded-full',
)}
onClick={onTry}
>
<RefreshCw className="size-4" />
Retry
</button>
<button
type="button"
className={cn(
buttonVariants({
color: 'ghost',
}),
'rounded-full',
)}
onClick={onClear}
>
Clear History
</button>
</div>
);
return (
<>
@@ -155,7 +131,35 @@ function AIDialog() {
<Message key={i} message={item} onSuggestionSelected={onSubmit} />
))}
</List>
{!loading ? activeBar : null}
{!loading && messages.at(-1)?.role === 'assistant' ? (
<div className="flex flex-row shrink-0 items-center gap-2 border-t p-2">
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
}),
'gap-1.5 rounded-full',
)}
onClick={onTry}
>
<RefreshCw className="size-4" />
Retry
</button>
<button
type="button"
className={cn(
buttonVariants({
color: 'ghost',
}),
'rounded-full',
)}
onClick={onClear}
>
Clear Chat
</button>
</div>
) : null}
{loading ? (
<button
type="button"
+1 -3
View File
@@ -10,9 +10,7 @@ const client = new OramaClient({
api_key: 'oPZjdlFbq5BpR54bV5Vj57RYt83Xosk7',
});
export default function CustomSearchDialog(
props: SharedProps,
): React.ReactElement {
export default function CustomSearchDialog(props: SharedProps) {
return (
<SearchDialog
{...props}
+6 -21
View File
@@ -9,7 +9,7 @@ import {
type NoReference,
type ParsedSchema,
} from '@/utils/schema';
import { getSecurities } from '@/utils/get-security';
import { getSecurities, type Security } from '@/utils/get-security';
import { APIPlayground } from '@/ui';
interface BaseRequestField {
@@ -48,7 +48,7 @@ interface FileSchema extends BaseSchema {
interface ObjectSchema extends BaseSchema {
type: 'object';
properties: Record<string, ReferenceSchema>;
properties: Record<string, RequestSchema | ReferenceSchema>;
/**
* Reference to schema, or true if it's `any`
@@ -84,7 +84,7 @@ interface Context {
export interface APIPlaygroundProps {
route: string;
method: string;
authorization?: PrimitiveRequestField & { authType: string };
authorization?: Security;
path?: PrimitiveRequestField[];
query?: PrimitiveRequestField[];
header?: PrimitiveRequestField[];
@@ -153,7 +153,7 @@ export function Playground({
function getAuthorizationField(
method: MethodInformation,
{ schema: { document } }: RenderContext,
): (PrimitiveRequestField & { authType: string }) | undefined {
): Security | undefined {
const security = method.security ?? document.security ?? [];
if (security.length === 0) return;
const singular = security.find(
@@ -161,22 +161,7 @@ function getAuthorizationField(
);
if (!singular) return;
const scheme = getSecurities(singular, document)[0];
return {
type: 'string',
name: scheme.type === 'apiKey' ? scheme.name : 'Authorization',
authType: scheme.type,
defaultValue:
scheme.type === 'oauth2' ||
(scheme.type === 'http' && scheme.scheme === 'bearer')
? 'Bearer'
: 'Basic',
isRequired: security.every(
(requirements) => Object.keys(requirements).length > 0,
),
description: 'The Authorization access token',
};
return getSecurities(singular, document)[0];
}
function getIdFromSchema(
@@ -336,7 +321,7 @@ function toSchema(
...schema,
type,
},
true,
required,
ctx,
);
}
+107 -29
View File
@@ -44,7 +44,12 @@ import {
import { ChevronDown } from 'lucide-react';
interface FormValues {
authorization: string;
authorization:
| string
| {
username: string;
password: string;
};
path: Record<string, unknown>;
query: Record<string, unknown>;
header: Record<string, unknown>;
@@ -63,6 +68,18 @@ export interface CustomField<TName extends FieldPath<FormValues>, Info> {
}) => ReactElement;
}
function defaultAuthValue(auth: APIPlaygroundProps['authorization']) {
if (!auth || auth.type === 'apiKey') return '';
if (auth.type === 'http' && auth.scheme === 'basic') {
return {
username: '',
password: '',
};
}
return 'Bearer';
}
export function APIPlayground({
route,
method = 'GET',
@@ -78,7 +95,7 @@ export function APIPlayground({
...props
}: APIPlaygroundProps & {
fields?: {
auth?: CustomField<'authorization', PrimitiveRequestField>;
auth?: CustomField<'authorization', RequestSchema>;
path?: CustomField<`path.${string}`, PrimitiveRequestField>;
query?: CustomField<`query.${string}`, PrimitiveRequestField>;
header?: CustomField<`header.${string}`, PrimitiveRequestField>;
@@ -93,7 +110,7 @@ export function APIPlayground({
const dynamicRef = useRef(new Map<string, DynamicField>());
const form = useForm<FormValues>({
defaultValues: {
authorization: authorization?.defaultValue,
authorization: defaultAuthValue(authorization),
path: getDefaultValues(path, schemas),
query: getDefaultValues(query, schemas),
header: getDefaultValues(header, schemas),
@@ -105,10 +122,44 @@ export function APIPlayground({
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(body, schemas),
);
const query = { ...input.query };
const header = { ...input.header };
if (input.authorization && authorization) {
if (authorization.type === 'apiKey') {
if (authorization.in === 'header') {
header[authorization.name] = input.authorization;
} else if (authorization.in === 'query') {
query[authorization.name] = input.authorization;
} else {
if ('cookie' in header) {
header.Cookie = header.cookie;
delete header.cookie;
}
header.Cookie = [
header.Cookie as string,
`${authorization.name}=${input.authorization}`,
]
.filter((s) => s.length > 0)
.join('; ');
}
} else if (
authorization.type === 'http' &&
authorization.scheme === 'basic'
) {
if (typeof input.authorization === 'object')
header.Authorization = `Basic ${btoa(`${input.authorization.username}:${input.authorization.password}`)}`;
} else {
header.Authorization = input.authorization;
}
}
const serverUrl = serverRef.current
? getUrl(serverRef.current.url, serverRef.current.variables)
: window.location.origin;
let url = `${serverUrl}${createPathnameFromInput(route, input.path, input.query)}`;
let url = `${serverUrl}${createPathnameFromInput(route, input.path, query)}`;
if (proxyUrl) {
const updated = new URL(proxyUrl, window.location.origin);
@@ -116,12 +167,6 @@ export function APIPlayground({
url = updated.toString();
}
const header = { ...input.header };
if (input.authorization && authorization) {
header[authorization.name] = input.authorization;
}
return fetcher.fetch({
url: url.toString(),
header,
@@ -138,13 +183,14 @@ export function APIPlayground({
useEffect(() => {
if (!authorization) return;
const key = `__fumadocs_authorization_${authorization.authType}`;
const key = `__fumadocs_auth_${JSON.stringify(authorization)}`;
const cached = localStorage.getItem(key);
if (cached) form.setValue('authorization', cached);
if (cached) form.setValue('authorization', JSON.parse(cached));
const subscription = form.watch((value, { name }) => {
if (name !== 'authorization' || !value.authorization) return;
localStorage.setItem(key, value.authorization);
if (!name || !name.startsWith('authorization') || !value.authorization)
return;
localStorage.setItem(key, JSON.stringify(value.authorization));
});
return () => {
@@ -201,11 +247,56 @@ export function APIPlayground({
<CollapsiblePanel title="Server URL">
<ServerSelect />
</CollapsiblePanel>
{authorization ? (
{header.length > 0 || authorization ? (
<CollapsiblePanel title="Headers">
{renderCustomField('authorization', authorization, fields.auth)}
{authorization?.type === 'http' &&
authorization.scheme === 'basic'
? renderCustomField(
'authorization',
{
name: 'Authorization',
type: 'object',
isRequired: true,
properties: {
username: {
type: 'string',
isRequired: true,
defaultValue: '',
},
password: {
type: 'string',
isRequired: true,
defaultValue: '',
},
},
},
fields.auth,
)
: authorization
? renderCustomField(
'authorization',
{
name: 'Authorization',
type: 'string',
isRequired: true,
description: 'The Authorization access token',
defaultValue: '',
},
fields.auth,
)
: null}
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{path.length > 0 ? (
<CollapsiblePanel title="Path">
{path.map((field) =>
@@ -232,19 +323,6 @@ export function APIPlayground({
</CollapsiblePanel>
) : null}
{header.length > 0 ? (
<CollapsiblePanel title="Headers">
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{body ? (
<CollapsiblePanel title="Body">
{body.type === 'object' && !fields.body ? (