import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js"; import { getExportInstruments } from "../../otel/instruments.js"; import { getExporter, inferColumns, } from "../../services/export/export-service.js"; import type { ExportJobStore } from "../../store/types/types.js"; import type { DataProvider, ExportJob, ExportOptions, } from "../../types/types.js"; /** * Dependencies for {@link runDownloadExport}. Mirrors the relevant slice * of `ExportRoutesDeps` so the download handler can be unit-tested in * isolation from the rest of the route group. */ export interface DownloadDeps { /** Persistent store (preferred). When present, the handler reads * the job through `jobStore.get(id)`. */ jobStore?: ExportJobStore | undefined; /** In-memory fallback used when `jobStore` is omitted — the existing * `Map` slot shipped with the initial plugin. */ jobs: Map; /** Registered providers keyed by `resource`. */ providers: Map; } /** * Discriminated outcome of {@link runDownloadExport}. The route caller * branches on `kind` and either streams `body` on the `ok` branch or * passes the carried `ExportError` to the problem-details renderer. */ export type DownloadOutcome = | { kind: "ok"; body: string; contentType: string; filename: string; format: ExportJob["format"]; } | { kind: "not-found"; error: ExportError } | { kind: "not-ready"; error: ExportError } | { kind: "provider-not-found"; error: ExportError } | { kind: "failed"; error: ExportError }; /** * Resolve a job from either the persistent `jobStore` (preferred) or * the in-memory `jobs` map fallback. */ async function resolveJob( deps: DownloadDeps, id: string ): Promise { if (deps.jobStore !== undefined) { return deps.jobStore.get(id); } return deps.jobs.get(id) ?? null; } /** * Implementation of `GET /:id/download`. Looks up the job, validates * it is `completed`, re-fetches the source rows through the registered * `DataProvider`, re-renders the body via the existing format writer, * and returns a discriminated outcome. Every failure branch instantiates * an `ExportError` so the OpenSpec error-code registry stays exercised * from a production path. */ export async function runDownloadExport( deps: DownloadDeps, id: string ): Promise { const instruments = getExportInstruments(); const job = await resolveJob(deps, id); if (job === null) { const err = new ExportError( EXPORT_CODES.JOB_NOT_FOUND, `Export job not found: ${id}`, { context: { id, statusCode: 404 } } ); instruments.downloadFailed.add(1, { reason: "not-found" }); return { kind: "not-found", error: err }; } if (job.status !== "completed") { const err = new ExportError( EXPORT_CODES.JOB_NOT_READY, `Export job ${id} is not ready for download (status: ${job.status})`, { context: { id, status: job.status, statusCode: 409 } } ); instruments.downloadFailed.add(1, { reason: "not-ready" }); return { kind: "not-ready", error: err }; } const provider = deps.providers.get(job.resource); if (provider === undefined) { const err = new ExportError( EXPORT_CODES.PROVIDER_NOT_FOUND, `No data provider registered for resource: ${job.resource}`, { context: { id, resource: job.resource, statusCode: 404, }, } ); instruments.downloadFailed.add(1, { reason: "provider-not-found" }); return { kind: "provider-not-found", error: err }; } try { const data = await provider.fetch(job.filters); const columns = inferColumns(data); const exporter = getExporter(job.format); const options: ExportOptions = {}; const result = exporter.export(data, columns, options); const filename = job.filename ?? result.filename; instruments.downloadServed.add(1, { format: job.format }); return { kind: "ok", body: result.content, contentType: result.mimeType, filename, format: job.format, }; } catch (cause) { const detail = cause instanceof Error ? cause.message : String(cause); const err = new ExportError( EXPORT_CODES.PROVIDER_FETCH_FAILED, `Download for job ${id} failed: ${detail}`, { cause, context: { id, resource: job.resource, statusCode: 502, }, } ); instruments.downloadFailed.add(1, { reason: "failed" }); return { kind: "failed", error: err }; } } /** * Build the value of the `Content-Disposition` header for a download * response. The filename is escaped per RFC 6266 §4.1 — every `"` is * doubled, every `\` is doubled, and non-ASCII / control characters * are replaced with `_`. */ export function buildContentDisposition(filename: string): string { let out = ""; for (const ch of filename) { // `ch` is a non-empty code-point, so `codePointAt(0)` is defined. const code = ch.codePointAt(0) as number; if (code < 0x20 || code === 0x7f) { out += "_"; } else if (ch === '"' || ch === "\\") { out += `\\${ch}`; } else { out += ch; } } return `attachment; filename="${out}"`; }