first commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# `routes/download` — `GET /:id/download` handler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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 `ExportJobStore` slot when wired
|
||||
(`SurrealExportStore` / `MemoryExportJobStore`) or falls back to the in-memory
|
||||
`Map<string, ExportJob>` shipped on `ExportRoutesDeps.jobs`.
|
||||
- Validates the job is in `status: "completed"` — any other status raises
|
||||
`EXPORT.JOB_NOT_READY` (HTTP `409`).
|
||||
- Re-fetches the source rows through the registered `DataProvider`, passing `job.filters`
|
||||
exactly as supplied to the original `POST /exports` call 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. The `Content-Type` is taken from the writer's `mimeType` field.
|
||||
- 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 and
|
||||
`export.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`:
|
||||
|
||||
```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:
|
||||
|
||||
```ts
|
||||
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 `ExportError` carried on the
|
||||
`DownloadOutcome.error` field. This decouples the response shape from the discriminated
|
||||
outcome and keeps the route layer free of `try { … } catch { … }` blocks.
|
||||
- `context.statusCode` is set on every `ExportError` so 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 the `ExportJob` shape — 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 `GET` and avoids the cache-invalidation problem a body-caching
|
||||
scheme would introduce.
|
||||
- `buildContentDisposition` strips `[0x00-0x1f]` plus `0x7f` rather than emitting them
|
||||
literally — these are forbidden in HTTP header values per RFC 7230 §3.2.6.
|
||||
Reference in New Issue
Block a user