"use client"; import { CheckIcon, CopyIcon, PlusIcon, SendHorizontalIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Kbd } from "@/components/ui/kbd"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export interface RequestHeader { id: string; key: string; value: string; enabled: boolean; } export interface BuiltRequest { method: HttpMethod; url: string; headers: Record; body?: string; } export interface ResponseData { status: number; statusText?: string; headers?: Record; body?: unknown; } export interface RequestBuilderProps { defaultMethod?: HttpMethod; defaultUrl?: string; defaultParams?: Record; defaultHeaders?: Omit[]; defaultBody?: string; onSend?: (request: BuiltRequest) => Promise; className?: string; } const METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const methodTone: Record = { GET: "text-emerald-700 dark:text-emerald-400", POST: "text-sky-700 dark:text-sky-400", PUT: "text-amber-700 dark:text-amber-300", PATCH: "text-violet-700 dark:text-violet-400", DELETE: "text-red-700 dark:text-red-400", }; const STATUS_TEXT: Record = { 200: "OK", 201: "Created", 204: "No Content", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 409: "Conflict", 422: "Unprocessable", 429: "Too Many Requests", 500: "Server Error", 502: "Bad Gateway", }; function pathParams(url: string) { const path = url.split("?")[0].replace(/^[a-z]+:\/\/[^/]+/i, ""); return [...path.matchAll(/:([A-Za-z_]\w*)/g)].map((m) => m[1]); } function queryParams(url: string): [string, string][] { const q = url.split("?")[1]; if (!q) return []; return q .split("&") .filter(Boolean) .map((pair) => { const [k, ...v] = pair.split("="); return [k, v.join("=")]; }); } function withQuery(url: string, entries: [string, string][]) { const base = url.split("?")[0]; return entries.length ? `${base}?${entries.map(([k, v]) => `${k}=${v}`).join("&")}` : base; } function UrlTokens({ url, params }: { url: string; params: Record }) { const [base, query] = [url.split("?")[0], url.includes("?") ? url.slice(url.indexOf("?")) : ""]; const out: ReactNode[] = []; const origin = base.match(/^[a-z]+:\/\/[^/]+/i)?.[0] ?? ""; if (origin) out.push({origin}); const rest = base.slice(origin.length); let last = 0; for (const m of rest.matchAll(/:([A-Za-z_]\w*)/g)) { const at = m.index ?? 0; if (at > last) out.push({rest.slice(last, at)}); out.push( {m[0]} , ); last = at + m[0].length; } if (last < rest.length) out.push({rest.slice(last)}); if (query) { query.split(/([?&=])/).forEach((part, i) => { const prev = query.split(/([?&=])/)[i - 1]; out.push( {part} , ); }); } return <>{out}; } function JsonLines({ text }: { text: string }) { const re = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/gi; const out: ReactNode[] = []; let last = 0; for (const m of text.matchAll(re)) { const at = m.index ?? 0; if (at > last) out.push({text.slice(last, at)}); if (m[1]) { out.push( {m[1]} , ); if (m[2]) out.push({m[2]}); } else { out.push( {m[0]} , ); } last = at + m[0].length; } if (last < text.length) out.push({text.slice(last)}); return <>{out}; } function firstOutsideString(text: string, ch: string) { let inString = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inString) { if (c === "\\") i++; else if (c === '"') inString = false; } else if (c === '"') inString = true; else if (c === ch) return i; } return -1; } function describeJsonError(text: string, message: string) { if (/end of (JSON|data)/i.test(message)) return "Invalid JSON · ends before it closes"; const pos = Number(message.match(/position (\d+)/)?.[1] ?? Number.NaN); const token = message.match(/Unexpected token '?(.)'?/)?.[1]; const at = Number.isNaN(pos) ? (token ? firstOutsideString(text, token) : -1) : pos; const line = at >= 0 ? text.slice(0, at).split("\n").length : null; return `Invalid JSON${line ? ` · line ${line}` : ""}${token ? ` · unexpected “${token}”` : ""}`; } const formatBytes = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`); const fieldClass = "h-8 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 font-mono text-xs text-foreground shadow-xs/5 outline-none ring-ring/24 transition-shadow placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] dark:bg-input/32"; const iconBtn = "grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground outline-none transition-[color,background-color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] [@media(hover:hover)]:hover:bg-accent [@media(hover:hover)]:hover:text-foreground"; function Count({ n }: { n: number }) { if (!n) return null; return {n}; } type Phase = { state: "idle" } | { state: "loading"; started: number } | { state: "done"; res: ResponseData; ms: number; size: number } | { state: "error"; message: string; ms: number }; let uid = 0; const nextId = () => `h${++uid}`; export function RequestBuilder({ defaultMethod = "GET", defaultUrl = "https://api.example.com/v1/users/:id", defaultParams = {}, defaultHeaders = [{ key: "Accept", value: "application/json", enabled: true }], defaultBody = "", onSend, className, }: RequestBuilderProps) { const reduce = useReducedMotion(); const id = useId(); const [method, setMethod] = useState(defaultMethod); const [url, setUrl] = useState(defaultUrl); const [params, setParams] = useState>(defaultParams); const [headers, setHeaders] = useState(() => defaultHeaders.map((h) => ({ ...h, id: nextId() }))); const [body, setBody] = useState(defaultBody); const [tab, setTab] = useState("params"); const [resTab, setResTab] = useState<"body" | "headers">("body"); const [phase, setPhase] = useState({ state: "idle" }); const [now, setNow] = useState(0); const [copied, setCopied] = useState(false); const overlay = useRef(null); const req = useRef(0); const names = useMemo(() => pathParams(url), [url]); const query = useMemo(() => queryParams(url), [url]); const missing = names.filter((n) => !params[n]?.trim()); const hasBody = method !== "GET" && method !== "DELETE"; const bodyError = useMemo(() => { if (!hasBody || !body.trim()) return null; try { JSON.parse(body); return null; } catch (e) { return describeJsonError(body, (e as Error).message); } }, [body, hasBody]); useEffect(() => { if (phase.state !== "loading") return; const t = setInterval(() => setNow(performance.now() - phase.started), 31); return () => clearInterval(t); }, [phase]); useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1400); return () => clearTimeout(t); }, [copied]); const resolved = (() => { const at = url.indexOf("?"); const base = at === -1 ? url : url.slice(0, at); const filled = base.replace(/:([A-Za-z_]\w*)/g, (m, n: string) => (params[n]?.trim() ? encodeURIComponent(params[n].trim()) : m)); return at === -1 ? filled : filled + url.slice(at); })(); const send = async () => { if (phase.state === "loading") return; const ticket = ++req.current; const started = performance.now(); setNow(0); setPhase({ state: "loading", started }); const built: BuiltRequest = { method, url: resolved, headers: Object.fromEntries(headers.filter((h) => h.enabled && h.key.trim()).map((h) => [h.key.trim(), h.value])), body: hasBody && body.trim() ? body : undefined, }; try { const res = onSend ? await onSend(built) : await fetch(built.url, { method, headers: built.headers, body: built.body }).then(async (r) => ({ status: r.status, statusText: r.statusText, headers: Object.fromEntries(r.headers.entries()), body: await r.text().then((t) => { try { return JSON.parse(t); } catch { return t; } }), })); if (ticket !== req.current) return; const text = res.body === undefined ? "" : typeof res.body === "string" ? res.body : JSON.stringify(res.body); setPhase({ state: "done", res, ms: performance.now() - started, size: new Blob([text]).size }); } catch (e) { if (ticket !== req.current) return; setPhase({ state: "error", message: (e as Error).message || "Network error", ms: performance.now() - started }); } }; const setQueryValue = (i: number, value: string) => setUrl(withQuery(url, query.map((q, k) => (k === i ? [q[0], value] : q)))); const enabledHeaders = headers.filter((h) => h.enabled && h.key.trim()).length; const done = phase.state === "done" ? phase : null; const pretty = done ? (typeof done.res.body === "string" ? done.res.body : done.res.body === undefined ? "" : JSON.stringify(done.res.body, null, 2)) : ""; const status = done?.res.status ?? 0; const statusTone = status >= 500 ? "bg-destructive/10 text-destructive-foreground" : status >= 400 ? "bg-warning/12 text-warning-foreground" : status >= 300 ? "bg-info/10 text-info-foreground" : "bg-success/10 text-success-foreground"; return (
{ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }} >
setUrl(e.target.value)} onScroll={(e) => { if (overlay.current) overlay.current.scrollLeft = e.currentTarget.scrollLeft; }} spellCheck={false} value={url} />
setTab(String(v))} value={tab}> Params Headers Body {bodyError && }
{names.length === 0 && query.length === 0 && (

Add :name to the path or{" "} ?key=value to the URL.

)} {names.map((n) => ( ))} {query.map(([k, v], i) => ( ))}
{headers.map((h) => ( setHeaders((hs) => hs.map((x) => (x.id === h.id ? { ...x, enabled: Boolean(c) } : x)))} /> setHeaders((hs) => hs.map((x) => (x.id === h.id ? { ...x, key: e.target.value } : x)))} placeholder="Header" spellCheck={false} value={h.key} /> setHeaders((hs) => hs.map((x) => (x.id === h.id ? { ...x, value: e.target.value } : x)))} placeholder="Value" spellCheck={false} value={h.value} /> ))} {hasBody ? ( <>