mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: add built-in UI to basic auth
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Add built-in UI to basic auth input (username:password)
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user