mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Consistent URL resolution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Consistent URL resolution
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
...({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]}`;
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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(', ')})
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface RenderContext {
|
||||
*/
|
||||
disablePlayground?: boolean;
|
||||
|
||||
baseUrl: string;
|
||||
servers: ServerObject[];
|
||||
|
||||
slugger: Slugger;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,7 +30,6 @@ export function Root({
|
||||
}
|
||||
servers={ctx.servers}
|
||||
shikiOptions={ctx.shikiOptions}
|
||||
defaultBaseUrl={ctx.baseUrl}
|
||||
>
|
||||
{children}
|
||||
</ApiProvider>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user