# 03-api — `/v1/batch` endpoints in `internal/api` Wire the batch runner into the API server. This is the HTTP surface and the only place the guard, auth, caps-as-HTTP-statuses, and restricted-caller redaction meet the runner. New code goes in a new file `internal/api/batch.go`; do **not** grow `api.go`. ## Endpoints | Method | Path | Result | |---|---|---| | `POST /v1/batch` | submit a list of targets | `202` job created/reused, `400` malformed, `422` a target refused up front or over a cap, `429` caller/jobs busy | | `GET /v1/batch/{id}` | status + per-target results | `200` (results inline), `401`/`404` | | `DELETE /v1/batch/{id}` | cancel | `200` cancelled, `401`/`404` | Register them in `New` and add `/v1/batch`, `/v1/batch/` to the `ServeHTTP` fast-path switch (no `requestDeadline` — submit/poll are quick; the worker is not under any request context). ## Request/response shapes (exact JSON — the given test compares these) `POST /v1/batch`: ```json {"targets": [{"url": "…", "mode": "clean", "question": "…", "pages": "…", "grep": "…", "jq": "…", "context": true, "recover": false}, …]} ``` Each target carries the same fields as `/v1/fetch` minus the outer `url` semantics (each target is its own URL). A `targets` list is required and non-empty; otherwise `400`. Response `202`: ```json {"success": true, "job_id": "…", "status": "queued", "targets": 3, "reused": false} ``` `GET /v1/batch/{id}`: ```json {"job_id": "…", "status": "done", "targets_total": 3, "targets_done": 3, "created_at": "…", "targets": [ {"idx": 0, "url": "…", "status": "done", "result": { …/v1/fetch envelope… }}, {"idx": 1, "url": "…", "status": "failed", "error": "blocked: private address …", "hint": "browser"} ]} ``` `result` is the `/v1/fetch`-shaped envelope (content, mode, untrusted, source, warnings, sizes, timings, and pdf/digest/context when present), written by the runner's `FetchFunc` adapter. A `failed` target carries `error` and optional `hint` instead of `result`. Results are inline regardless of status, once a target is finished; a pending target has `status: "pending"` and no `result`/`error`. `DELETE /v1/batch/{id}` → `200 {"success": true, "status": "cancelled"}`. ## Refactor of existing code (explicit — this is the one task that edits working code) `v1fetch` today inlines the whole per-target pipeline: guard → `doFetch` → `recover` → render/digest → context → envelope. The batch worker needs the *same* pipeline for one target. Do **not** copy-paste that pipeline into the batch adapter — extract it into one shared method that both `v1fetch` and the runner's `FetchFunc` call: ```go func (s *Server) fetchTarget(ctx context.Context, c *auth.Caller, t batch.Target) (batch.Result, error) ``` `v1fetch` is refactored to call it, keeping its exact response shape and behavior — the existing `v1fetch` tests must pass unchanged. The runner is wired as a new field `s.Batch *batch.Runner`, and its `FetchFunc` is a closure over `s.fetchTarget` bound to the authenticated caller. This refactor is called out explicitly for reviewer attention: flag any behavioral drift between the two callers. The per-target result is capped by the output cap before it is stored. ## Rules 1. **Guard pre-check at submit.** Run `guard.ParseURL` (the no-resolution pre-check) on every target URL before accepting. If any target fails, refuse the **whole** submit with `422` and the offending index + reason (`blocked: …`). This is the "refused up front" gate — the connect-time guard (private IP, rebinding) cannot run until fetch time and surfaces as a per-target `failed`, not a submit refusal. 2. **Body cap.** The global `MaxBytesReader` caps bodies at `maxReqBody` (64 KiB); `/v1/batch` gets its own larger limit (256 KiB) so 32 targets with questions fit. Apply it in the handler. 3. **Caps → statuses.** Map the runner's typed errors: `ErrTooManyTargets` → `422`; `ErrCallerBusy` and `ErrBusy` → `429` with a `Retry-After` when sensible. Idempotent reuse returns `202` with `reused: true`. 4. **Ownership.** `GET`/`DELETE` resolve the job by id, then require `job.caller == c.Name`; else `404` (not `403` — do not reveal that another caller's job exists). The job id is the random opaque token; `idem_key` is never returned or logged. 5. **Restricted redaction.** Pass the caller (not just its name) into the runner so each target's fetch goes through `doFetch` with the real `*auth.Caller`; the existing `logHost`/`logReason`/ `logErr` then blank host, reason, and error detail automatically. The batch handler's own log line logs `caller`, `job_id`, target count, and status — **never** a target URL or hostname, for any caller. 6. **The adapter.** The runner's `FetchFunc` is the shared `s.fetchTarget` (see the refactor section above), bound to the authenticated `*auth.Caller`. It wraps `doFetch` + `recover` + digest + context + envelope, keyed to one `batch.Target`, returning `batch.Result` (whose `Meta` carries the envelope's source/digest/pdf/context/warnings/sizes/timings). The per-target result is capped by the output cap before it is stored. 7. **Job status on the wire** uses the runner's vocabulary (`queued|running|done|partial|failed| cancelled`); unknown id → `404`. All three endpoints require a bearer key via the existing `caller` helper (`MetricsOnly` → `403`). ## Given Copy `_files/api/batch_test.go` → `internal/api/batch_test.go`. It builds a Server with `s.Batch` wired over an in-memory `dcache` (pinning the `s.Batch *batch.Runner` field and the `s.fetchTarget` adapter), then drives the endpoints. Asserts, at least: - submit → `202` with a `job_id`; resubmit of the same body → same id, `reused: true`; - a target the guard pre-check rejects (non-80/443 port) → `422` and no job created; - 33 targets → `422`; - a private-address target (passes pre-check, blocked at connect) → per-target `failed` with `error` containing `blocked`, job aggregate `partial`; - `GET` by another caller's key → `404`; `DELETE` by the owner → `200`, then `GET` → `cancelled`; ## Gate `make gate` green; `go test ./internal/api/` green under `-race`.