Initial Commit
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user