OpenAPI: Support forwarding pure playground cookies with proxy

This commit is contained in:
Fuma Nama
2026-03-29 19:39:16 +08:00
parent 43cd8db7e7
commit d38e64135d
4 changed files with 144 additions and 88 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Support forwarding pure playground cookies with proxy
+8 -7
View File
@@ -11,7 +11,7 @@ import {
useRef,
} from 'react';
import { useApiContext, useServerContext } from '@/ui/contexts/api';
import type { FetchResult } from '@/playground/fetcher';
import type { BrowserFetcherOptions, FetchResult } from '@/playground/fetcher';
import type { SecurityEntry } from '@/playground/index';
import { getStatusInfo } from './status-info';
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
@@ -94,8 +94,11 @@ export interface PlaygroundClientOptions {
*/
transformAuthInputs?: (fields: AuthField[]) => AuthField[];
fetchOptions?: BrowserFetcherOptions;
/**
* Request timeout in seconds (default: 10s)
* @deprecated use `fetchOptions.requestTimeout` instead.
*/
requestTimeout?: number;
@@ -160,7 +163,8 @@ export default function PlaygroundClient({
client: {
playground: {
components: { ResultDisplay = DefaultResultDisplay } = {},
requestTimeout = 10,
requestTimeout,
fetchOptions = { requestTimeout },
transformAuthInputs,
} = {},
},
@@ -194,7 +198,7 @@ export default function PlaygroundClient({
const testQuery = useQuery(async (input: FormValues) => {
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters, requestTimeout),
mod.createBrowserFetcher(mediaAdapters, { proxyUrl, ...fetchOptions }),
);
const encoded = encodeRequestData(
{ ...mapInputs(input), method, bodyMediaType: body?.mediaType },
@@ -209,10 +213,7 @@ export default function PlaygroundClient({
),
resolveRequestData(route, encoded),
),
{
proxyUrl,
...encoded,
},
encoded,
);
});
+70 -35
View File
@@ -1,10 +1,7 @@
import type { RequestData } from '@/requests/types';
import type { MediaAdapter } from '@/requests/media/adapter';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export interface FetchOptions extends RequestData {
proxyUrl?: string;
}
import type { Awaitable } from '@/types';
export interface FetchResult {
status: number;
@@ -18,63 +15,101 @@ export interface Fetcher {
*
* @param url - The full URL of request.
*/
fetch: (url: string, options: FetchOptions) => Promise<FetchResult>;
fetch: (url: string, data: RequestData) => Promise<FetchResult>;
}
export interface BrowserFetcherOptions {
/**
* Request timeout in seconds (default: 10s)
*/
requestTimeout?: number | false;
proxyUrl?: string;
/**
* Forward cookies via search parameters when API proxy is configured.
*
* @default true
*/
proxyForwardCookie?: boolean;
/**
* transform the request options before sending.
*/
onRequestInit?: (requestInit: RequestInit) => Awaitable<RequestInit>;
}
export function createBrowserFetcher(
adapters: Record<string, MediaAdapter>,
requestTimeout: number,
{
proxyUrl,
proxyForwardCookie = true,
requestTimeout = 10,
onRequestInit,
}: BrowserFetcherOptions = {},
): Fetcher {
return {
async fetch(url, options) {
const headers = new Headers();
if (options.bodyMediaType && options.bodyMediaType !== 'multipart/form-data')
headers.append('Content-Type', options.bodyMediaType);
async fetch(url, data) {
let requestUrl = new URL(url, document.baseURI);
let requestInit: RequestInit = {
method: data.method,
cache: 'no-cache',
signal:
typeof requestTimeout === 'number'
? AbortSignal.timeout(requestTimeout * 1000)
: undefined,
};
for (const key in options.header) {
const param = options.header[key];
const headers = (requestInit.headers = new Headers());
for (const key in data.header) {
const param = data.header[key];
headers.append(key, param.value);
}
const proxyUrl = options.proxyUrl ? new URL(options.proxyUrl, document.baseURI) : null;
if (proxyUrl) {
proxyUrl.searchParams.append('url', url);
url = proxyUrl.toString();
requestUrl = new URL(proxyUrl, document.baseURI);
requestUrl.searchParams.append('url', url);
}
let body: BodyInit | undefined = undefined;
if (options.bodyMediaType && options.body) {
const adapter = resolveMediaAdapter(options.bodyMediaType, adapters);
if (data.bodyMediaType && data.body) {
const adapter = resolveMediaAdapter(data.bodyMediaType, adapters);
if (!adapter)
return {
status: 400,
type: 'text',
data: `[Fumadocs] No adapter for ${options.bodyMediaType}, you need to specify one from 'createOpenAPI()'.`,
data: `[Fumadocs] No adapter for ${data.bodyMediaType}, you need to specify one from 'createOpenAPI()'.`,
};
body = adapter.encode(options as { body: unknown });
if (data.bodyMediaType !== 'multipart/form-data') {
headers.append('Content-Type', data.bodyMediaType);
}
requestInit.body = adapter.encode(data as { body: unknown });
}
// cookies
for (const key in options.cookie) {
const param = options.cookie[key];
const segs: string[] = [`${key}=${param.value}`];
if (proxyUrl && proxyForwardCookie) {
const encoded = Object.entries(data.cookie)
.map(([k, v]) => `${k}=${encodeURIComponent(v.value)}`)
.join('; ');
requestUrl.searchParams.set('cookie', encoded);
requestInit.credentials = 'omit';
} else {
for (const key in data.cookie) {
const param = data.cookie[key];
const segs: string[] = [`${key}=${encodeURIComponent(param.value)}`];
if (proxyUrl && proxyUrl.origin !== window.location.origin)
segs.push(`domain=${proxyUrl.host}`);
segs.push('path=/', 'max-age=30');
if (proxyUrl && requestUrl.origin !== window.location.origin)
segs.push(`domain=${requestUrl.host}`);
segs.push('path=/', 'max-age=30');
document.cookie = segs.join('; ');
document.cookie = segs.join('; ');
}
}
return fetch(url, {
method: options.method,
cache: 'no-cache',
headers,
body,
signal: AbortSignal.timeout(requestTimeout * 1000),
})
if (onRequestInit) requestInit = await onRequestInit(requestInit);
return fetch(requestUrl, requestInit)
.then(async (res) => {
const contentType = res.headers.get('Content-Type') ?? '';
let type: FetchResult['type'];
+61 -46
View File
@@ -1,10 +1,13 @@
const keys = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'] as const;
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'] as const;
const methodsWithBody = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
type Proxy = {
[K in (typeof keys)[number]]: (req: Request) => Promise<Response>;
};
type Handler = (req: Request) => Promise<Response>;
interface CreateProxyOptions {
export interface Proxy extends Record<(typeof methods)[number], Handler> {
handle: Handler;
}
export interface CreateProxyOptions {
/**
* Filter by prefixes of request url
*
@@ -42,45 +45,32 @@ export function createProxy(options: CreateProxyOptions = {}): Proxy {
},
overrides,
} = options;
const handlers: Partial<Proxy> = {};
const handlers: Partial<Proxy> = {
handle: handler,
};
async function handler(req: Request): Promise<Response> {
const searchParams = new URL(req.url).searchParams;
const url = searchParams.get('url');
const rawUrl = searchParams.get('url');
if (!url)
if (!rawUrl)
return Response.json('[Proxy] A `url` query parameter is required for proxy url', {
status: 400,
});
const parsedUrl = URL.parse(url);
if (!parsedUrl)
const targetUrl = URL.parse(rawUrl);
if (!targetUrl)
return Response.json('[Proxy] Invalid `url` parameter value.', {
status: 400,
});
if (allowedOrigins && !allowedOrigins.includes(parsedUrl.origin)) {
return Response.json(`[Proxy] The origin "${parsedUrl.origin}" is not allowed.`, {
if (allowedOrigins && !allowedOrigins.includes(targetUrl.origin)) {
return Response.json(`[Proxy] The origin "${targetUrl.origin}" is not allowed.`, {
status: 400,
});
}
const contentLength = req.headers.get('content-length');
const hasBody = contentLength && parseInt(contentLength) > 0;
let proxied = new Request(parsedUrl, {
method: req.method,
cache: 'no-cache',
headers: req.headers,
body:
hasBody && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())
? await req.arrayBuffer()
: undefined,
});
if (overrides?.request) {
proxied = overrides.request(proxied);
}
const proxied = await rewriteRequest(req, targetUrl, searchParams.get('cookie'));
if (!filterRequest(proxied)) {
return Response.json('[Proxy] The proxied request is not allowed', {
@@ -88,26 +78,51 @@ export function createProxy(options: CreateProxyOptions = {}): Proxy {
});
}
proxied.headers.forEach((_value, originalKey) => {
const key = originalKey.toLowerCase();
try {
return rewriteResponse(await fetch(proxied));
} catch (err) {
return Response.json(
`[Proxy] Failed to proxy request: ${err instanceof Error ? err.message : 'unknown reason'}`,
{
status: 500,
},
);
}
}
if (key === 'origin') {
proxied.headers.delete(originalKey);
}
async function rewriteRequest(
request: Request,
url: URL,
cookie: string | null,
): Promise<Request> {
const headers = new Headers(request.headers);
headers.delete('origin');
if (cookie) {
headers.set('Cookie', cookie);
}
const contentLength = headers.get('content-length');
const hasBody = contentLength && parseInt(contentLength) > 0;
const proxied = new Request(url, {
method: request.method,
cache: 'no-cache',
headers,
body:
hasBody && methodsWithBody.has(request.method.toUpperCase())
? await request.arrayBuffer()
: undefined,
});
let res = await fetch(proxied).catch((e) => new Error(e.toString()));
if (res instanceof Error) {
return Response.json(`[Proxy] Failed to proxy request: ${res.message}`, {
status: 500,
});
}
return overrides?.request ? overrides.request(proxied) : proxied;
}
async function rewriteResponse(response: Response): Promise<Response> {
if (overrides?.response) {
res = overrides.response(res);
response = overrides.response(response);
}
const headers = new Headers(res.headers);
const headers = new Headers(response.headers);
headers.forEach((_value, originalKey) => {
const key = originalKey.toLowerCase();
@@ -115,16 +130,16 @@ export function createProxy(options: CreateProxyOptions = {}): Proxy {
headers.delete(originalKey);
}
});
headers.set('X-Forwarded-Host', res.url);
headers.set('X-Forwarded-Host', response.url);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
for (const key of keys) {
for (const key of methods) {
handlers[key] = handler;
}