mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat: API Endpoints dashboard — interactive catalog, webhooks, OpenAPI viewer
Phase 1: Interactive REST API Catalog - GET /api/openapi/spec: serves parsed openapi.yaml as JSON catalog - POST /api/openapi/try: Try It proxy for inline endpoint testing - Endpoint catalog with tag grouping, search, method badges - Expand: schemas, auth, curl examples, Try It panel Phase 2: OpenAPI Spec Viewer - Spec info header with version, download YAML/JSON, schema browser Phase 3: Webhooks & Event Subscriptions - Migration 011: webhooks table - src/lib/db/webhooks.ts: CRUD + delivery tracking + auto-disable - src/lib/webhookDispatcher.ts: HMAC-SHA256, retries - API: CRUD /api/webhooks + test delivery - Dashboard: add/edit/toggle/test/delete webhook UI 923 tests pass, tsc clean
This commit is contained in:
@@ -1,46 +1,842 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/* ─── Types ──────────────────────────────────────────── */
|
||||
interface Endpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
tags: string[];
|
||||
summary: string;
|
||||
description: string;
|
||||
security: boolean;
|
||||
parameters: any[];
|
||||
requestBody: boolean;
|
||||
responses: string[];
|
||||
}
|
||||
|
||||
interface CatalogData {
|
||||
info: { title?: string; version?: string; description?: string };
|
||||
servers: { url: string; description?: string }[];
|
||||
tags: { name: string; description?: string }[];
|
||||
endpoints: Endpoint[];
|
||||
schemas: string[];
|
||||
}
|
||||
|
||||
interface WebhookItem {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string | null;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
created_at: string;
|
||||
last_triggered_at: string | null;
|
||||
last_status: number | null;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
interface TryItResult {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: any;
|
||||
latencyMs: number;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30",
|
||||
POST: "bg-blue-500/15 text-blue-500 border-blue-500/30",
|
||||
PUT: "bg-amber-500/15 text-amber-500 border-amber-500/30",
|
||||
PATCH: "bg-orange-500/15 text-orange-500 border-orange-500/30",
|
||||
DELETE: "bg-red-500/15 text-red-500 border-red-500/30",
|
||||
};
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
"request.completed",
|
||||
"request.failed",
|
||||
"provider.error",
|
||||
"provider.recovered",
|
||||
"quota.exceeded",
|
||||
"combo.switched",
|
||||
];
|
||||
|
||||
/* ─── Main Component ─────────────────────────────────── */
|
||||
export default function ApiEndpointsTab() {
|
||||
const t = useTranslations("endpoints");
|
||||
const [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [section, setSection] = useState<"catalog" | "webhooks">("catalog");
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
|
||||
// Try It state
|
||||
const [tryingEndpoint, setTryingEndpoint] = useState<string | null>(null);
|
||||
const [tryBody, setTryBody] = useState("");
|
||||
const [tryResult, setTryResult] = useState<TryItResult | null>(null);
|
||||
const [trying, setTrying] = useState(false);
|
||||
|
||||
// Webhooks state
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
const [webhooksLoading, setWebhooksLoading] = useState(false);
|
||||
const [showAddWebhook, setShowAddWebhook] = useState(false);
|
||||
const [whUrl, setWhUrl] = useState("");
|
||||
const [whEvents, setWhEvents] = useState<string[]>(["*"]);
|
||||
const [whDesc, setWhDesc] = useState("");
|
||||
const [testingWebhookId, setTestingWebhookId] = useState<string | null>(null);
|
||||
|
||||
// Load catalog
|
||||
const loadCatalog = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/openapi/spec");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadCatalog().then((data) => {
|
||||
if (!cancelled) {
|
||||
setCatalog(data);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load webhooks
|
||||
const fetchWebhooksData = async (): Promise<WebhookItem[]> => {
|
||||
try {
|
||||
const res = await fetch("/api/webhooks");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.webhooks || [];
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
};
|
||||
|
||||
const loadWebhooks = async () => {
|
||||
setWebhooksLoading(true);
|
||||
const data = await fetchWebhooksData();
|
||||
setWebhooks(data);
|
||||
setWebhooksLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "webhooks") return;
|
||||
let cancelled = false;
|
||||
fetchWebhooksData().then((data) => {
|
||||
if (!cancelled) {
|
||||
setWebhooks(data);
|
||||
setWebhooksLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [section]);
|
||||
|
||||
// Filter endpoints
|
||||
const filteredEndpoints = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
return catalog.endpoints.filter((ep) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
ep.path.toLowerCase().includes(search.toLowerCase()) ||
|
||||
ep.summary.toLowerCase().includes(search.toLowerCase()) ||
|
||||
ep.tags.some((t) => t.toLowerCase().includes(search.toLowerCase()));
|
||||
const matchesTag = !selectedTag || ep.tags.includes(selectedTag);
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
}, [catalog, search, selectedTag]);
|
||||
|
||||
// Group by tag
|
||||
const groupedEndpoints = useMemo(() => {
|
||||
const groups: Record<string, Endpoint[]> = {};
|
||||
for (const ep of filteredEndpoints) {
|
||||
const tag = ep.tags[0] || "Other";
|
||||
if (!groups[tag]) groups[tag] = [];
|
||||
groups[tag].push(ep);
|
||||
}
|
||||
return groups;
|
||||
}, [filteredEndpoints]);
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
return catalog.tags.map((t) => t.name);
|
||||
}, [catalog]);
|
||||
|
||||
// Try It handler
|
||||
const handleTryIt = async (ep: Endpoint) => {
|
||||
const key = `${ep.method}:${ep.path}`;
|
||||
if (tryingEndpoint === key) {
|
||||
setTryingEndpoint(null);
|
||||
setTryResult(null);
|
||||
return;
|
||||
}
|
||||
setTryingEndpoint(key);
|
||||
setTryResult(null);
|
||||
setTryBody(ep.method === "GET" ? "" : "{\n \n}");
|
||||
};
|
||||
|
||||
const executeTryIt = async (ep: Endpoint) => {
|
||||
setTrying(true);
|
||||
try {
|
||||
const res = await fetch("/api/openapi/try", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
method: ep.method,
|
||||
path: ep.path.replace("/api/", "/"),
|
||||
body: tryBody ? JSON.parse(tryBody) : undefined,
|
||||
}),
|
||||
});
|
||||
if (res.ok) setTryResult(await res.json());
|
||||
} catch (err: any) {
|
||||
setTryResult({
|
||||
status: 0,
|
||||
statusText: "Error",
|
||||
headers: {},
|
||||
body: { error: err.message },
|
||||
latencyMs: 0,
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
setTrying(false);
|
||||
};
|
||||
|
||||
// Webhook handlers
|
||||
const addWebhook = async () => {
|
||||
if (!whUrl.trim()) return;
|
||||
try {
|
||||
await fetch("/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: whUrl, events: whEvents, description: whDesc }),
|
||||
});
|
||||
setWhUrl("");
|
||||
setWhEvents(["*"]);
|
||||
setWhDesc("");
|
||||
setShowAddWebhook(false);
|
||||
await loadWebhooks();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const toggleWebhook = async (wh: WebhookItem) => {
|
||||
try {
|
||||
await fetch(`/api/webhooks/${wh.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !wh.enabled }),
|
||||
});
|
||||
setWebhooks((prev) => prev.map((w) => (w.id === wh.id ? { ...w, enabled: !w.enabled } : w)));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const deleteWebhook = async (id: string) => {
|
||||
if (!confirm("Delete this webhook?")) return;
|
||||
try {
|
||||
await fetch(`/api/webhooks/${id}`, { method: "DELETE" });
|
||||
setWebhooks((prev) => prev.filter((w) => w.id !== id));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const testWebhook = async (id: string) => {
|
||||
setTestingWebhookId(id);
|
||||
try {
|
||||
await fetch(`/api/webhooks/${id}/test`, { method: "POST" });
|
||||
await loadWebhooks();
|
||||
} catch {}
|
||||
setTestingWebhookId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-white/5 rounded-lg w-1/3" />
|
||||
<div className="h-64 bg-white/5 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<Card className="p-8 text-center space-y-4">
|
||||
<div className="flex items-center justify-center size-16 rounded-2xl bg-primary/10 text-primary mx-auto">
|
||||
<span className="material-symbols-outlined text-[32px]">code</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t("apiEndpointsTitle")}</h2>
|
||||
<p className="text-sm text-text-muted max-w-md mx-auto">{t("apiEndpointsDescription")}</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-amber-500/10 text-amber-500 text-sm font-medium">
|
||||
<span className="material-symbols-outlined text-[18px]">construction</span>
|
||||
{t("comingSoon")}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-5">
|
||||
{/* Header with spec info */}
|
||||
{catalog && (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">api</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold">{catalog.info.title || "API"}</h2>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-mono font-semibold">
|
||||
{catalog.info.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{catalog.endpoints.length} endpoints across {allTags.length} categories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/docs/openapi.yaml"
|
||||
download
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg
|
||||
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
YAML
|
||||
</a>
|
||||
<a
|
||||
href="/api/openapi/spec"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg
|
||||
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="text-sm font-semibold mb-3">{t("plannedFeatures")}</h3>
|
||||
<ul className="space-y-2 text-sm text-text-muted">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
|
||||
{t("featureRestApi")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
|
||||
{t("featureWebhooks")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
|
||||
{t("featureSwagger")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
|
||||
{t("featureAuth")}
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
{/* Section tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-xl bg-black/5 dark:bg-white/[0.03] w-fit">
|
||||
{[
|
||||
{ id: "catalog" as const, label: "API Catalog", icon: "menu_book" },
|
||||
{ id: "webhooks" as const, label: "Webhooks", icon: "webhook" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSection(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
|
||||
${
|
||||
section === tab.id
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ═══ API CATALOG ═══ */}
|
||||
{section === "catalog" && catalog && (
|
||||
<>
|
||||
{/* Search & filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted absolute left-3 top-1/2 -translate-y-1/2">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search endpoints..."
|
||||
className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedTag(null)}
|
||||
className={`px-2 py-1 text-[10px] font-medium rounded-md transition-colors
|
||||
${
|
||||
!selectedTag
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allTags.slice(0, 8).map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||
className={`px-2 py-1 text-[10px] font-medium rounded-md transition-colors
|
||||
${
|
||||
selectedTag === tag
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{allTags.length > 8 && (
|
||||
<span className="px-2 py-1 text-[10px] text-text-muted">
|
||||
+{allTags.length - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint groups */}
|
||||
{Object.entries(groupedEndpoints).map(([tag, endpoints]) => (
|
||||
<Card key={tag} className="overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-black/5 dark:border-white/5">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">folder</span>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{tag}
|
||||
</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
|
||||
{endpoints.length}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/30" />
|
||||
</div>
|
||||
<div className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{endpoints.map((ep) => {
|
||||
const key = `${ep.method}:${ep.path}`;
|
||||
const isExpanded = expandedEndpoint === key;
|
||||
const isTrying = tryingEndpoint === key;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02]
|
||||
cursor-pointer transition-colors"
|
||||
onClick={() => setExpandedEndpoint(isExpanded ? null : key)}
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2 py-0.5 rounded border min-w-[42px] text-center font-mono
|
||||
${METHOD_COLORS[ep.method] || "bg-gray-500/15 text-gray-500"}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-text-main flex-1 truncate">
|
||||
{ep.path}
|
||||
</code>
|
||||
<span className="text-[11px] text-text-muted hidden sm:inline truncate max-w-[200px]">
|
||||
{ep.summary}
|
||||
</span>
|
||||
{ep.security && (
|
||||
<span
|
||||
className="material-symbols-outlined text-[12px] text-amber-500"
|
||||
title="Requires auth"
|
||||
>
|
||||
lock
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3 space-y-3 bg-black/[0.01] dark:bg-white/[0.01]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-text-main font-medium">{ep.summary}</p>
|
||||
{ep.description && ep.description !== ep.summary && (
|
||||
<p className="text-[11px] text-text-muted mt-1">{ep.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-text-muted">
|
||||
{ep.security && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-amber-500">
|
||||
lock
|
||||
</span>
|
||||
Bearer Auth
|
||||
</span>
|
||||
)}
|
||||
{ep.requestBody && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
description
|
||||
</span>
|
||||
Request Body
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
Responses: {ep.responses.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTryIt(ep);
|
||||
}}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 text-[10px] font-semibold rounded-lg
|
||||
transition-colors shrink-0
|
||||
${
|
||||
isTrying
|
||||
? "bg-primary text-white"
|
||||
: "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
{isTrying ? "close" : "play_arrow"}
|
||||
</span>
|
||||
{isTrying ? "Close" : "Try It"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* curl example */}
|
||||
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3">
|
||||
<p className="text-[9px] font-semibold text-text-muted uppercase tracking-wider mb-1">
|
||||
Example
|
||||
</p>
|
||||
<code className="text-[11px] font-mono text-text-main break-all">
|
||||
curl -X {ep.method} http://localhost:20128
|
||||
{ep.path.replace("/api/", "/")}
|
||||
{ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""}
|
||||
{ep.requestBody
|
||||
? " -H \"Content-Type: application/json\" -d '{...}'"
|
||||
: ""}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Try It panel */}
|
||||
{isTrying && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-3">
|
||||
{ep.method !== "GET" && (
|
||||
<div>
|
||||
<label className="text-[9px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Request Body (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={tryBody}
|
||||
onChange={(e) => setTryBody(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full mt-1 px-3 py-2 text-xs font-mono rounded-lg border border-black/10
|
||||
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary resize-none"
|
||||
placeholder='{ "model": "gpt-4o", "messages": [...] }'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => executeTryIt(ep)}
|
||||
disabled={trying}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg
|
||||
bg-primary text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{trying ? "hourglass_empty" : "send"}
|
||||
</span>
|
||||
{trying ? "Sending..." : "Send Request"}
|
||||
</button>
|
||||
|
||||
{tryResult && (
|
||||
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3 space-y-2">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded font-bold ${
|
||||
tryResult.status >= 200 && tryResult.status < 300
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: tryResult.status >= 400
|
||||
? "bg-red-500/15 text-red-500"
|
||||
: "bg-amber-500/15 text-amber-500"
|
||||
}`}
|
||||
>
|
||||
{tryResult.status} {tryResult.statusText}
|
||||
</span>
|
||||
<span className="text-text-muted">{tryResult.latencyMs}ms</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-text-main overflow-auto max-h-[300px] whitespace-pre-wrap">
|
||||
{typeof tryResult.body === "string"
|
||||
? tryResult.body
|
||||
: JSON.stringify(tryResult.body, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filteredEndpoints.length === 0 && (
|
||||
<Card className="p-8 text-center">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm text-text-muted mt-2">No endpoints match your filter</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Schemas section */}
|
||||
{catalog.schemas.length > 0 && (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">
|
||||
data_object
|
||||
</span>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
Data Schemas
|
||||
</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
|
||||
{catalog.schemas.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{catalog.schemas.map((schema) => (
|
||||
<span
|
||||
key={schema}
|
||||
className="text-[10px] px-2 py-1 rounded-md bg-purple-500/10 text-purple-500 dark:text-purple-300 font-mono"
|
||||
>
|
||||
{schema}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══ WEBHOOKS ═══ */}
|
||||
{section === "webhooks" && (
|
||||
<>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[18px]">webhook</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Event Webhooks</h3>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
Receive HTTP callbacks when events occur in OmniRoute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!showAddWebhook && (
|
||||
<button
|
||||
onClick={() => setShowAddWebhook(true)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg
|
||||
bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
Add Webhook
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add webhook form */}
|
||||
{showAddWebhook && (
|
||||
<div className="mb-4 p-3 rounded-lg border border-primary/20 bg-primary/[0.03] space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
value={whUrl}
|
||||
onChange={(e) => setWhUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
value={whDesc}
|
||||
onChange={(e) => setWhDesc(e.target.value)}
|
||||
placeholder="Production monitoring"
|
||||
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Events
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<button
|
||||
onClick={() => setWhEvents(["*"])}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors
|
||||
${
|
||||
whEvents.includes("*")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
All events
|
||||
</button>
|
||||
{WEBHOOK_EVENTS.map((ev) => (
|
||||
<button
|
||||
key={ev}
|
||||
onClick={() => {
|
||||
if (whEvents.includes("*")) {
|
||||
setWhEvents([ev]);
|
||||
} else if (whEvents.includes(ev)) {
|
||||
setWhEvents(whEvents.filter((e) => e !== ev));
|
||||
} else {
|
||||
setWhEvents([...whEvents, ev]);
|
||||
}
|
||||
}}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors
|
||||
${
|
||||
whEvents.includes(ev) || whEvents.includes("*")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={addWebhook}
|
||||
disabled={!whUrl.trim()}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-primary text-white
|
||||
hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddWebhook(false)}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg
|
||||
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhooks list */}
|
||||
{webhooksLoading ? (
|
||||
<div className="text-xs text-text-muted py-4 text-center">Loading...</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">
|
||||
webhook
|
||||
</span>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
No webhooks configured. Add one to receive event notifications.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className={`flex items-center justify-between px-3 py-2.5 rounded-lg border transition-colors
|
||||
${
|
||||
wh.enabled
|
||||
? "border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/[0.02]"
|
||||
: "border-black/5 dark:border-white/5 opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-text-main truncate">{wh.url}</code>
|
||||
{wh.failure_count > 0 && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/10 text-red-500">
|
||||
{wh.failure_count} failures
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{wh.description && (
|
||||
<span className="text-[10px] text-text-muted">{wh.description}</span>
|
||||
)}
|
||||
<span className="text-[9px] text-text-muted">
|
||||
Events: {wh.events.join(", ")}
|
||||
</span>
|
||||
{wh.last_triggered_at && (
|
||||
<span className="text-[9px] text-text-muted">
|
||||
Last: {new Date(wh.last_triggered_at).toLocaleString()}
|
||||
{wh.last_status ? ` (${wh.last_status})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => testWebhook(wh.id)}
|
||||
disabled={testingWebhookId === wh.id}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title="Send test event"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${testingWebhookId === wh.id ? "animate-spin text-primary" : "text-text-muted"}`}
|
||||
>
|
||||
{testingWebhookId === wh.id ? "sync" : "send"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleWebhook(wh)}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title={wh.enabled ? "Disable" : "Enable"}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${wh.enabled ? "text-emerald-500" : "text-text-muted"}`}
|
||||
>
|
||||
{wh.enabled ? "toggle_on" : "toggle_off"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteWebhook(wh.id)}
|
||||
className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-red-500">
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Webhook signature info */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-500">vpn_key</span>
|
||||
<h3 className="text-xs font-semibold">Webhook Signatures</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mb-2">
|
||||
Each webhook delivery includes an{" "}
|
||||
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">
|
||||
X-Webhook-Signature
|
||||
</code>{" "}
|
||||
header signed with HMAC-SHA256 using the webhook secret. Verify the signature to
|
||||
ensure the payload is authentic.
|
||||
</p>
|
||||
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3">
|
||||
<code className="text-[10px] font-mono text-text-main">
|
||||
{`const crypto = require('crypto');\nconst sig = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');\nif (sig !== req.headers['x-webhook-signature']) throw new Error('Invalid signature');`}
|
||||
</code>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
168
src/app/api/openapi/spec/route.ts
Normal file
168
src/app/api/openapi/spec/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* API: OpenAPI Spec
|
||||
* GET — returns the parsed openapi.yaml as JSON
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Simple YAML parser for OpenAPI spec (handles common YAML patterns)
|
||||
function parseYamlLite(content: string): any {
|
||||
try {
|
||||
// Try JSON first (in case file is already JSON)
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// Fall through to YAML parsing
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const result: any = {};
|
||||
const stack: { indent: number; obj: any; key: string }[] = [{ indent: -1, obj: result, key: "" }];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "");
|
||||
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||
|
||||
const indent = line.search(/\S/);
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Pop stack to find parent
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
const parent = stack[stack.length - 1].obj;
|
||||
|
||||
// Array item
|
||||
if (trimmed.startsWith("- ")) {
|
||||
const parentKey = stack[stack.length - 1].key;
|
||||
if (parentKey && !Array.isArray(parent[parentKey])) {
|
||||
parent[parentKey] = [];
|
||||
}
|
||||
const val = trimmed.slice(2).trim();
|
||||
if (val.includes(": ")) {
|
||||
const obj: any = {};
|
||||
const [k, ...v] = val.split(": ");
|
||||
obj[k.trim()] = parseYamlValue(v.join(": ").trim());
|
||||
const arr = parentKey ? parent[parentKey] : parent;
|
||||
if (Array.isArray(arr)) arr.push(obj);
|
||||
} else {
|
||||
const arr = parentKey ? parent[parentKey] : parent;
|
||||
if (Array.isArray(arr)) arr.push(parseYamlValue(val));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const val = trimmed.slice(colonIdx + 1).trim();
|
||||
if (val) {
|
||||
parent[key] = parseYamlValue(val);
|
||||
} else {
|
||||
parent[key] = {};
|
||||
stack.push({ indent, obj: parent, key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseYamlValue(val: string): any {
|
||||
if (val === "true") return true;
|
||||
if (val === "false") return false;
|
||||
if (val === "null") return null;
|
||||
if (/^\d+$/.test(val)) return parseInt(val, 10);
|
||||
if (/^\d+\.\d+$/.test(val)) return parseFloat(val);
|
||||
// Remove surrounding quotes
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
return val.slice(1, -1);
|
||||
}
|
||||
// JSON array inline
|
||||
if (val.startsWith("[") && val.endsWith("]")) {
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch {
|
||||
/* use as-is */
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
let cachedSpec: { data: any; mtime: number } | null = null;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Try multiple locations for the spec file
|
||||
const candidates = [
|
||||
path.join(process.cwd(), "docs", "openapi.yaml"),
|
||||
path.join(process.cwd(), "app", "docs", "openapi.yaml"),
|
||||
];
|
||||
|
||||
let specPath = "";
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
specPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!specPath) {
|
||||
return NextResponse.json({ error: "openapi.yaml not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(specPath);
|
||||
const mtime = stat.mtimeMs;
|
||||
|
||||
// Use cache if file hasn't changed
|
||||
if (cachedSpec && cachedSpec.mtime === mtime) {
|
||||
return NextResponse.json(cachedSpec.data);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(specPath, "utf-8");
|
||||
|
||||
// Group endpoints by tag for the catalog
|
||||
const raw = parseYamlLite(content);
|
||||
|
||||
// Build a structured catalog
|
||||
const catalog: any = {
|
||||
info: raw.info || {},
|
||||
servers: raw.servers || [],
|
||||
tags: raw.tags || [],
|
||||
endpoints: [] as any[],
|
||||
schemas: Object.keys(raw.components?.schemas || {}),
|
||||
};
|
||||
|
||||
// Parse paths into flat endpoint list
|
||||
const paths = raw.paths || {};
|
||||
for (const [pathStr, methods] of Object.entries(paths as Record<string, any>)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
for (const [method, spec] of Object.entries(methods as Record<string, any>)) {
|
||||
if (["get", "post", "put", "patch", "delete"].includes(method) && spec) {
|
||||
catalog.endpoints.push({
|
||||
method: method.toUpperCase(),
|
||||
path: pathStr,
|
||||
tags: spec.tags || [],
|
||||
summary: spec.summary || "",
|
||||
description: spec.description || "",
|
||||
security: spec.security ? true : false,
|
||||
parameters: spec.parameters || [],
|
||||
requestBody: spec.requestBody ? true : false,
|
||||
responses: Object.keys(spec.responses || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedSpec = { data: catalog, mtime };
|
||||
|
||||
return NextResponse.json(catalog);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to parse OpenAPI spec" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
97
src/app/api/openapi/try/route.ts
Normal file
97
src/app/api/openapi/try/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* API: OpenAPI "Try It" Proxy
|
||||
* POST — forwards a request to a local endpoint and returns the result
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { method = "GET", path, headers = {}, body: reqBody } = body;
|
||||
|
||||
if (!path || typeof path !== "string") {
|
||||
return NextResponse.json({ error: "Missing 'path' field" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only allow requests to local endpoints for security
|
||||
if (!path.startsWith("/")) {
|
||||
return NextResponse.json({ error: "Path must start with /" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build the target URL using the incoming request's origin
|
||||
const origin = request.headers.get("x-forwarded-proto")
|
||||
? `${request.headers.get("x-forwarded-proto")}://${request.headers.get("host")}`
|
||||
: `http://${request.headers.get("host") || "localhost:20128"}`;
|
||||
|
||||
const targetUrl = `${origin}${path}`;
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
// Forward cookies/auth from the original request
|
||||
const forwardHeaders: Record<string, string> = {
|
||||
...headers,
|
||||
};
|
||||
|
||||
// Forward auth from the dashboard session
|
||||
const cookie = request.headers.get("cookie");
|
||||
if (cookie && !forwardHeaders["Cookie"]) {
|
||||
forwardHeaders["Cookie"] = cookie;
|
||||
}
|
||||
|
||||
if (reqBody && !forwardHeaders["Content-Type"]) {
|
||||
forwardHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: method.toUpperCase(),
|
||||
headers: forwardHeaders,
|
||||
};
|
||||
|
||||
if (reqBody && method.toUpperCase() !== "GET") {
|
||||
fetchOptions.body = typeof reqBody === "string" ? reqBody : JSON.stringify(reqBody);
|
||||
}
|
||||
|
||||
const res = await fetch(targetUrl, fetchOptions);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
// Read response
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
let responseBody: any;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
responseBody = await res.json();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
// Truncate very large responses
|
||||
responseBody = text.length > 10000 ? text.slice(0, 10000) + "\n... (truncated)" : text;
|
||||
}
|
||||
|
||||
// Collect response headers
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: responseHeaders,
|
||||
body: responseBody,
|
||||
latencyMs,
|
||||
contentType,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 0,
|
||||
statusText: "Network Error",
|
||||
headers: {},
|
||||
body: { error: error.message || "Request failed" },
|
||||
latencyMs: 0,
|
||||
contentType: "application/json",
|
||||
},
|
||||
{ status: 200 } // Return 200 so the frontend can display the error
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/app/api/webhooks/[id]/route.ts
Normal file
49
src/app/api/webhooks/[id]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* API: Webhook by ID
|
||||
* GET — Get webhook details
|
||||
* PUT — Update webhook
|
||||
* DELETE — Delete webhook
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb";
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const webhook = getWebhook(id);
|
||||
if (!webhook) {
|
||||
return NextResponse.json({ error: "Webhook not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ webhook });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const webhook = updateWebhookRecord(id, body);
|
||||
if (!webhook) {
|
||||
return NextResponse.json({ error: "Webhook not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ webhook });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const deleted = deleteWebhook(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "Webhook not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/webhooks/[id]/test/route.ts
Normal file
42
src/app/api/webhooks/[id]/test/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* API: Webhook Test Delivery
|
||||
* POST — Send a test ping event to a specific webhook
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWebhook, recordWebhookDelivery } from "@/lib/localDb";
|
||||
import { deliverWebhook } from "@/lib/webhookDispatcher";
|
||||
|
||||
export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const webhook = getWebhook(id);
|
||||
if (!webhook) {
|
||||
return NextResponse.json({ error: "Webhook not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await deliverWebhook(
|
||||
webhook.url,
|
||||
{
|
||||
event: "test.ping",
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
message: "Test webhook delivery from OmniRoute",
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
},
|
||||
webhook.secret,
|
||||
0 // No retries for test
|
||||
);
|
||||
|
||||
recordWebhookDelivery(webhook.id, result.status, result.success);
|
||||
|
||||
return NextResponse.json({
|
||||
delivered: result.success,
|
||||
status: result.status,
|
||||
error: result.error || null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
56
src/app/api/webhooks/route.ts
Normal file
56
src/app/api/webhooks/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* API: Webhooks
|
||||
* GET — List all webhooks
|
||||
* POST — Create a new webhook
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWebhooks, createWebhook } from "@/lib/localDb";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const webhooks = getWebhooks();
|
||||
// Mask secrets in listing
|
||||
const masked = webhooks.map((w) => ({
|
||||
...w,
|
||||
secret: w.secret ? `${w.secret.slice(0, 10)}...` : null,
|
||||
}));
|
||||
return NextResponse.json({ webhooks: masked });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to list webhooks" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.url || typeof body.url !== "string") {
|
||||
return NextResponse.json({ error: "Missing or invalid 'url' field" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(body.url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const webhook = createWebhook({
|
||||
url: body.url,
|
||||
events: body.events || ["*"],
|
||||
secret: body.secret,
|
||||
description: body.description || "",
|
||||
});
|
||||
|
||||
return NextResponse.json({ webhook }, { status: 201 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Failed to create webhook" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/lib/db/migrations/011_webhooks.sql
Normal file
15
src/lib/db/migrations/011_webhooks.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Migration 011: Webhooks for event subscriptions
|
||||
-- Part of API Endpoints dashboard (#563)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '["*"]',
|
||||
secret TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
description TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_triggered_at TEXT,
|
||||
last_status INTEGER,
|
||||
failure_count INTEGER DEFAULT 0
|
||||
);
|
||||
150
src/lib/db/webhooks.ts
Normal file
150
src/lib/db/webhooks.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Database module: Webhooks
|
||||
* CRUD operations for webhook event subscriptions
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string | null;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
created_at: string;
|
||||
last_triggered_at: string | null;
|
||||
last_status: number | null;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
interface WebhookRow {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string;
|
||||
secret: string | null;
|
||||
enabled: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
last_triggered_at: string | null;
|
||||
last_status: number | null;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
function rowToWebhook(row: WebhookRow): Webhook {
|
||||
return {
|
||||
...row,
|
||||
events: JSON.parse(row.events || '["*"]'),
|
||||
enabled: row.enabled === 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWebhooks(): Webhook[] {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM webhooks ORDER BY created_at DESC").all() as WebhookRow[];
|
||||
return rows.map(rowToWebhook);
|
||||
}
|
||||
|
||||
export function getWebhook(id: string): Webhook | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM webhooks WHERE id = ?").get(id) as WebhookRow | undefined;
|
||||
return row ? rowToWebhook(row) : null;
|
||||
}
|
||||
|
||||
export function getEnabledWebhooks(): Webhook[] {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM webhooks WHERE enabled = 1").all() as WebhookRow[];
|
||||
return rows.map(rowToWebhook);
|
||||
}
|
||||
|
||||
export function createWebhook(data: {
|
||||
url: string;
|
||||
events?: string[];
|
||||
secret?: string;
|
||||
description?: string;
|
||||
}): Webhook {
|
||||
const db = getDbInstance();
|
||||
const id = crypto.randomUUID();
|
||||
const secret = data.secret || `whsec_${crypto.randomBytes(24).toString("hex")}`;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO webhooks (id, url, events, secret, description)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, data.url, JSON.stringify(data.events || ["*"]), secret, data.description || "");
|
||||
|
||||
return getWebhook(id)!;
|
||||
}
|
||||
|
||||
export function updateWebhook(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
}>
|
||||
): Webhook | null {
|
||||
const db = getDbInstance();
|
||||
const existing = getWebhook(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.url !== undefined) {
|
||||
fields.push("url = ?");
|
||||
values.push(data.url);
|
||||
}
|
||||
if (data.events !== undefined) {
|
||||
fields.push("events = ?");
|
||||
values.push(JSON.stringify(data.events));
|
||||
}
|
||||
if (data.secret !== undefined) {
|
||||
fields.push("secret = ?");
|
||||
values.push(data.secret);
|
||||
}
|
||||
if (data.enabled !== undefined) {
|
||||
fields.push("enabled = ?");
|
||||
values.push(data.enabled ? 1 : 0);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
fields.push("description = ?");
|
||||
values.push(data.description);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return existing;
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE webhooks SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
||||
|
||||
return getWebhook(id);
|
||||
}
|
||||
|
||||
export function deleteWebhook(id: string): boolean {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM webhooks WHERE id = ?").run(id);
|
||||
return (result as any).changes > 0;
|
||||
}
|
||||
|
||||
export function recordWebhookDelivery(id: string, status: number, success: boolean): void {
|
||||
const db = getDbInstance();
|
||||
if (success) {
|
||||
db.prepare(
|
||||
`UPDATE webhooks SET last_triggered_at = datetime('now'), last_status = ?, failure_count = 0 WHERE id = ?`
|
||||
).run(status, id);
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE webhooks SET last_triggered_at = datetime('now'), last_status = ?, failure_count = failure_count + 1 WHERE id = ?`
|
||||
).run(status, id);
|
||||
}
|
||||
}
|
||||
|
||||
export function disableWebhooksWithHighFailures(threshold = 10): number {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(`UPDATE webhooks SET enabled = 0 WHERE failure_count >= ? AND enabled = 1`)
|
||||
.run(threshold);
|
||||
return (result as any).changes;
|
||||
}
|
||||
@@ -183,3 +183,17 @@ export {
|
||||
} from "./db/modelComboMappings";
|
||||
|
||||
export type { ModelComboMapping } from "./db/modelComboMappings";
|
||||
|
||||
export {
|
||||
// Webhooks
|
||||
getWebhooks,
|
||||
getWebhook,
|
||||
getEnabledWebhooks,
|
||||
createWebhook,
|
||||
updateWebhook as updateWebhookRecord,
|
||||
deleteWebhook,
|
||||
recordWebhookDelivery,
|
||||
disableWebhooksWithHighFailures,
|
||||
} from "./db/webhooks";
|
||||
|
||||
export type { Webhook } from "./db/webhooks";
|
||||
|
||||
107
src/lib/webhookDispatcher.ts
Normal file
107
src/lib/webhookDispatcher.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Webhook Dispatcher
|
||||
* Dispatches events to registered webhooks with HMAC-SHA256 signing and retries
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
export type WebhookEvent =
|
||||
| "request.completed"
|
||||
| "request.failed"
|
||||
| "provider.error"
|
||||
| "provider.recovered"
|
||||
| "quota.exceeded"
|
||||
| "combo.switched"
|
||||
| "test.ping";
|
||||
|
||||
export interface WebhookPayload {
|
||||
event: WebhookEvent;
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
function signPayload(payload: string, secret: string): string {
|
||||
return `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`;
|
||||
}
|
||||
|
||||
export async function deliverWebhook(
|
||||
url: string,
|
||||
payload: WebhookPayload,
|
||||
secret?: string | null,
|
||||
maxRetries = 3
|
||||
): Promise<{ success: boolean; status: number; error?: string }> {
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OmniRoute-Webhook/1.0",
|
||||
"X-Webhook-Event": payload.event,
|
||||
"X-Webhook-Timestamp": payload.timestamp,
|
||||
};
|
||||
|
||||
if (secret) {
|
||||
headers["X-Webhook-Signature"] = signPayload(body, secret);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res.ok || res.status < 500) {
|
||||
return { success: res.ok, status: res.status };
|
||||
}
|
||||
|
||||
// Server error — retry with exponential backoff
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (attempt === maxRetries) {
|
||||
return { success: false, status: 0, error: error.message || "Network error" };
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, status: 0, error: "Max retries exceeded" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to all matching enabled webhooks
|
||||
*/
|
||||
export async function dispatchEvent(event: WebhookEvent, data: Record<string, any>): Promise<void> {
|
||||
// Lazy import to avoid circular deps
|
||||
const { getEnabledWebhooks, recordWebhookDelivery, disableWebhooksWithHighFailures } =
|
||||
await import("./db/webhooks");
|
||||
|
||||
const webhooks = getEnabledWebhooks();
|
||||
const payload: WebhookPayload = {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
const deliveries = webhooks
|
||||
.filter((wh) => {
|
||||
const events = wh.events;
|
||||
return events.includes("*") || events.includes(event);
|
||||
})
|
||||
.map(async (wh) => {
|
||||
const result = await deliverWebhook(wh.url, payload, wh.secret);
|
||||
recordWebhookDelivery(wh.id, result.status, result.success);
|
||||
return { webhookId: wh.id, ...result };
|
||||
});
|
||||
|
||||
await Promise.allSettled(deliveries);
|
||||
|
||||
// Auto-disable webhooks with too many failures
|
||||
disableWebhooksWithHighFailures(10);
|
||||
}
|
||||
Reference in New Issue
Block a user