169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
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<string, ExportJob>` slot shipped with the initial plugin. */
|
|
jobs: Map<string, ExportJob>;
|
|
/** Registered providers keyed by `resource`. */
|
|
providers: Map<string, DataProvider>;
|
|
}
|
|
|
|
/**
|
|
* 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<ExportJob | null> {
|
|
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<DownloadOutcome> {
|
|
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}"`;
|
|
}
|