OpenAPI: Implement multiple security schemes support

This commit is contained in:
Fuma Nama
2025-05-27 20:14:59 +08:00
parent 020f77548b
commit 5770180018
6 changed files with 377 additions and 278 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Implement multiple security schemes support
+258 -175
View File
@@ -8,6 +8,7 @@ import {
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
@@ -39,7 +40,6 @@ import {
import { ChevronDown, LoaderCircle } from 'lucide-react';
import type { Security } from '@/utils/get-security';
import { useRequestData } from '@/ui/contexts/code-example';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import type { RequestData } from '@/requests/_shared';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
@@ -50,12 +50,6 @@ import {
} from '@/playground/schema';
interface FormValues {
authorization:
| string
| {
username: string;
password: string;
};
path: Record<string, string>;
query: Record<string, string>;
header: Record<string, string>;
@@ -75,13 +69,15 @@ export interface CustomField<TName extends FieldPath<FormValues>, Info> {
}) => ReactElement;
}
type SecurityEntry = Security & {
id: string;
};
export type ClientProps = HTMLAttributes<HTMLFormElement> & {
route: string;
method: string;
authorization?: Security & {
persistentId: string;
};
parameters?: ParameterField[];
securities: SecurityEntry[][];
body?: {
schema: RequestSchema;
mediaType: string;
@@ -97,7 +93,7 @@ export type ClientProps = HTMLAttributes<HTMLFormElement> & {
`${ParameterField['in']}.${string}`,
ParameterField
>;
auth?: CustomField<'authorization', RequestSchema>;
auth?: CustomField<FieldPath<FormValues>, RequestSchema>;
body?: CustomField<'body', RequestSchema>;
};
@@ -127,7 +123,7 @@ const ServerSelect = lazy(() => import('@/ui/server-select'));
export default function Client({
route,
method = 'GET',
authorization,
securities,
parameters,
body,
fields,
@@ -139,18 +135,17 @@ export default function Client({
const { server } = useServerSelectContext();
const requestData = useRequestData();
const fieldInfoMap = useMemo(() => new Map<string, FieldInfo>(), []);
const authInfo = usePersistentAuthInfo(authorization);
const { mediaAdapters } = useApiContext();
const submitHandler = useRef<SubmitHandler | null>(null);
const defaultValues: FormValues = useMemo(
() => ({
authorization: authInfo.info,
path: requestData.data.path,
query: requestData.data.query,
header: requestData.data.header,
body: requestData.data.body,
cookie: requestData.data.cookie,
}),
[authInfo.info, requestData.data],
[requestData.data],
);
const form = useForm<FormValues>({
@@ -180,19 +175,8 @@ export default function Client({
useEffect(() => {
const subscription = form.watch((_value) => {
const value = _value as FormValues;
if (authorization && value.authorization) {
authInfo.saveInfo(value.authorization);
writeAuthInfo(
authorization,
value.authorization,
value.header,
value.query,
value.cookie,
);
}
let value = _value as FormValues;
if (submitHandler.current) value = submitHandler.current(value);
requestData.saveData(toRequestData(method, body?.mediaType, value));
});
@@ -204,6 +188,8 @@ export default function Client({
}, []);
const onSubmit = form.handleSubmit((value) => {
if (submitHandler.current) value = submitHandler.current(value);
testQuery.start(value);
});
@@ -237,109 +223,78 @@ export default function Client({
)}
</button>
</div>
<FormBody
body={body}
fields={fields}
parameters={parameters}
authorization={authorization}
/>
{securities.length > 0 && (
<SecuritiesTabs
securities={securities}
onSetSubmitHandler={(v) => (submitHandler.current = v)}
/>
)}
<FormBody body={body} fields={fields} parameters={parameters} />
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
{authorization && <AuthFooter authorization={authorization} />}
</form>
</SchemaProvider>
</FormProvider>
);
}
function SecuritiesTabs({
securities,
onSetSubmitHandler,
}: {
securities: SecurityEntry[][];
onSetSubmitHandler: (handler: SubmitHandler) => void;
}) {
const [idx, setIdx] = useState(0);
return (
<div className="p-3">
<div className="flex items-center mb-4 overflow-auto border bg-fd-muted rounded-full">
{securities.map((security, i) => (
<button
type="button"
key={i}
onClick={() => setIdx(i)}
className={cn(
'flex-1 text-xs font-medium font-mono px-2 py-1.5 text-nowrap transition-colors',
i === idx
? 'bg-fd-primary/10 text-fd-primary'
: 'text-fd-muted-foreground hover:text-fd-accent-foreground',
)}
>
{security.map((item) => item.id).join(' & ')}
</button>
))}
</div>
<AuthInput
securities={securities[idx]}
onSetSubmitHandler={onSetSubmitHandler}
/>
</div>
);
}
const paramNames = ['Headers', 'Cookies', 'Query', 'Path'] as const;
const paramTypes = ['header', 'cookie', 'query', 'path'] as const;
function FormBody({
authorization,
parameters = [],
fields = {},
body,
}: Pick<ClientProps, 'parameters' | 'authorization' | 'body' | 'fields'>) {
}: Pick<ClientProps, 'parameters' | 'body' | 'fields'>) {
const params = useMemo(() => {
return paramTypes.map((param) => parameters.filter((v) => v.in === param));
}, [parameters]);
const auth = useMemo(():
| {
in: string;
field: RequestSchema;
}
| undefined => {
if (!authorization) return;
if (authorization.type === 'http' && authorization.scheme === 'basic') {
return {
in: 'header',
field: {
type: 'object',
required: ['username', 'password'],
description: authorization.description,
properties: {
username: {
type: 'string',
},
password: {
type: 'string',
},
},
},
};
} else if (
authorization.type === 'http' ||
authorization.type === 'oauth2'
) {
return {
in: 'header',
field: {
type: 'string',
description:
authorization.description ?? `The Authorization access token.`,
},
};
} else if (authorization.type === 'apiKey') {
return {
in: authorization.in,
field: {
type: 'string',
description: authorization.description ?? 'The API key.',
},
};
}
// TODO: handle OpenID connect
}, [authorization]);
function renderAuth() {
if (!auth) return null;
if (fields?.auth)
return renderCustomField('authorization', auth.field, fields.auth);
return (
<FieldSet
fieldName="authorization"
name="Authorization"
field={auth.field}
isRequired
/>
);
}
return (
<>
{params.map((param, i) => {
if (param.length === 0) return;
const name = paramNames[i];
const type = paramTypes[i];
if ((!auth || type !== auth.in) && param.length === 0) return;
return (
<CollapsiblePanel key={name} title={name}>
{auth && type === auth.in ? renderAuth() : null}
{param.map((field) => {
const fieldName = `${type}.${field.name}` as const;
@@ -428,6 +383,204 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
);
}
type SubmitHandler = (values: FormValues) => FormValues;
type AuthField = {
fieldName: string;
defaultValue: unknown;
original: SecurityEntry;
children: ReactNode;
mapOutput?: (values: unknown) => unknown;
};
/**
* manipulate values without mutating the original object
*
* @returns a new manipulated object
*/
function manipulateValues(
values: Record<string, unknown>,
fieldName: string,
update: (v: unknown) => unknown,
): Record<string, unknown> {
const root = { ...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] as Record<string, unknown>;
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 AuthInput({
securities,
onSetSubmitHandler,
}: {
securities: SecurityEntry[];
onSetSubmitHandler: (handler: SubmitHandler) => void;
}) {
const form = useFormContext();
const auth = useMemo(() => {
const result: AuthField[] = [];
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' &&
'username' in out &&
'password' in out
) {
return `Basic ${btoa(`${out.username}:${out.password}`)}`;
}
return out;
},
children: (
<FieldSet
name="Authorization (header)"
field={{
type: 'object',
required: ['username', 'password'],
description: security.description,
properties: {
username: {
type: 'string',
},
password: {
type: 'string',
},
},
}}
fieldName={fieldName}
/>
),
});
} else if (security.type === 'http' || security.type === 'oauth2') {
const fieldName = 'header.Authorization';
result.push({
fieldName: fieldName,
original: security,
defaultValue: 'Bearer ',
children: (
<>
<FieldSet
name="Authorization (header)"
fieldName={fieldName}
field={{
type: 'string',
description:
security.description ?? `The Authorization access token.`,
}}
/>
<AuthFooter authorization={security} />
</>
),
});
} else if (security.type === 'apiKey') {
const fieldName = `${security.in}.${security.name}`;
result.push({
fieldName,
defaultValue: '',
original: security,
children: (
<FieldSet
fieldName={fieldName}
name={security.name}
field={{
type: 'string',
description:
security.description ?? `The API key in ${security.in}.`,
}}
/>
),
});
}
// TODO: handle OpenID connect
}
return result;
}, [securities]);
useEffect(() => {
const prefix = '__fumadocs_auth';
for (const item of auth) {
const value = localStorage.getItem(prefix + item.original.id);
form.setValue(item.fieldName, value ? JSON.parse(value) : '');
}
return () => {
for (const item of auth) {
const v = form.getValues(item.fieldName);
if (v) {
localStorage.setItem(prefix + item.original.id, JSON.stringify(v));
}
form.reset((values) =>
manipulateValues(values, item.fieldName, () => undefined),
);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore `form`
}, [auth, securities]);
onSetSubmitHandler((values) => {
for (const item of auth) {
if (!item.mapOutput) continue;
values = manipulateValues(
values as unknown as Record<string, unknown>,
item.fieldName,
item.mapOutput,
) as unknown as FormValues;
}
return values;
});
return (
<div className="flex flex-col gap-4">
{auth.map((item) => (
<Fragment key={item.fieldName}>{item.children}</Fragment>
))}
</div>
);
}
function renderCustomField(
fieldName: string,
info: RequestSchema & { name?: string },
@@ -540,40 +693,6 @@ function DefaultResultDisplay({ data }: { data: FetchResult }) {
);
}
function usePersistentAuthInfo(authorization?: ClientProps['authorization']) {
const key = authorization
? `__fumadocs_auth_${authorization.persistentId}`
: null;
const [info, setInfo] = useState<FormValues['authorization']>(() => {
if (!authorization || authorization.type === 'apiKey') return '';
if (authorization.type === 'http' && authorization.scheme === 'basic') {
return {
username: '',
password: '',
};
}
return 'Bearer';
});
useEffect(() => {
if (!key) return;
const item = localStorage.getItem(key);
if (item) {
setInfo(JSON.parse(item));
}
}, [key]);
return {
info,
saveInfo: useEffectEvent((value: FormValues['authorization']) => {
if (!key) return;
localStorage.setItem(key, JSON.stringify(value));
}),
};
}
function CollapsiblePanel({
title,
children,
@@ -593,39 +712,3 @@ function CollapsiblePanel({
</Collapsible>
);
}
function writeAuthInfo(
authorization: Security,
input: FormValues['authorization'],
header: Record<string, unknown>,
query: Record<string, unknown>,
cookie: Record<string, string>,
) {
if (authorization.type === 'apiKey') {
if (authorization.in === 'header') {
header[authorization.name] = input as string;
}
if (authorization.in === 'query') {
query[authorization.name] = input as string;
}
if (authorization.in === 'cookie') {
cookie[authorization.name] = input as string;
}
return;
}
if (
authorization.type === 'http' &&
authorization.scheme === 'basic' &&
typeof input === 'object'
) {
header.Authorization = `Basic ${btoa(`${input.username}:${input.password}`)}`;
return;
}
if (typeof input === 'string') {
header.Authorization = input;
}
}
+20 -33
View File
@@ -1,6 +1,5 @@
import type { MethodInformation, RenderContext } from '@/types';
import { getPreferredType, type ParsedSchema } from '@/utils/schema';
import { getSecurities } from '@/utils/get-security';
import { type ClientProps } from './client';
import { ClientLazy } from '@/ui/lazy';
@@ -48,7 +47,7 @@ export async function APIPlayground({
};
const props: ClientProps = {
authorization: getAuthorizationField(method, ctx),
securities: parseSecurities(method, ctx),
method: method.method,
route: path,
parameters: method.parameters?.map((v) => ({
@@ -118,42 +117,30 @@ function writeReferences(
return output;
}
function getAuthorizationField(
function parseSecurities(
method: MethodInformation,
{ schema: { document } }: RenderContext,
): ClientProps['authorization'] {
): ClientProps['securities'] {
const result: ClientProps['securities'] = [];
const security = method.security ?? document.security ?? [];
if (security.length === 0) return;
if (security.length === 0) return result;
let item;
for (const requirements of security) {
const keys = Object.keys(requirements).length;
for (const map of security) {
const list: ClientProps['securities'][number] = [];
if (keys === 0) return;
else if (keys === 1) item = requirements;
for (const [key, scopes] of Object.entries(map)) {
const scheme = document.components?.securitySchemes?.[key];
if (!scheme) continue;
list.push({
...scheme,
scopes,
id: key,
});
}
result.push(list);
}
if (!item) {
console.warn(
`Cannot find suitable security scheme for API Playground from ${JSON.stringify(security, null, 2)}. Only schemes with one requirement are allowed at the moment.`,
);
return;
}
const scheme = getSecurities(item, document)[0];
if (scheme.type === 'oauth2') {
const flow = Object.keys(scheme.flows).at(0);
if (!flow) throw new Error("security scheme's `flows` must not be empty");
if (flow === 'implicit' || flow === 'password')
throw new Error(
`OAuth 2.0 flow type: ${flow} is not supported, consider other types like \`authorizationCode\` instead.`,
);
}
return {
persistentId: Object.keys(item)[0],
...scheme,
};
return result;
}
@@ -2,14 +2,13 @@ import type { MethodInformation, RenderContext } from '@/types';
import type { ResolvedSchema } from '@/utils/schema';
import { getPreferredType } from '@/utils/schema';
import { sample } from 'openapi-sampler';
import { getSecurities, getSecurityPrefix } from '@/utils/get-security';
import type { RequestData } from '@/requests/_shared';
export function getRequestData(
path: string,
method: MethodInformation,
sampleKey: string | null,
{ schema: { document } }: RenderContext,
_ctx: RenderContext,
): RequestData {
const result: RequestData = {
path: {},
@@ -49,16 +48,6 @@ export function getRequestData(
}
}
const requirements = method.security ?? document.security;
if (requirements && requirements.length > 0) {
for (const security of getSecurities(requirements[0], document)) {
const prefix = getSecurityPrefix(security);
const name = security.type === 'apiKey' ? security.name : 'Authorization';
result.header[name] = prefix ? `${prefix} <token>` : '<token>';
}
}
if (method.requestBody) {
const body = method.requestBody.content;
const type = getPreferredType(body);
+90 -57
View File
@@ -11,7 +11,7 @@ import {
type NoReference,
type ResolvedSchema,
} from '@/utils/schema';
import { getSecurities, getSecurityPrefix } from '@/utils/get-security';
import { getSecurityPrefix } from '@/utils/get-security';
import { idToTitle } from '@/utils/id-to-title';
import { Markdown } from '../markdown';
import { heading } from '../heading';
@@ -25,7 +25,13 @@ import {
} from '@/render/operation/api-example';
import { Badge, MethodLabel } from '@/ui/components/method-label';
import { type SampleGenerator } from '@/requests/_shared';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import {
Tab,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from 'fumadocs-ui/components/tabs';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { CopyResponseTypeScript } from '@/ui/client';
@@ -323,63 +329,90 @@ function AuthSection({
requirements: SecurityRequirementObject[];
ctx: RenderContext;
}) {
let id = 0;
const info: ReactNode[] = [];
for (const requirement of requirements) {
for (const schema of getSecurities(requirement, document)) {
const prefix = getSecurityPrefix(schema);
const scopeElement =
schema.scopes.length > 0 ? (
<p>
Scope: <code>{schema.scopes.join(', ')}</code>
</p>
) : null;
if (schema.type === 'http' || schema.type === 'oauth2') {
info.push(
<renderer.Property
key={id++}
name="Authorization"
type={prefix ? `${prefix} <token>` : '<token>'}
required
return (
<Tabs defaultValue="0">
<TabsList>
{requirements.map((securities, i) => (
<TabsTrigger key={i} value={i.toString()}>
{Object.keys(securities).join(' & ')}
</TabsTrigger>
))}
</TabsList>
{requirements.map((securities, i) => {
return (
<TabsContent
key={i}
value={i.toString()}
className="flex flex-col gap-4"
>
{schema.description ? <Markdown text={schema.description} /> : null}
<p>
In: <code>header</code>
</p>
{scopeElement}
</renderer.Property>,
);
}
{Object.entries(securities).map(([key, scopes]) => {
const schema = document.components?.securitySchemes?.[key];
if (!schema) return;
if (schema.type === 'apiKey') {
info.push(
<renderer.Property key={id++} name={schema.name} type="<token>">
{schema.description ? <Markdown text={schema.description} /> : null}
<p>
In: <code>{schema.in}</code>
{scopeElement}
</p>
</renderer.Property>,
);
}
const prefix = getSecurityPrefix(schema);
const scopeElement =
scopes.length > 0 ? (
<p>
Scope: <code>{scopes.join(', ')}</code>
</p>
) : null;
if (schema.type === 'openIdConnect') {
info.push(
<renderer.Property
key={id++}
name="OpenID Connect"
type="<token>"
required
>
{schema.description ? <Markdown text={schema.description} /> : null}
{scopeElement}
</renderer.Property>,
);
}
}
}
if (schema.type === 'http' || schema.type === 'oauth2') {
return (
<renderer.Property
key={key}
name="Authorization"
type={prefix ? `${prefix} <token>` : '<token>'}
required
>
{schema.description && (
<Markdown text={schema.description} />
)}
<p>
In: <code>header</code>
</p>
{scopeElement}
</renderer.Property>
);
}
return info;
if (schema.type === 'apiKey') {
return (
<renderer.Property
key={key}
name={schema.name}
type="<token>"
>
{schema.description && (
<Markdown text={schema.description} />
)}
<p>
In: <code>{schema.in}</code>
{scopeElement}
</p>
</renderer.Property>
);
}
if (schema.type === 'openIdConnect') {
return (
<renderer.Property
key={key}
name="OpenID Connect"
type="<token>"
required
>
{schema.description && (
<Markdown text={schema.description} />
)}
{scopeElement}
</renderer.Property>
);
}
})}
</TabsContent>
);
})}
</Tabs>
);
}
+3 -1
View File
@@ -32,7 +32,9 @@ export function getSecurities(
return results;
}
export function getSecurityPrefix(security: Security): string | undefined {
export function getSecurityPrefix(
security: SecuritySchemeObject,
): string | undefined {
if (security.type === 'http')
return {
basic: 'Basic',