OpenAPI: Consistent URL resolution

This commit is contained in:
Fuma Nama
2025-06-05 21:29:10 +08:00
parent af0d61f6c8
commit 99e3c95980
17 changed files with 127 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Consistent URL resolution
+20 -9
View File
@@ -31,7 +31,12 @@ import type {
SecurityEntry,
} from '@/playground/index';
import { getStatusInfo } from './status-info';
import { getUrl } from '@/utils/server-url';
import {
joinURL,
resolveRequestData,
resolveServerUrl,
withBase,
} from '@/utils/url';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { MethodLabel } from '@/ui/components/method-label';
import { useQuery } from '@/utils/use-query';
@@ -182,15 +187,21 @@ export default function Client({
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters),
);
const data = toRequestData(method, body?.mediaType, input);
const serverUrl = server
? getUrl(server.url, server.variables)
: window.location.origin;
return fetcher.fetch(`${serverUrl}${route}`, {
proxyUrl,
...toRequestData(method, body?.mediaType, input),
});
return fetcher.fetch(
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
window.location.origin,
),
resolveRequestData(route, data),
),
{
proxyUrl,
...data,
},
);
});
function initAuthValues(values: FormValues, inputs: AuthField[]) {
+8 -6
View File
@@ -1,4 +1,3 @@
import { getPathnameFromInput } from '@/utils/get-pathname-from-input';
import type { RequestData } from '@/requests/_shared';
import type { MediaAdapter } from '@/media/adapter';
@@ -13,14 +12,19 @@ export interface FetchResult {
}
export interface Fetcher {
fetch: (route: string, options: FetchOptions) => Promise<FetchResult>;
/**
* This method will not apply the path & search parameters from `options` to given `url`.
*
* @param url - The full URL of request.
*/
fetch: (url: string, options: FetchOptions) => Promise<FetchResult>;
}
export function createBrowserFetcher(
adapters: Record<string, MediaAdapter>,
): Fetcher {
return {
async fetch(route, options) {
async fetch(url, options) {
const headers = new Headers();
if (options.bodyMediaType)
headers.append('Content-Type', options.bodyMediaType);
@@ -32,11 +36,9 @@ export function createBrowserFetcher(
}
const proxyUrl = options.proxyUrl
? new URL(options.proxyUrl, window.location.origin)
? new URL(options.proxyUrl, document.baseURI)
: null;
let url = getPathnameFromInput(route, options.path, options.query);
if (proxyUrl) {
proxyUrl.searchParams.append('url', url);
url = proxyUrl.toString();
+2 -15
View File
@@ -9,7 +9,6 @@ import {
processDocument,
type ProcessedDocument,
} from '@/utils/process-document';
import { getUrl } from '@/utils/server-url';
import type { defaultAdapters } from '@/media/adapter';
type ApiPageContextProps = Pick<
@@ -110,10 +109,7 @@ export async function APIPage(props: ApiPageProps) {
type="webhook"
key={`${item.name}:${item.method}`}
method={method}
ctx={{
...ctx,
baseUrl: 'http://localhost:8080',
}}
ctx={ctx}
path={`/${item.name}`}
hasHead={hasHead}
/>
@@ -133,8 +129,7 @@ export async function getContext(
const servers =
document.servers && document.servers.length > 0
? document.servers
: [{ url: 'https://example.com' }];
const server = servers[0];
: [{ url: '/' }];
return {
schema,
@@ -148,14 +143,6 @@ export async function getContext(
shikiOptions: options.shikiOptions,
generateTypeScriptSchema: options.generateTypeScriptSchema,
generateCodeSamples: options.generateCodeSamples,
baseUrl: getUrl(
server.url,
server.variables
? Object.fromEntries(
Object.entries(server.variables).map(([k, v]) => [k, v.default]),
)
: {},
),
servers,
mediaAdapters: {
...({
-5
View File
@@ -1,4 +1,3 @@
import { getPathnameFromInput } from '@/utils/get-pathname-from-input';
import type { MediaAdapter } from '@/media/adapter';
export type SampleGenerator = (
@@ -21,10 +20,6 @@ export interface RequestData {
bodyMediaType?: string;
}
export function getUrl(url: string, data: RequestData): string {
return getPathnameFromInput(url, data.path, data.query);
}
export function ident(code: string, tab: number = 1) {
return code
.split('\n')
+3 -2
View File
@@ -1,6 +1,7 @@
'use client';
import { inputToString } from '@/utils/input-to-string';
import { getUrl, ident, type SampleGenerator } from '@/requests/_shared';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
const MediaTypeFormatMap = {
'application/json': 'json',
@@ -10,7 +11,7 @@ const MediaTypeFormatMap = {
export const generator: SampleGenerator = (url, data) => {
const s: string[] = [];
s.push(`curl -X ${data.method} "${getUrl(url, data)}"`);
s.push(`curl -X ${data.method} "${resolveRequestData(url, data)}"`);
for (const header in data.header) {
const value = `${header}: ${data.header[header]}`;
+3 -2
View File
@@ -1,11 +1,12 @@
'use client';
import { getUrl, ident, SampleGenerator } from '@/requests/_shared';
import { ident, SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const imports = ['fmt', 'net/http', 'io/ioutil'];
const headers = new Map<string, string>();
const variables = new Map<string, string>();
variables.set('url', JSON.stringify(getUrl(url, data)));
variables.set('url', JSON.stringify(resolveRequestData(url, data)));
for (const header in data.header) {
headers.set(header, JSON.stringify(data.header[header]));
+3 -2
View File
@@ -1,5 +1,6 @@
'use client';
import { getUrl, ident, type SampleGenerator } from '@/requests/_shared';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -31,7 +32,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
options.set('body', 'body');
}
const params = [JSON.stringify(getUrl(url, data))];
const params = [JSON.stringify(resolveRequestData(url, data))];
if (options.size > 0) {
const str = Array.from(options.entries())
.map(([k, v]) => ident(k === v ? k : `${k}: ${v}`))
+3 -2
View File
@@ -1,5 +1,6 @@
'use client';
import { getUrl, type SampleGenerator } from '@/requests/_shared';
import { type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const headers = { ...data.header };
@@ -30,7 +31,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
return `import requests
url = ${JSON.stringify(getUrl(url, data))}
url = ${JSON.stringify(resolveRequestData(url, data))}
${body ?? ''}
response = requests.request(${params.join(', ')})
-1
View File
@@ -46,7 +46,6 @@ export interface RenderContext {
*/
disablePlayground?: boolean;
baseUrl: string;
servers: ServerObject[];
slugger: Slugger;
+1 -1
View File
@@ -62,7 +62,7 @@ export function ApiProvider({
const [server, setServer] = useState<SelectedServer | null>(() => {
const defaultItem = defaultBaseUrl
? servers.find((item) => item.url === defaultBaseUrl)
: null;
: servers.at(0);
return defaultItem
? {
@@ -20,7 +20,7 @@ import {
SelectValue,
} from '@/ui/components/select';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { getUrl } from '@/utils/server-url';
import { joinURL, resolveServerUrl, withBase } from '@/utils/url';
import type { RequestData } from '@/requests/_shared';
import { defaultSamples } from '@/requests';
@@ -140,7 +140,15 @@ export function CodeExample(props: CodeSample) {
if (typeof sample.source === 'string') return sample.source;
return sample.source(
`${server ? getUrl(server.url, server.variables) : '/'}${route}`,
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined'
? window.location.origin
: 'https://loading',
),
route,
),
data,
{
mediaAdapters,
-1
View File
@@ -30,7 +30,6 @@ export function Root({
}
servers={ctx.servers}
shikiOptions={ctx.shikiOptions}
defaultBaseUrl={ctx.baseUrl}
>
{children}
</ApiProvider>
+5 -4
View File
@@ -22,7 +22,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/ui/components/dialog';
import { getUrl } from '@/utils/server-url';
import { resolveServerUrl, withBase } from '@/utils/url';
import { FormProvider, useController, useForm } from 'react-hook-form';
import type { OpenAPIV3_1 } from 'openapi-types';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
@@ -43,9 +43,10 @@ export default function ServerSelect(props: HTMLAttributes<HTMLDivElement>) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="text-xs p-3 py-2 bg-fd-muted text-fd-muted-foreground transition-colors truncate hover:bg-fd-accent hover:text-fd-accent-foreground focus-visible:outline-none">
{isMounted
? server
? getUrl(server.url, server.variables)
: window.location.origin
? withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
window.location.origin,
)
: 'loading...'}
</DialogTrigger>
<DialogContent {...props}>
@@ -1,31 +0,0 @@
export function getPathnameFromInput(
url: string,
path: Record<string, unknown>,
query: Record<string, unknown>,
): string {
let pathname = url;
for (const key in path) {
if (path[key] === '') continue;
if (Array.isArray(path[key])) {
pathname = pathname.replace(`{${key}}`, path[key].join('/'));
} else {
pathname = pathname.replace(`{${key}}`, String(path[key]));
}
}
const searchParams = new URLSearchParams();
for (const key in query) {
if (query[key] === '') continue;
if (Array.isArray(query[key])) {
for (const value of query[key]) {
searchParams.append(key, String(value));
}
} else {
searchParams.set(key, String(query[key]));
}
}
return searchParams.size > 0 ? `${pathname}?${searchParams}` : pathname;
}
-9
View File
@@ -1,9 +0,0 @@
export function getUrl(url: string, variables: Record<string, string>): string {
let out = url;
for (const [key, value] of Object.entries(variables)) {
out = out.replaceAll(`{${key}}`, value);
}
return out;
}
+64
View File
@@ -0,0 +1,64 @@
import type { RequestData } from '@/requests/_shared';
export function joinURL(base: string, pathname: string): string {
pathname = pathname
.split('/')
.filter((v) => v.length > 0)
.join('/');
if (!base.endsWith('/') && pathname.length > 0) base += '/';
return base + pathname;
}
/**
* @param url - URL (can be relative)
* @param base - the base URL (must be absolute)
*/
export function withBase(url: string, base: string): string {
if (!url.startsWith('https://') && !url.startsWith('http://')) {
return joinURL(base, url);
}
return url;
}
export function resolveServerUrl(
template: string,
variables: Record<string, string>,
): string {
for (const [key, value] of Object.entries(variables)) {
template = template.replaceAll(`{${key}}`, value);
}
return template;
}
export function resolveRequestData(
pathname: string,
{ path, query }: RequestData,
): string {
for (const key in path) {
if (path[key] === '') continue;
if (Array.isArray(path[key])) {
pathname = pathname.replace(`{${key}}`, path[key].join('/'));
} else {
pathname = pathname.replace(`{${key}}`, String(path[key]));
}
}
const searchParams = new URLSearchParams();
for (const key in query) {
if (Array.isArray(query[key])) {
for (const value of query[key]) {
if (!query[key]) continue;
searchParams.append(key, String(value));
}
} else if (query[key] && query[key] !== '') {
searchParams.set(key, String(query[key]));
}
}
return searchParams.size > 0 ? `${pathname}?${searchParams}` : pathname;
}