mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useMemo } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import {
|
|
OPENAPI_ENDPOINTS,
|
|
OPENAPI_TAGS,
|
|
OPENAPI_VERSION,
|
|
type OpenApiEndpoint,
|
|
} from "../lib/openapi.generated";
|
|
|
|
const METHOD_COLORS: Record<string, string> = {
|
|
GET: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
|
|
POST: "bg-blue-500/10 text-blue-600 border-blue-500/20",
|
|
PUT: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
|
DELETE: "bg-red-500/10 text-red-600 border-red-500/20",
|
|
PATCH: "bg-purple-500/10 text-purple-600 border-purple-500/20",
|
|
};
|
|
|
|
/**
|
|
* A small map of richer "Try It" example bodies keyed by full path. The
|
|
* generated OpenAPI module exposes every endpoint with its description and
|
|
* summary; only paths in this map get a non-empty default request body when
|
|
* selected. Anything not listed here falls back to the empty placeholder
|
|
* (the user can paste their own body).
|
|
*/
|
|
const EXAMPLE_BODIES: Record<string, string> = {
|
|
"/api/v1/chat/completions": JSON.stringify(
|
|
{
|
|
model: "openai/gpt-4o-mini",
|
|
messages: [{ role: "user", content: "Hello!" }],
|
|
stream: true,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/embeddings": JSON.stringify(
|
|
{ model: "openai/text-embedding-3-small", input: "Hello world" },
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/images/generations": JSON.stringify(
|
|
{ model: "openai/gpt-image-2", prompt: "A sunset over mountains", n: 1 },
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/responses": JSON.stringify(
|
|
{ model: "openai/gpt-4o-mini", input: "What is OmniRoute?" },
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/messages": JSON.stringify(
|
|
{
|
|
model: "anthropic/claude-3-5-sonnet",
|
|
max_tokens: 1024,
|
|
messages: [{ role: "user", content: "Hi" }],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/messages/count_tokens": JSON.stringify(
|
|
{
|
|
model: "anthropic/claude-3-5-sonnet",
|
|
messages: [{ role: "user", content: "Hi" }],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/moderations": JSON.stringify(
|
|
{ model: "openai/omni-moderation-latest", input: "Sample text" },
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/rerank": JSON.stringify(
|
|
{
|
|
model: "cohere/rerank-v3.5",
|
|
query: "best ai gateway",
|
|
documents: ["Document 1", "Document 2"],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
"/api/v1/audio/transcriptions": "",
|
|
"/api/v1/audio/speech": JSON.stringify(
|
|
{ model: "openai/tts-1", input: "Hello world", voice: "alloy" },
|
|
null,
|
|
2
|
|
),
|
|
};
|
|
|
|
export function ApiExplorerClient() {
|
|
const t = useTranslations("docs");
|
|
const te = useTranslations("endpoint");
|
|
const [selected, setSelected] = useState<OpenApiEndpoint | null>(null);
|
|
const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [requestBody, setRequestBody] = useState("");
|
|
const [response, setResponse] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [filterTag, setFilterTag] = useState<string | null>(null);
|
|
|
|
const filteredEndpoints = useMemo(
|
|
() => (filterTag ? OPENAPI_ENDPOINTS.filter((e) => e.tag === filterTag) : OPENAPI_ENDPOINTS),
|
|
[filterTag]
|
|
);
|
|
|
|
const handleSelect = useCallback((endpoint: OpenApiEndpoint) => {
|
|
setSelected(endpoint);
|
|
setResponse(null);
|
|
const example = EXAMPLE_BODIES[endpoint.path] ?? "";
|
|
setRequestBody(example);
|
|
}, []);
|
|
|
|
const handleTryIt = async () => {
|
|
if (!selected) return;
|
|
setLoading(true);
|
|
setResponse(null);
|
|
|
|
try {
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
|
|
const opts: RequestInit = { method: selected.method, headers };
|
|
if (selected.method !== "GET" && requestBody.trim()) {
|
|
opts.body = requestBody;
|
|
}
|
|
|
|
// Strip the leading /api so callers can paste `http://localhost:20128`
|
|
// as the base URL without doubling the prefix. The OpenAPI spec uses
|
|
// `/api/v1/...` because that is the Next.js route; the runtime client
|
|
// hits the same path.
|
|
const res = await fetch(`${baseUrl}${selected.path}`, opts);
|
|
const contentType = res.headers.get("content-type") || "";
|
|
|
|
if (contentType.includes("text/event-stream")) {
|
|
setResponse(t("apiExplorerSseStarted"));
|
|
} else {
|
|
const data = await res.json();
|
|
setResponse(JSON.stringify(data, null, 2));
|
|
}
|
|
} catch (err) {
|
|
setResponse(
|
|
t("apiExplorerError", {
|
|
message: err instanceof Error ? err.message : te("requestFailed"),
|
|
})
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col lg:flex-row gap-6">
|
|
<div className="lg:w-72 shrink-0">
|
|
<div className="sticky top-4">
|
|
<div className="mb-3 flex items-center gap-2 text-xs text-text-muted">
|
|
<span>
|
|
{OPENAPI_ENDPOINTS.length} endpoints · OpenAPI v{OPENAPI_VERSION}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
|
<button
|
|
onClick={() => setFilterTag(null)}
|
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
|
|
${!filterTag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
|
|
>
|
|
{te("all")}
|
|
</button>
|
|
{OPENAPI_TAGS.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
onClick={() => setFilterTag(filterTag === tag ? null : tag)}
|
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
|
|
${filterTag === tag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="space-y-1 max-h-96 overflow-y-auto">
|
|
{filteredEndpoints.map((endpoint) => (
|
|
<button
|
|
key={`${endpoint.method}-${endpoint.path}`}
|
|
onClick={() => handleSelect(endpoint)}
|
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors
|
|
${
|
|
selected?.path === endpoint.path && selected?.method === endpoint.method
|
|
? "bg-primary/10 border border-primary/20"
|
|
: "hover:bg-bg-subtle border border-transparent"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`px-1.5 py-0.5 text-[10px] font-mono font-bold rounded border ${METHOD_COLORS[endpoint.method] || "border-border"}`}
|
|
>
|
|
{endpoint.method}
|
|
</span>
|
|
<span className="truncate text-text-muted font-mono text-xs">
|
|
{endpoint.path}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{selected ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<span
|
|
className={`px-2 py-1 text-xs font-mono font-bold rounded border ${METHOD_COLORS[selected.method]}`}
|
|
>
|
|
{selected.method}
|
|
</span>
|
|
<span className="font-mono text-sm text-text-main">{selected.path}</span>
|
|
{selected.requiresAuth && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded border border-amber-500/30 bg-amber-500/10 text-amber-600">
|
|
{t("apiExplorerAuth")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-text-muted">{selected.summary || selected.description}</p>
|
|
{selected.description && selected.description !== selected.summary && (
|
|
<p className="text-xs text-text-muted whitespace-pre-line">{selected.description}</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-text-muted block mb-1">{te("baseUrl")}</label>
|
|
<input
|
|
type="text"
|
|
value={baseUrl}
|
|
onChange={(e) => setBaseUrl(e.target.value)}
|
|
className="w-full px-3 py-2 text-sm bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-text-muted block mb-1">{te("apiKey")}</label>
|
|
<input
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder="sk-..."
|
|
className="w-full px-3 py-2 text-sm bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{selected.method !== "GET" && selected.hasRequestBody && (
|
|
<div>
|
|
<label className="text-xs text-text-muted block mb-1">{te("requestBody")}</label>
|
|
<textarea
|
|
value={requestBody}
|
|
onChange={(e) => setRequestBody(e.target.value)}
|
|
rows={8}
|
|
className="w-full px-3 py-2 text-sm font-mono bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleTryIt}
|
|
disabled={loading}
|
|
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
>
|
|
{loading ? te("sending") : te("sendRequest")}
|
|
</button>
|
|
|
|
{response !== null && (
|
|
<div>
|
|
<label className="text-xs text-text-muted block mb-1">
|
|
{t("apiExplorerResponseLabel")}
|
|
</label>
|
|
<pre className="bg-bg-subtle p-4 rounded-lg overflow-x-auto text-xs font-mono text-text-main max-h-80">
|
|
{response}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16 text-text-muted">
|
|
<span className="material-symbols-outlined text-4xl mb-2 block">api</span>
|
|
<p className="text-lg font-medium">{t("apiExplorerSelectEndpoint")}</p>
|
|
<p className="text-sm mt-1">{t("apiExplorerChooseApi")}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|