From 3662c9c66648e3b9fbac9ab6b7c48a6658a62259 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 9 Feb 2026 18:12:51 -0800 Subject: [PATCH] feat(playground): proxy real api calls with rate-limited endpoint --- data/api-endpoints.json | 235 ++++++++++++++++----- src/app/api/playground/route.ts | 260 ++++++++++++++++++++++++ src/app/playground/PlaygroundClient.tsx | 134 ++++++++++-- 3 files changed, 563 insertions(+), 66 deletions(-) create mode 100644 src/app/api/playground/route.ts diff --git a/data/api-endpoints.json b/data/api-endpoints.json index 1891eafb..4459c65b 100644 --- a/data/api-endpoints.json +++ b/data/api-endpoints.json @@ -1,34 +1,122 @@ [ + { + "id": "health", + "name": "Health", + "method": "GET", + "path": "/api/health", + "description": "Basic API health and counts", + "params": [] + }, + { + "id": "stats", + "name": "Stats", + "method": "GET", + "path": "/api/stats", + "description": "Top-level stats for skills, MCP servers, categories, and reviews", + "params": [] + }, { "id": "list-skills", "name": "List Skills", "method": "GET", - "path": "/api/skills.json", - "description": "Get all available skills", - "params": [] + "path": "/api/skills", + "description": "Get skills with optional filters and pagination", + "params": [ + { + "name": "category", + "type": "string", + "required": false, + "placeholder": "memory" + }, + { + "name": "search", + "type": "string", + "required": false, + "placeholder": "notion" + }, + { + "name": "sort", + "type": "string", + "required": false, + "placeholder": "trending" + }, + { + "name": "limit", + "type": "number", + "required": false, + "placeholder": "20" + }, + { + "name": "offset", + "type": "number", + "required": false, + "placeholder": "0" + } + ] }, { - "id": "trending-skills", + "id": "list-mcp", + "name": "List MCP Servers", + "method": "GET", + "path": "/api/mcp", + "description": "Get MCP servers with optional category/search filters", + "params": [ + { + "name": "category", + "type": "string", + "required": false, + "placeholder": "search" + }, + { + "name": "search", + "type": "string", + "required": false, + "placeholder": "slack" + } + ] + }, + { + "id": "trending", "name": "Trending Skills", "method": "GET", - "path": "/api/trending/skills", - "description": "Get trending scores and badges for skills", - "params": [] + "path": "/api/trending", + "description": "Get skills ranked by trending score", + "params": [ + { + "name": "category", + "type": "string", + "required": false, + "placeholder": "memory" + }, + { + "name": "limit", + "type": "number", + "required": false, + "placeholder": "25" + } + ] }, { "id": "list-collections", "name": "List Collections", "method": "GET", "path": "/api/collections", - "description": "Get curated skill-bundle collections (no auth)", - "params": [] + "description": "Get curated collections, optionally filtered by search", + "params": [ + { + "name": "search", + "type": "string", + "required": false, + "placeholder": "automation" + } + ] }, { "id": "news-feed", "name": "News Feed", "method": "GET", - "path": "/api/feed.json", - "description": "Get the latest news items (optionally filter by tag)", + "path": "/api/feed", + "description": "Get latest news items with optional tag filter", "params": [ { "name": "tag", @@ -39,71 +127,126 @@ ] }, { - "id": "canary-results", - "name": "Canary Results", + "id": "changelog", + "name": "Changelog", "method": "GET", - "path": "/api/canary/results", - "description": "Fetch recent canary run results", + "path": "/api/changelog", + "description": "Get changelog entries with pagination", "params": [ { "name": "limit", "type": "number", "required": false, - "placeholder": "50" + "placeholder": "20" }, { - "name": "status", - "type": "string", + "name": "offset", + "type": "number", "required": false, - "placeholder": "pass" + "placeholder": "0" } ] }, { - "id": "health", - "name": "Health", + "id": "list-requests", + "name": "Kit Requests", "method": "GET", - "path": "/api/health", - "description": "Basic API health and counts", - "params": [] + "path": "/api/requests", + "description": "List requested kits sorted by votes or recency", + "params": [ + { + "name": "sort", + "type": "string", + "required": false, + "placeholder": "votes" + }, + { + "name": "category", + "type": "string", + "required": false, + "placeholder": "automation" + } + ] }, { - "id": "list-bounties", - "name": "List Bounties", - "method": "GET", - "path": "/api/bounties.json", - "description": "Get all Fund-a-Kit bounties", - "params": [] + "id": "create-request", + "name": "Create Kit Request", + "method": "POST", + "path": "/api/requests", + "description": "Submit a new kit request", + "params": [ + { + "name": "title", + "type": "string", + "required": true, + "placeholder": "Notion Assistant Kit", + "in": "body" + }, + { + "name": "description", + "type": "string", + "required": true, + "placeholder": "Create pages, update databases, and summarize content", + "in": "body" + }, + { + "name": "category", + "type": "string", + "required": true, + "placeholder": "productivity", + "in": "body" + } + ] }, { - "id": "list-macros", - "name": "List Macro Tools", - "method": "GET", - "path": "/api/macros.json", - "description": "Get all macro tools", - "params": [] + "id": "upvote-request", + "name": "Upvote Kit Request", + "method": "POST", + "path": "/api/requests/{id}/vote", + "description": "Increment votes for a request", + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "placeholder": "req_1739059030123_ab12cd", + "in": "path" + } + ] }, { - "id": "list-mcp", - "name": "List MCP Servers", + "id": "list-bounties", + "name": "List Bounties", "method": "GET", - "path": "/api/mcp.json", - "description": "Get MCP servers (optionally filter by category)", + "path": "/api/bounties", + "description": "Get bounties with optional status/tag filters", "params": [ { - "name": "category", + "name": "status", "type": "string", "required": false, - "placeholder": "search" + "placeholder": "open" + }, + { + "name": "tag", + "type": "string", + "required": false, + "placeholder": "automation" + }, + { + "name": "sort", + "type": "string", + "required": false, + "placeholder": "recent" } ] }, { - "id": "list-requests", - "name": "Kit Requests", + "id": "list-macros", + "name": "List Macro Tools", "method": "GET", - "path": "/api/requests", - "description": "List requested kits (sorted by votes)", + "path": "/api/macros.json", + "description": "Get all macro tools", "params": [] } ] diff --git a/src/app/api/playground/route.ts b/src/app/api/playground/route.ts new file mode 100644 index 00000000..4e4fc058 --- /dev/null +++ b/src/app/api/playground/route.ts @@ -0,0 +1,260 @@ +import { NextRequest, NextResponse } from "next/server"; + +import curatedEndpoints from "../../../../data/api-endpoints.json"; + +type RequestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"; + +type PlaygroundRequest = { + endpoint?: unknown; + method?: unknown; + params?: unknown; +}; + +type EndpointDefinition = { + path: string; + method: string; +}; + +type RateLimitState = { + count: number; + resetAt: number; +}; + +const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]); +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX = 10; + +const rateLimitStore = new Map(); +const allowedEndpointKeys = new Set( + (curatedEndpoints as EndpointDefinition[]).map((endpoint) => { + const method = endpoint.method.toUpperCase(); + return `${method} ${endpoint.path}`; + }) +); + +function getClientIp(request: NextRequest): string { + const forwarded = request.headers.get("x-forwarded-for") || ""; + const firstForwarded = forwarded.split(",")[0]?.trim(); + const realIp = request.headers.get("x-real-ip")?.trim(); + const cfIp = request.headers.get("cf-connecting-ip")?.trim(); + + return firstForwarded || realIp || cfIp || "unknown"; +} + +function checkRateLimit(ip: string): { ok: boolean; remaining: number; resetAt: number; retryAfterSec?: number } { + const now = Date.now(); + const existing = rateLimitStore.get(ip); + + if (!existing || now >= existing.resetAt) { + const resetAt = now + RATE_LIMIT_WINDOW_MS; + const nextState: RateLimitState = { count: 1, resetAt }; + rateLimitStore.set(ip, nextState); + return { ok: true, remaining: RATE_LIMIT_MAX - 1, resetAt }; + } + + const nextCount = existing.count + 1; + const remaining = Math.max(0, RATE_LIMIT_MAX - nextCount); + + if (nextCount > RATE_LIMIT_MAX) { + return { + ok: false, + remaining: 0, + resetAt: existing.resetAt, + retryAfterSec: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)), + }; + } + + existing.count = nextCount; + rateLimitStore.set(ip, existing); + + return { ok: true, remaining, resetAt: existing.resetAt }; +} + +function withRateLimitHeaders(response: NextResponse, remaining: number, resetAt: number): NextResponse { + response.headers.set("X-RateLimit-Limit", String(RATE_LIMIT_MAX)); + response.headers.set("X-RateLimit-Remaining", String(remaining)); + response.headers.set("X-RateLimit-Reset", String(Math.floor(resetAt / 1000))); + return response; +} + +function normalizeMethod(input: unknown): RequestMethod | null { + if (typeof input !== "string") return null; + const method = input.toUpperCase() as RequestMethod; + return ALLOWED_METHODS.has(method) ? method : null; +} + +function normalizeEndpoint(input: unknown): string | null { + if (typeof input !== "string") return null; + const endpoint = input.trim(); + if (!endpoint.startsWith("/api/")) return null; + if (endpoint.startsWith("/api/playground")) return null; + if (endpoint.includes("..") || endpoint.includes("://")) return null; + return endpoint; +} + +function normalizeParams(input: unknown): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return {}; + return input as Record; +} + +function replacePathParams(endpoint: string, params: Record): { path: string; usedKeys: Set } { + const usedKeys = new Set(); + const path = endpoint.replace(/\{([^}]+)\}/g, (_match, key: string) => { + const value = params[key]; + if (typeof value === "undefined" || value === null || String(value).trim() === "") { + return `{${key}}`; + } + + usedKeys.add(key); + return encodeURIComponent(String(value)); + }); + + return { path, usedKeys }; +} + +function buildTargetUrl( + endpoint: string, + method: RequestMethod, + params: Record, + origin: string +): { url: string; body?: string } { + const { path, usedKeys } = replacePathParams(endpoint, params); + const url = new URL(path, origin); + + if (method === "GET" || method === "HEAD") { + Object.entries(params).forEach(([key, value]) => { + if (usedKeys.has(key) || typeof value === "undefined" || value === null) return; + const serialized = typeof value === "string" ? value : JSON.stringify(value); + if (serialized.trim().length === 0) return; + url.searchParams.set(key, serialized); + }); + return { url: url.toString() }; + } + + const body: Record = {}; + Object.entries(params).forEach(([key, value]) => { + if (usedKeys.has(key) || typeof value === "undefined") return; + body[key] = value; + }); + + return { url: url.toString(), body: JSON.stringify(body) }; +} + +export async function POST(request: NextRequest) { + const ip = getClientIp(request); + const rl = checkRateLimit(ip); + + if (!rl.ok) { + const limited = NextResponse.json( + { error: "Rate limit exceeded. Max 10 requests per minute per IP." }, + { + status: 429, + headers: { + "Retry-After": String(rl.retryAfterSec ?? 60), + }, + } + ); + return withRateLimitHeaders(limited, rl.remaining, rl.resetAt); + } + + let payload: PlaygroundRequest; + try { + payload = (await request.json()) as PlaygroundRequest; + } catch { + const invalidJson = NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + return withRateLimitHeaders(invalidJson, rl.remaining, rl.resetAt); + } + + const method = normalizeMethod(payload.method); + const endpoint = normalizeEndpoint(payload.endpoint); + + if (!method || !endpoint) { + const invalid = NextResponse.json( + { error: "Invalid request. Expected { endpoint, method, params }." }, + { status: 400 } + ); + return withRateLimitHeaders(invalid, rl.remaining, rl.resetAt); + } + + if (!allowedEndpointKeys.has(`${method} ${endpoint}`)) { + const notAllowed = NextResponse.json( + { error: "Endpoint or method is not available in the curated playground list." }, + { status: 403 } + ); + return withRateLimitHeaders(notAllowed, rl.remaining, rl.resetAt); + } + + const params = normalizeParams(payload.params); + const target = buildTargetUrl(endpoint, method, params, request.nextUrl.origin); + + const upstreamHeaders: Record = { + Accept: "application/json, text/plain;q=0.9, */*;q=0.8", + }; + + const authorization = request.headers.get("authorization"); + if (authorization) upstreamHeaders["Authorization"] = authorization; + + const cookie = request.headers.get("cookie"); + if (cookie) upstreamHeaders["Cookie"] = cookie; + + if (target.body && method !== "GET" && method !== "HEAD") { + upstreamHeaders["Content-Type"] = "application/json"; + } + + const startedAt = performance.now(); + + try { + const upstreamResponse = await fetch(target.url, { + method, + headers: upstreamHeaders, + body: target.body, + cache: "no-store", + }); + + const durationMs = Math.round(performance.now() - startedAt); + + const headers: Record = {}; + upstreamResponse.headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") return; + headers[key] = value; + }); + + const contentType = upstreamResponse.headers.get("content-type") || ""; + const rawText = await upstreamResponse.text(); + let data: unknown = rawText; + + if (contentType.includes("application/json")) { + try { + data = rawText ? JSON.parse(rawText) : null; + } catch { + data = rawText; + } + } + + const response = NextResponse.json( + { + endpoint, + method, + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers, + durationMs, + data, + }, + { status: upstreamResponse.status } + ); + + return withRateLimitHeaders(response, rl.remaining, rl.resetAt); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to reach upstream API route"; + const failure = NextResponse.json( + { + endpoint, + method, + error: message, + }, + { status: 502 } + ); + return withRateLimitHeaders(failure, rl.remaining, rl.resetAt); + } +} diff --git a/src/app/playground/PlaygroundClient.tsx b/src/app/playground/PlaygroundClient.tsx index 80974c0e..e6a9a096 100644 --- a/src/app/playground/PlaygroundClient.tsx +++ b/src/app/playground/PlaygroundClient.tsx @@ -44,6 +44,17 @@ type ResponseState = { method: string; }; +type PlaygroundProxyResponse = { + endpoint?: string; + method?: string; + status?: number; + statusText?: string; + headers?: Record; + durationMs?: number; + data?: unknown; + error?: string; +}; + const HISTORY_KEY = "foragents_api_playground_history_v1"; const HISTORY_LIMIT = 10; @@ -143,6 +154,25 @@ function buildRequest(endpoint: ApiEndpoint, values: Record): { return { url, init }; } +function buildProxyParams(endpoint: ApiEndpoint, values: Record): Record { + const params: Record = {}; + + for (const p of endpoint.params || []) { + const raw = values[p.name] ?? ""; + if (!raw.trim()) continue; + + const loc = inferParamLocation(endpoint, p); + if (loc === "body") { + params[p.name] = coerceValue(p.type, raw); + continue; + } + + params[p.name] = raw; + } + + return params; +} + function formatTs(ts: number): string { try { return new Date(ts).toLocaleString(); @@ -166,6 +196,7 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [response, setResponse] = useState(null); + const [copied, setCopied] = useState(false); const abortRef = useRef(null); @@ -187,14 +218,17 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint // Initialize param fields when endpoint changes useEffect(() => { + if (!selectedEndpoint) return; + const next: Record = {}; - for (const p of selectedEndpoint?.params || []) { + for (const p of selectedEndpoint.params || []) { next[p.name] = ""; } setValues(next); setError(null); setResponse(null); - }, [selectedEndpoint?.id]); + setCopied(false); + }, [selectedEndpoint]); function persistHistory(next: RequestHistoryItem[]) { setHistory(next); @@ -211,6 +245,7 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint setLoading(true); setError(null); setResponse(null); + setCopied(false); if (abortRef.current) abortRef.current.abort(); const ac = new AbortController(); @@ -234,36 +269,63 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint params: values, }; - const t0 = performance.now(); - try { - const res = await fetch(built.url, { ...built.init, signal: ac.signal }); - const durationMs = Math.round(performance.now() - t0); + const requestStart = performance.now(); - const headers: Record = {}; - res.headers.forEach((v, k) => { - headers[k] = v; + try { + const res = await fetch("/api/playground", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + endpoint: selectedEndpoint.path, + method: selectedEndpoint.method.toUpperCase(), + params: buildProxyParams(selectedEndpoint, values), + }), + signal: ac.signal, }); + const fallbackDurationMs = Math.round(performance.now() - requestStart); const ct = res.headers.get("content-type") || ""; - let body: unknown; + + let payload: PlaygroundProxyResponse | null = null; if (ct.includes("application/json")) { - body = await res.json().catch(async () => safeJsonParse(await res.text())); - } else { + payload = (await res.json().catch(() => null)) as PlaygroundProxyResponse | null; + } + + if (!payload) { const text = await res.text(); - body = safeJsonParse(text); + payload = { + status: res.status, + statusText: res.statusText, + data: safeJsonParse(text), + headers: {}, + durationMs: fallbackDurationMs, + }; } + const proxyHeaders = payload.headers ?? {}; + const status = typeof payload.status === "number" ? payload.status : res.status; + const statusText = payload.statusText || res.statusText; + const durationMs = typeof payload.durationMs === "number" ? payload.durationMs : fallbackDurationMs; + const body = typeof payload.data === "undefined" ? payload : payload.data; + setResponse({ - status: res.status, - statusText: res.statusText, - headers, + status, + statusText, + headers: proxyHeaders, body, durationMs, url: built.url, method: selectedEndpoint.method.toUpperCase(), }); - const nextHistory = [{ ...historyItem, status: res.status, ok: res.ok }, ...history] + if (payload.error) { + setError(payload.error); + } + + const nextHistory = [{ ...historyItem, status, ok: status >= 200 && status < 300 }, ...history] .slice(0, HISTORY_LIMIT); persistHistory(nextHistory); } catch (err) { @@ -283,12 +345,29 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint setValues(item.params || {}); setError(null); setResponse(null); + setCopied(false); } function clearHistory() { persistHistory([]); } + async function copyResponseBody() { + if (!response) return; + + const text = typeof response.body === "string" + ? response.body + : JSON.stringify(response.body, null, 2); + + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + } + const builtPreview = useMemo(() => { if (!selectedEndpoint) return null; const built = buildRequest(selectedEndpoint, values); @@ -440,7 +519,12 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint disabled={loading || !selectedEndpoint} className="bg-[#06D6A0] hover:bg-[#06D6A0]/90 text-black font-semibold" > - {loading ? "Sending…" : "Send Request"} + {loading ? ( + + + Sending… + + ) : "Send Request"} {loading ? ( @@ -471,7 +555,10 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint ) : null} {loading ? ( -

Loading…

+
+ + Loading response… +
) : null} {response ? ( @@ -493,6 +580,13 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint {response.status} {response.statusText}
{response.durationMs}ms + @@ -522,7 +616,7 @@ export default function PlaygroundClient({ endpoints }: { endpoints: ApiEndpoint
- Tip: This playground uses fetch() from your browser, so cookies, auth, and rate limits apply. + Tip: Requests go through /api/playground, which applies a 10 req/min/IP limit and then proxies to real API routes.