{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "api-1",
  "title": "API 1: request explorer",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An API explorer: a method, an address, headers and a JSON body, then the answer with its status in words, time and size and the body as a tree; a broken body is caught before sending, with history and Copy as cURL.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/copy-button.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/json-viewer.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/textarea.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/api-1/api-1.tsx",
      "content": "// Ballmac UI: API 1. https://ui.ballmac.com/blocks/api-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, History, Loader2, Plus, Send, Terminal, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CopyButton } from \"@/components/ballmac/copy-button\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { JsonViewer } from \"@/components/ballmac/json-viewer\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Textarea } from \"@/components/ballmac/textarea\"\nimport { cn } from \"@/lib/utils\"\n\ntype Api1Method = \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\"\ntype Api1Pair = { id: string; key: string; value: string }\ntype Api1Request = { method: Api1Method; url: string; headers: Api1Pair[]; body: string }\ntype Api1Response = { status: number; statusText: string; ms: number; headers: Record<string, string>; body: unknown }\n\ntype Api1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    /** Starting request. */\n    defaultRequest?: Partial<Api1Request>\n    /** Called to send the request. Return the response; reject for a network failure. Without it a built-in demo API answers. */\n    onSend?: (request: Api1Request) => Promise<Api1Response>\n    /** Show a response at first, for showing that screen. */\n    defaultResponse?: Api1Response\n}\n\nconst methods: Api1Method[] = [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"]\nconst methodDot: Record<Api1Method, string> = { GET: \"bg-chart-2\", POST: \"bg-chart-1\", PUT: \"bg-amber-500\", PATCH: \"bg-amber-500\", DELETE: \"bg-destructive\" }\n\nconst users = [\n  { id: \"usr_1\", name: \"Mara Ellison\", email: \"mara@acme.com\", role: \"owner\" },\n  { id: \"usr_2\", name: \"Theo Nakamura\", email: \"theo@acme.com\", role: \"admin\" },\n  { id: \"usr_3\", name: \"Priya Shah\", email: \"priya@acme.com\", role: \"member\" },\n]\n\n// A tiny pretend API so the block works with no server. It checks the same things a real one would.\nasync function demoApi(r: Api1Request): Promise<Api1Response> {\n  const path = r.url.replace(/^https?:\\/\\/[^/]+/, \"\").split(\"?\")[0]!\n  const authed = r.headers.some((h) => h.key.toLowerCase() === \"authorization\" && h.value.trim().length > 7)\n  const base = { headers: { \"content-type\": \"application/json\", \"x-request-id\": \"req_8f3a1c\" } }\n  const ms = 90 + ((path.length * 37) % 140)\n  await new Promise((res) => setTimeout(res, 350))\n  if (!authed) return { ...base, status: 401, statusText: \"Unauthorized\", ms, body: { error: { code: \"missing_credentials\", message: \"Add an Authorization header with a Bearer token.\" } } }\n  if (path === \"/v1/users\" && r.method === \"GET\") return { ...base, status: 200, statusText: \"OK\", ms, body: { data: users, has_more: false } }\n  const one = path.match(/^\\/v1\\/users\\/(\\w+)$/)\n  if (one && r.method === \"GET\") {\n    const u = users.find((x) => x.id === one[1])\n    return u ? { ...base, status: 200, statusText: \"OK\", ms, body: u } : { ...base, status: 404, statusText: \"Not Found\", ms, body: { error: { code: \"not_found\", message: `No user with id ${one[1]}.` } } }\n  }\n  if (path === \"/v1/users\" && r.method === \"POST\") {\n    let b: unknown\n    try {\n      b = JSON.parse(r.body || \"{}\")\n    } catch {\n      return { ...base, status: 400, statusText: \"Bad Request\", ms, body: { error: { code: \"invalid_json\", message: \"The body is not valid JSON.\" } } }\n    }\n    const o = b as { name?: string; email?: string }\n    if (!o.email) return { ...base, status: 422, statusText: \"Unprocessable Entity\", ms, body: { error: { code: \"invalid_request\", message: \"email is required.\", param: \"email\" } } }\n    return { ...base, status: 201, statusText: \"Created\", ms, body: { id: \"usr_4\", name: o.name ?? \"\", email: o.email, role: \"member\" } }\n  }\n  return { ...base, status: 404, statusText: \"Not Found\", ms, body: { error: { code: \"not_found\", message: `No route for ${r.method} ${path}.` } } }\n}\n\nconst pair = (id: string, key = \"\", value = \"\"): Api1Pair => ({ id, key, value })\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4\">\n      <Skeleton className=\"h-12 rounded-xl\" />\n      <Skeleton className=\"h-64 rounded-2xl\" />\n    </div>\n  )\n}\n\nconst statusTone = (s: number) => (s >= 500 ? \"bg-destructive/10 border-destructive/40\" : s >= 400 ? \"bg-amber-500/15 border-amber-500/40\" : \"bg-chart-2/10 border-chart-2/30\")\n\n/**\n * An API explorer: choose a method, type an address, add headers and a JSON body, send, and read the answer: its status in words, how long it\n * took, how big it was, and the body as a collapsible tree. A broken body is caught before sending, every request is kept in a history you can go\n * back to, and \"Copy as cURL\" writes the matching command. A built-in demo API answers when no handler is passed.\n */\nfunction Api1({ title = \"API explorer\", defaultRequest, onSend, defaultResponse, state = \"ready\", onRetry, errorState, className, ...props }: Api1Props) {\n  const id = React.useId()\n  const [req, setReq] = React.useState<Api1Request>({ method: \"GET\", url: \"https://api.acme.com/v1/users\", headers: [pair(\"h1\", \"Authorization\", \"Bearer sk_test_4eC39HqLyjWDarjtT1zdp7dc\"), pair(\"h2\", \"Accept\", \"application/json\")], body: '{\\n  \"name\": \"Sam Okafor\",\\n  \"email\": \"sam@acme.com\"\\n}', ...defaultRequest })\n  // Ids for added rows count up from a ref, so they are the same on the server and in the browser.\n  const nextId = React.useRef(3)\n  const [tab, setTab] = React.useState<\"headers\" | \"body\">(\"headers\")\n  const [resTab, setResTab] = React.useState<\"body\" | \"headers\">(\"body\")\n  const [res, setRes] = React.useState<Api1Response | null>(defaultResponse ?? null)\n  const [sending, setSending] = React.useState(false)\n  const [failure, setFailure] = React.useState<string>()\n  const [history, setHistory] = React.useState<{ req: Api1Request; status: number | null }[]>([])\n  const hasBody = req.method !== \"GET\" && req.method !== \"DELETE\"\n\n  const bodyError = React.useMemo(() => {\n    if (!hasBody || !req.body.trim()) return null\n    try {\n      JSON.parse(req.body)\n      return null\n    } catch (e) {\n      return e instanceof Error ? e.message.replace(/^JSON\\.parse: /, \"\") : \"Not valid JSON\"\n    }\n  }, [hasBody, req.body])\n  const urlError = /^https?:\\/\\/\\S+$/.test(req.url.trim()) ? null : \"Enter a full address that starts with https://\"\n\n  const curl = React.useMemo(() => {\n    const parts = [`curl -X ${req.method} '${req.url}'`, ...req.headers.filter((h) => h.key.trim()).map((h) => `  -H '${h.key}: ${h.value}'`)]\n    if (hasBody && req.body.trim()) parts.push(`  -d '${req.body.replace(/\\s*\\n\\s*/g, \" \").replace(/'/g, \"'\\\\''\")}'`)\n    return parts.join(\" \\\\\\n\")\n  }, [req, hasBody])\n\n  async function send() {\n    if (urlError || bodyError || sending) return\n    setSending(true)\n    setFailure(undefined)\n    try {\n      const r = await (onSend ?? demoApi)(req)\n      setRes(r)\n      setHistory((h) => [{ req, status: r.status }, ...h].slice(0, 6))\n    } catch (e) {\n      setRes(null)\n      setFailure(e instanceof Error ? e.message : \"The request could not be sent.\")\n      setHistory((h) => [{ req, status: null }, ...h].slice(0, 6))\n    } finally {\n      setSending(false)\n    }\n  }\n  const setPair = (pid: string, p: Partial<Api1Pair>) => setReq((r) => ({ ...r, headers: r.headers.map((h) => (h.id === pid ? { ...h, ...p } : h)) }))\n  const size = res ? new Blob([JSON.stringify(res.body)]).size : 0\n\n  const content = (\n    <div className=\"grid gap-6\">\n      <form onSubmit={(e) => { e.preventDefault(); void send() }} noValidate>\n        <div className=\"bg-card flex flex-col gap-2 rounded-2xl border p-2 sm:flex-row\">\n          <Select value={req.method} onValueChange={(v) => setReq({ ...req, method: v as Api1Method })}>\n            <SelectTrigger aria-label=\"Method\" className=\"w-full font-mono font-semibold sm:w-32\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {methods.map((m) => (\n                <SelectItem key={m} value={m} className=\"font-mono font-semibold\">\n                  {m}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n          <Input dir=\"ltr\" aria-label=\"Address\" aria-invalid={!!urlError} value={req.url} onChange={(e) => setReq({ ...req, url: e.target.value })} className=\"flex-1 font-mono text-sm\" spellCheck={false} />\n          <Button type=\"submit\" disabled={sending || !!urlError || !!bodyError}>\n            {sending ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Send aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />}\n            {sending ? \"Sending\" : \"Send\"}\n          </Button>\n        </div>\n        {urlError ? <p role=\"alert\" className=\"text-destructive mt-2 ps-2 text-sm\">{urlError}</p> : null}\n      </form>\n\n      <div className=\"grid items-start gap-6 lg:grid-cols-2\">\n        <div className=\"bg-card min-w-0 rounded-2xl border\">\n          <div role=\"tablist\" aria-label=\"Request\" className=\"flex gap-1 border-b px-3 pt-3\">\n            {([[\"headers\", `Headers · ${req.headers.filter((h) => h.key.trim()).length}`], [\"body\", \"Body\"]] as const).map(([k, l]) => (\n              <button key={k} type=\"button\" role=\"tab\" aria-selected={tab === k} onClick={() => setTab(k)} className={cn(\"focus-visible:ring-ring/50 -mb-px rounded-t-md border-b-2 px-3.5 py-2 text-sm font-medium outline-none focus-visible:ring-[3px]\", tab === k ? \"border-foreground\" : \"text-muted-foreground border-transparent\")}>\n                {l}\n              </button>\n            ))}\n          </div>\n          <div role=\"tabpanel\" className=\"p-4\">\n            {tab === \"headers\" ? (\n              <div className=\"grid gap-2.5\">\n                {req.headers.map((h, i) => (\n                  <div key={h.id} className=\"flex items-center gap-2\">\n                    <Input dir=\"ltr\" aria-label={`Header ${i + 1} name`} value={h.key} onChange={(e) => setPair(h.id, { key: e.target.value })} placeholder=\"Name\" className=\"w-2/5 font-mono text-xs\" spellCheck={false} />\n                    <Input dir=\"ltr\" aria-label={`Header ${i + 1} value`} value={h.value} onChange={(e) => setPair(h.id, { value: e.target.value })} placeholder=\"Value\" className=\"flex-1 font-mono text-xs\" spellCheck={false} />\n                    <Button type=\"button\" variant=\"ghost\" size=\"icon\" aria-label={`Remove header ${i + 1}`} onClick={() => setReq((r) => ({ ...r, headers: r.headers.filter((x) => x.id !== h.id) }))}>\n                      <X aria-hidden=\"true\" />\n                    </Button>\n                  </div>\n                ))}\n                <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"w-fit\" onClick={() => setReq((r) => ({ ...r, headers: [...r.headers, pair(`h${nextId.current++}`)] }))}>\n                  <Plus aria-hidden=\"true\" />\n                  Add header\n                </Button>\n              </div>\n            ) : hasBody ? (\n              <div>\n                <Textarea dir=\"ltr\" aria-label=\"Request body (JSON)\" aria-invalid={!!bodyError} value={req.body} onChange={(e) => setReq({ ...req, body: e.target.value })} rows={9} spellCheck={false} className=\"font-mono text-xs\" />\n                {bodyError ? (\n                  <p role=\"alert\" className=\"text-destructive mt-2 flex items-start gap-2 text-sm\">\n                    <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                    The body is not valid JSON: {bodyError}\n                  </p>\n                ) : null}\n              </div>\n            ) : (\n              <p className=\"text-muted-foreground py-6 text-center text-sm\">{req.method} requests do not send a body.</p>\n            )}\n          </div>\n          <div className=\"flex items-center justify-between gap-3 border-t px-4 py-3\">\n            <p className=\"text-muted-foreground flex items-center gap-1.5 text-xs\">\n              <Terminal aria-hidden=\"true\" className=\"size-3.5\" />\n              Same request as a command\n            </p>\n            <CopyButton getValue={() => curl} label=\"Copy as cURL\" copiedLabel=\"Copied\" variant=\"outline\" />\n          </div>\n        </div>\n\n        <div className=\"bg-card min-w-0 rounded-2xl border\" aria-live=\"polite\" aria-busy={sending}>\n          {sending ? (\n            <div className=\"grid gap-3 p-6\" aria-label=\"Waiting for a response\">\n              <Skeleton className=\"h-6 w-40\" />\n              <Skeleton className=\"h-44\" />\n            </div>\n          ) : failure ? (\n            <div role=\"alert\" className=\"p-8 text-center\">\n              <span aria-hidden=\"true\" className=\"bg-destructive/10 mx-auto flex size-12 items-center justify-center rounded-full\">\n                <CircleAlert className=\"size-6\" />\n              </span>\n              <p className=\"mt-4 font-semibold\">No response</p>\n              <p className=\"text-muted-foreground mt-1 text-sm text-pretty\">{failure}</p>\n            </div>\n          ) : res ? (\n            <>\n              <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-3 text-sm\">\n                <span className={cn(\"rounded-md border px-2.5 py-1 font-mono font-semibold tabular-nums\", statusTone(res.status))}>\n                  {res.status} {res.statusText}\n                </span>\n                <span className=\"text-muted-foreground tabular-nums\">{res.ms} ms</span>\n                <span className=\"text-muted-foreground tabular-nums\">{size >= 1024 ? `${(size / 1024).toFixed(1)} KB` : `${size} B`}</span>\n              </div>\n              <div role=\"tablist\" aria-label=\"Response\" className=\"flex gap-1 px-3 pt-3\">\n                {([\"body\", \"headers\"] as const).map((k) => (\n                  <button key={k} type=\"button\" role=\"tab\" aria-selected={resTab === k} onClick={() => setResTab(k)} className={cn(\"focus-visible:ring-ring/50 -mb-px rounded-t-md border-b-2 px-3.5 py-2 text-sm font-medium capitalize outline-none focus-visible:ring-[3px]\", resTab === k ? \"border-foreground\" : \"text-muted-foreground border-transparent\")}>\n                    {k}\n                  </button>\n                ))}\n              </div>\n              <div role=\"tabpanel\" className=\"max-h-96 overflow-auto border-t p-4\">\n                {resTab === \"body\" ? (\n                  <JsonViewer value={res.body} label=\"Response body\" defaultExpandedDepth={3} />\n                ) : (\n                  <dl className=\"grid gap-2 font-mono text-xs\" dir=\"ltr\">\n                    {Object.entries(res.headers).map(([k, v]) => (\n                      <div key={k} className=\"flex gap-3\">\n                        <dt className=\"text-muted-foreground w-40 shrink-0\">{k}</dt>\n                        <dd className=\"break-all\">{v}</dd>\n                      </div>\n                    ))}\n                  </dl>\n                )}\n              </div>\n            </>\n          ) : (\n            <div className=\"p-10 text-center\">\n              <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n                <Send className=\"size-5 rtl:-scale-x-100\" />\n              </span>\n              <p className=\"mt-4 font-semibold\">Send a request</p>\n              <p className=\"text-muted-foreground mt-1 text-sm text-pretty\">The answer appears here with its status, how long it took and its body.</p>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {history.length ? (\n        <div className=\"bg-card rounded-2xl border\">\n          <h2 className=\"flex items-center gap-2 border-b px-5 py-3.5 text-sm font-semibold\">\n            <History aria-hidden=\"true\" className=\"size-4\" />\n            History\n          </h2>\n          <ul className=\"divide-y\">\n            {history.map((h, i) => (\n              <li key={i}>\n                <button type=\"button\" onClick={() => setReq(h.req)} className=\"hover:bg-accent/40 focus-visible:ring-ring/50 flex w-full items-center gap-3 px-5 py-2.5 text-start text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset motion-reduce:transition-none\">\n                  <span className=\"flex w-16 items-center gap-2 font-mono text-xs font-semibold\">\n                    <span aria-hidden=\"true\" className={cn(\"size-2 rounded-full\", methodDot[h.req.method])} />\n                    {h.req.method}\n                  </span>\n                  <span className=\"min-w-0 flex-1 truncate font-mono text-xs\" dir=\"ltr\">\n                    {h.req.url.replace(/^https?:\\/\\//, \"\")}\n                  </span>\n                  <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">{h.status ?? \"No reply\"}</span>\n                  <span className=\"sr-only\">Use this request again</span>\n                </button>\n              </li>\n            ))}\n          </ul>\n        </div>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"api-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-6\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Try a request without leaving the page.</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the explorer\" loading={<Skel />} error={{ title: errorState?.title ?? \"The explorer could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Api1, type Api1Props, type Api1Request, type Api1Response, type Api1Method }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/api-1/api-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "api",
      "request",
      "http",
      "rest",
      "postman",
      "playground",
      "curl",
      "developer",
      "docs",
      "explorer",
      "json"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "An API explorer. <Api1 title defaultRequest={{ method: 'POST', url, headers: [{ id, key, value }], body }} defaultResponse onSend={(request) => Promise<{ status, statusText, ms, headers, body }>} />. Send is disabled while the address is not a full https URL or a body is not valid JSON, and says why. Every send is added to a history of six. Copy as cURL builds the command from the current request. Without onSend a built-in demo API for /v1/users answers (401 without an Authorization header, 200, 201, 404, 422).",
      "whenToUse": [
        "API documentation with a try-it panel",
        "A developer dashboard where customers test their keys"
      ],
      "whenNotToUse": [
        "Showing one endpoint's reference (use api-endpoint)",
        "Watching live traffic (use log-stream)"
      ],
      "composesWith": [
        "dev-1",
        "webhooks-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter",
          "action": "Method menu, address, then Send; pressing Enter in the address sends. Header rows are labelled by position and each has a Remove button"
        },
        {
          "keys": "Validation",
          "action": "A bad address or broken JSON is an alert that says what is wrong and why Send is off"
        },
        {
          "keys": "Response",
          "action": "The panel is a polite live region that reads the status in words, such as '404 Not Found', with time and size"
        }
      ],
      "customization": [
        "Addresses, headers and bodies are shown left to right in right-to-left layouts, like code",
        "History restores a past request into the form",
        "Methods without a body (GET, DELETE) hide the body field and say so"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "api-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton request bar and panel under the real heading, announced as loading. While a request is in flight the response panel shows a skeleton and the page is marked busy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "api-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry). A request that gets no answer shows 'No response' with the reason in the response panel."
      }
    ],
    "examples": [
      "api-1-demo",
      "api-1-created",
      "api-1-notfound",
      "api-1-loading",
      "api-1-error"
    ],
    "url": "https://ui.ballmac.com/components/api-1"
  },
  "categories": [
    "blocks",
    "api"
  ],
  "type": "registry:block"
}