OpenAPI: Support cookie parameters

This commit is contained in:
Fuma Nama
2025-03-12 20:05:26 +08:00
parent 768ba65edf
commit 670c1797d0
7 changed files with 222 additions and 169 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support cookie parameters
+4
View File
@@ -172,6 +172,10 @@ paths:
- Events
parameters:
- $ref: '#/components/parameters/EventId'
- name: test
in: cookie
schema:
type: string
responses:
'204':
description: Success - no content.
+178 -152
View File
@@ -13,7 +13,12 @@ import {
useContext,
useState,
} from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import {
Controller,
FormProvider,
useForm,
useFormContext,
} from 'react-hook-form';
import { cn, buttonVariants } from 'fumadocs-ui/components/api';
import type {
FieldPath,
@@ -80,20 +85,22 @@ export type ClientProps = HTMLAttributes<HTMLFormElement> & {
route: string;
method: string;
authorization?: Security;
path?: PrimitiveRequestField[];
query?: PrimitiveRequestField[];
header?: PrimitiveRequestField[];
parameters?: PrimitiveRequestField[];
body?: RequestSchema & {
mediaType: string;
};
schemas: Record<string, RequestSchema>;
/**
* Resolver for reference schemas you've passed
*/
references: Record<string, RequestSchema>;
proxyUrl?: string;
fields?: {
parameter?: CustomField<
`${PrimitiveRequestField['in']}.${string}`,
PrimitiveRequestField
>;
auth?: CustomField<'authorization', RequestSchema>;
path?: CustomField<`path.${string}`, PrimitiveRequestField>;
query?: CustomField<`query.${string}`, PrimitiveRequestField>;
header?: CustomField<`header.${string}`, PrimitiveRequestField>;
body?: CustomField<'body', RequestSchema>;
};
@@ -145,18 +152,24 @@ export function Client({
route,
method = 'GET',
authorization,
path = [],
header = [],
query = [],
parameters = [],
body,
fields = {},
schemas,
references,
proxyUrl,
components: { ResultDisplay = DefaultResultDisplay } = {},
...props
}: ClientProps) {
const { servers } = useApiContext();
const { server, setServer, setServerVariables } = useServerSelectContext();
const params = useMemo(() => {
return {
headers: parameters.filter((v) => v.in === 'header'),
cookies: parameters.filter((v) => v.in === 'cookie'),
queries: parameters.filter((v) => v.in === 'query'),
paths: parameters.filter((v) => v.in === 'path'),
};
}, [parameters]);
const dynamicRef = useRef(new Map<string, DynamicField>());
const requestData = useRequestData();
@@ -209,6 +222,7 @@ export function Client({
value.authorization,
value.header,
value.query,
value.cookie,
);
}
@@ -245,15 +259,144 @@ export function Client({
);
}
let authField: ReactNode = null;
if (authorization) {
authField = (
<>
{renderCustomField(
'authorization',
return (
<FormProvider {...form}>
<SchemaContext.Provider
value={useMemo(
() => ({ references: references, dynamic: dynamicRef }),
[references],
)}
>
<AuthProvider authorization={authorization}>
<form
{...props}
className={cn(
'not-prose flex flex-col gap-2 rounded-xl border p-3 shadow-md',
props.className,
)}
onSubmit={onSubmit}
>
<FormHeader
method={method}
route={route}
isLoading={testQuery.isLoading}
/>
{servers.length > 1 ? (
<CollapsiblePanel title="Server URL">
<ServerSelect
server={server}
onServerChanged={setServer}
onVariablesChanged={setServerVariables}
/>
</CollapsiblePanel>
) : null}
{params.headers.length > 0 || authorization ? (
<CollapsiblePanel title="Headers">
{authorization ? (
<AuthField authorization={authorization} />
) : null}
{params.headers.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.parameter,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{params.paths.length > 0 ? (
<CollapsiblePanel title="Path">
{params.paths.map((field) =>
renderCustomField(
`path.${field.name}`,
field,
fields.parameter,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{params.queries.length > 0 ? (
<CollapsiblePanel title="Query">
{params.queries.map((field) =>
renderCustomField(
`query.${field.name}`,
field,
fields.parameter,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{params.cookies.length > 0 ? (
<CollapsiblePanel title="Cookies">
{params.cookies.map((field) =>
renderCustomField(
`cookie.${field.name}`,
field,
fields.parameter,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{body ? (
<CollapsiblePanel title="Body">
{body.type === 'object' && !fields.body ? (
<ObjectInput field={body} fieldName="body" />
) : (
renderCustomField('body', body, fields.body)
)}
</CollapsiblePanel>
) : null}
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
</form>
</AuthProvider>
</SchemaContext.Provider>
</FormProvider>
);
}
function AuthProvider({
authorization,
children,
}: {
authorization?: Security;
children: ReactNode;
}) {
const form = useFormContext();
if (!authorization || authorization.type !== 'oauth2') return children;
// only the first one, so it looks simpler :)
const flow = Object.keys(authorization.flows)[0];
return (
<OauthDialog
flow={flow as keyof typeof authorization.flows}
scheme={authorization}
setToken={(token) => form.setValue('authorization', `Bearer ${token}`)}
>
{children}
</OauthDialog>
);
}
function AuthField({ authorization }: { authorization: Security }) {
return (
<>
<FieldSet
fieldName="authorization"
name="Authorization"
field={
authorization?.type === 'http' && authorization.scheme === 'basic'
? {
name: 'Authorization',
type: 'object',
isRequired: true,
properties: {
@@ -268,133 +411,25 @@ export function Client({
},
}
: {
name: 'Authorization',
type: 'string',
isRequired: true,
description: 'The Authorization access token',
},
fields.auth,
)}
{authorization?.type === 'oauth2' && (
<OauthDialogTrigger
type="button"
className={cn(
buttonVariants({
color: 'secondary',
}),
)}
>
Open
</OauthDialogTrigger>
)}
</>
);
}
let children = (
<form
{...props}
className={cn(
'not-prose flex flex-col gap-2 rounded-xl border p-3 shadow-md',
props.className,
)}
onSubmit={onSubmit}
>
<FormHeader
method={method}
route={route}
isLoading={testQuery.isLoading}
}
}
/>
{servers.length > 1 ? (
<CollapsiblePanel title="Server URL">
<ServerSelect
server={server}
onServerChanged={setServer}
onVariablesChanged={setServerVariables}
/>
</CollapsiblePanel>
) : null}
{header.length > 0 || authorization ? (
<CollapsiblePanel title="Headers">
{authField}
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
{authorization?.type === 'oauth2' && (
<OauthDialogTrigger
type="button"
className={cn(
buttonVariants({
color: 'secondary',
}),
)}
</CollapsiblePanel>
) : null}
{path.length > 0 ? (
<CollapsiblePanel title="Path">
{path.map((field) =>
renderCustomField(
`path.${field.name}`,
field,
fields.path,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{query.length > 0 ? (
<CollapsiblePanel title="Query">
{query.map((field) =>
renderCustomField(
`query.${field.name}`,
field,
fields.query,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{body ? (
<CollapsiblePanel title="Body">
{body.type === 'object' && !fields.body ? (
<ObjectInput field={body} fieldName="body" />
) : (
renderCustomField('body', body, fields.body)
)}
</CollapsiblePanel>
) : null}
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
</form>
);
if (authorization?.type === 'oauth2') {
// only the first one, so it looks simpler :)
const flow = Object.keys(authorization.flows)[0];
children = (
<OauthDialog
flow={flow as keyof typeof authorization.flows}
scheme={authorization}
setToken={(token) => form.setValue('authorization', `Bearer ${token}`)}
>
{children}
</OauthDialog>
);
}
return (
<FormProvider {...form}>
<SchemaContext.Provider
value={useMemo(
() => ({ references: schemas, dynamic: dynamicRef }),
[schemas],
)}
>
{children}
</SchemaContext.Provider>
</FormProvider>
>
Open
</OauthDialogTrigger>
)}
</>
);
}
@@ -545,6 +580,7 @@ function writeAuthHeader(
input: FormValues['authorization'],
header: Record<string, unknown>,
query: Record<string, unknown>,
cookie: Record<string, string>,
) {
if (authorization.type === 'apiKey') {
if (authorization.in === 'header') {
@@ -556,17 +592,7 @@ function writeAuthHeader(
}
if (authorization.in === 'cookie') {
if ('cookie' in header) {
header.Cookie = header.cookie;
delete header.cookie;
}
header.Cookie = [
header.Cookie as string,
`${authorization.name}=${input}`,
]
.filter((s) => s.length > 0)
.join('; ');
cookie[authorization.name] = input as string;
}
return;
}
+22 -4
View File
@@ -32,12 +32,30 @@ export function createBrowserFetcher(): Fetcher {
if (paramValue.length > 0) headers.append(key, paramValue.toString());
}
const proxyUrl = options.proxyUrl
? new URL(options.proxyUrl, window.location.origin)
: null;
if (typeof document !== 'undefined') {
for (const key in options.cookie) {
const value = options.cookie[key];
if (!value) continue;
document.cookie = [
`${key}=${value}`,
'HttpOnly',
proxyUrl && proxyUrl.origin !== window.location.origin
? `domain=${proxyUrl.host}`
: 'path=/',
].join(';');
}
}
let url = getPathnameFromInput(route, options.path, options.query);
if (options.proxyUrl) {
const updated = new URL(options.proxyUrl, window.location.origin);
updated.searchParams.append('url', url);
url = updated.toString();
if (proxyUrl) {
proxyUrl.searchParams.append('url', url);
url = proxyUrl.toString();
}
return fetch(url, {
+12 -11
View File
@@ -21,7 +21,10 @@ interface BaseSchema {
isRequired: boolean;
}
export type PrimitiveRequestField = BaseRequestField & PrimitiveSchema;
export type PrimitiveRequestField = BaseRequestField &
PrimitiveSchema & {
in: 'cookie' | 'header' | 'query' | 'path';
};
interface PrimitiveSchema extends BaseSchema {
type: 'boolean' | 'string' | 'number';
@@ -119,15 +122,7 @@ export async function APIPlayground({
authorization: getAuthorizationField(method, ctx),
method: method.method,
route: path,
path: method.parameters
?.filter((v) => v.in === 'path')
.map((v) => parameterToField(v, context)),
query: method.parameters
?.filter((v) => v.in === 'query')
.map((v) => parameterToField(v, context)),
header: method.parameters
?.filter((v) => v.in === 'header')
.map((v) => parameterToField(v, context)),
parameters: method.parameters?.map((v) => parameterToField(v, context)),
body:
bodySchema && mediaType
? {
@@ -135,7 +130,7 @@ export async function APIPlayground({
mediaType: mediaType as string,
}
: undefined,
schemas: context.references,
references: context.references,
proxyUrl: ctx.proxyUrl,
...client,
};
@@ -195,8 +190,14 @@ function parameterToField(
v: NoReference<ParameterObject>,
ctx: Context,
): PrimitiveRequestField {
const allowed = ['header', 'cookie', 'query', 'path'] as const;
if (!allowed.includes(v.in as (typeof allowed)[number]))
throw new Error(`Unsupported parameter in: "${v.in}"`);
return {
name: v.name,
in: v.in as (typeof allowed)[number],
...(toSchema(
v.schema ?? { type: 'string' },
v.required ?? false,
@@ -77,7 +77,7 @@ export function generateDocument(
if (addGeneratedComment !== false) {
let commentContent =
'This file was generated by Fumadocs.\nDo not edit this file directly. Any changes should be made by running the generation command again.';
'This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again.';
if (typeof addGeneratedComment === 'string') {
commentContent = addGeneratedComment;
@@ -3,7 +3,6 @@ import * as Python from '@/requests/python';
import * as Go from '@/requests/go';
import * as Curl from '@/requests/curl';
import * as JS from '@/requests/javascript';
import { methodKeys } from '@/build-routes';
import type { RequestData } from '@/requests/_shared';
describe('Code Sample Generators', () => {