5.3 KiB
routes/download — GET /:id/download handler
(no submodules)
Purpose
Implements the GET /:id/download route on @suaveplan/api-plugin-export. The handler
serves the rendered file body for a previously completed export job. The endpoint closes
the deferral noted in archive/2026-05-19-add-api-plugin-export/proposal.md §"Out of
scope" — the POST /exports response already returned downloadUrl: "/{jobId}/download"
for forward compatibility, and this module finally resolves that URL to a real response.
The handler is decoupled from Hono via the discriminated DownloadOutcome union: the
route registration call site in routes/exports/exports.ts calls runDownloadExport(...),
branches on outcome.kind, and either streams the body or passes the carried ExportError
to the per-package RFC 7807 renderer. Every failure branch raises one of the existing
EXPORT_* codes so the spec-conformance pin (api-plugin-export.spec-conformance.test.ts)
keeps the registry fully exercised from a production path.
Features
- Reads the job from the persistent
ExportJobStoreslot when wired (SurrealExportStore/MemoryExportJobStore) or falls back to the in-memoryMap<string, ExportJob>shipped onExportRoutesDeps.jobs. - Validates the job is in
status: "completed"— any other status raisesEXPORT.JOB_NOT_READY(HTTP409). - Re-fetches the source rows through the registered
DataProvider, passingjob.filtersexactly as supplied to the originalPOST /exportscall so the export is idempotent against the current provider state. - Re-renders through the existing
getExporter(format)writer — no new format work, no body caching. TheContent-Typeis taken from the writer'smimeTypefield. - Emits
Content-Disposition: attachment; filename="<job.filename>"with the filename sanitised per RFC 6266 §4.1 (quotes / backslashes escaped, control characters replaced with_). - OTel: increments
export.download.served{format}on success andexport.download.failed{reason}(not-found/not-ready/provider-not-found/failed) on every failure branch.
Usage: Basic
The function is consumed by the route registration in routes/exports/exports.ts:
import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download";
app.get("/:id/download", async (c) => {
const { id } = c.req.param();
const outcome = await runDownloadExport(deps, id);
if (outcome.kind === "ok") {
c.header("content-type", outcome.contentType);
c.header(
"content-disposition",
buildContentDisposition(outcome.filename)
);
return c.body(outcome.body, 200);
}
return respondProblemJson(c, outcome.error);
});
Usage: Advanced
The handler can be driven directly outside Hono — e.g. from a CLI or worker entrypoint —
because the DownloadDeps interface only requires three plain fields:
import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download";
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[jobId, job]]),
jobs: new Map(),
providers: new Map([
[
"users",
{
resource: "users",
fetch: async () => [{ id: 1 }],
},
],
]),
},
jobId
);
if (out.kind === "ok") {
await Bun.write("out.csv", out.body);
}
When jobStore is omitted, the handler reads the job from the in-memory deps.jobs map
— the existing slot wired on ExportRoutesDeps since the initial plugin ship.
API Reference
| Symbol | Kind | Notes |
|---|---|---|
DownloadDeps |
interface |
{ jobStore?; jobs; providers } |
DownloadOutcome |
discriminated union | ok / not-found / not-ready / provider-not-found / failed |
runDownloadExport(deps, id) |
async function | Returns Promise<DownloadOutcome> |
buildContentDisposition(filename) |
function | RFC 6266 §4.1-compliant string |
Implementation Notes
- The handler never throws — every failure becomes an
ExportErrorcarried on theDownloadOutcome.errorfield. This decouples the response shape from the discriminated outcome and keeps the route layer free oftry { … } catch { … }blocks. context.statusCodeis set on everyExportErrorso the per-package problem-details renderer can map directly to the right HTTP code without a separate code-to-status table.inferColumns(data)is used unconditionally on download. The original column projection is not persisted on theExportJobshape — re-inferring keeps the download deterministic against the current provider response without bloating the persisted job.- The handler is intentionally idempotent against the current provider state: a fresh
download after the source rows change reflects the change. This matches RFC 7231 §4.2.2
"idempotent methods" for
GETand avoids the cache-invalidation problem a body-caching scheme would introduce. buildContentDispositionstrips[0x00-0x1f]plus0x7frather than emitting them literally — these are forbidden in HTTP header values per RFC 7230 §3.2.6.