Files
skills/docs/api-stack/api/api-plugin-export/src/routes/download/download.md
2026-05-29 08:20:56 +01:00

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 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:

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 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.