Initial Commit
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-export (add-export-download-handler)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-export`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `GET /:id/download` endpoint
|
||||
|
||||
`createExportRoutes(deps)` SHALL register an additional route `GET /{id}/download` that serves
|
||||
the rendered file body for a previously completed export job. The handler SHALL look up the
|
||||
job via `deps.jobStore?.get(id)` when an `ExportJobStore` is wired and otherwise via
|
||||
`deps.jobs.get(id)` against the in-memory map. When the job is missing the handler SHALL
|
||||
respond `404 application/problem+json` with `code === "EXPORT.JOB_NOT_FOUND"`. When the job
|
||||
is present but `status !== "completed"` the handler SHALL respond `409 application/problem+json`
|
||||
with `code === "EXPORT.JOB_NOT_READY"`. When the job is present but the originally registered
|
||||
`DataProvider` has been removed from `deps.providers`, the handler SHALL respond
|
||||
`404 application/problem+json` with `code === "EXPORT.PROVIDER_NOT_FOUND"`. On any throw from
|
||||
`provider.fetch(...)` or the exporter the handler SHALL respond `502 application/problem+json`
|
||||
with `code === "EXPORT.PROVIDER_FETCH_FAILED"`. On success the handler SHALL respond `200`
|
||||
with `Content-Type` set to the writer's `mimeType` (`text/csv` / `application/json` /
|
||||
`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) and
|
||||
`Content-Disposition: attachment; filename="<job.filename>"`.
|
||||
|
||||
#### Scenario: happy path streams CSV body
|
||||
|
||||
- **WHEN** a `completed` job for the `users` resource exists, the provider returns one row,
|
||||
and a `GET /{id}/download` request is issued
|
||||
- **THEN** the response is `200`, the `Content-Type` is `text/csv`, the
|
||||
`Content-Disposition` carries `attachment; filename="…"`, and the body parses as RFC 4180 CSV
|
||||
|
||||
#### Scenario: missing job returns 404 problem-details
|
||||
|
||||
- **WHEN** `GET /missing/download` is issued and no job is stored under `missing`
|
||||
- **THEN** the response is `404`, the `Content-Type` is `application/problem+json`, and the
|
||||
body carries `{ code: "EXPORT.JOB_NOT_FOUND" }`
|
||||
|
||||
#### Scenario: not-ready job returns 409 problem-details
|
||||
|
||||
- **WHEN** `GET /pending-job/download` is issued and the stored job is in `status: "processing"`
|
||||
- **THEN** the response is `409`, the `Content-Type` is `application/problem+json`, and the
|
||||
body carries `{ code: "EXPORT.JOB_NOT_READY" }`
|
||||
|
||||
#### Scenario: missing provider returns 404 problem-details
|
||||
|
||||
- **WHEN** `GET /{id}/download` is issued for a job whose `resource` has no entry in
|
||||
`deps.providers`
|
||||
- **THEN** the response is `404` with `{ code: "EXPORT.PROVIDER_NOT_FOUND" }`
|
||||
|
||||
#### Scenario: provider failure returns 502 problem-details
|
||||
|
||||
- **WHEN** `provider.fetch` rejects during a `GET /{id}/download` request
|
||||
- **THEN** the response is `502` with `{ code: "EXPORT.PROVIDER_FETCH_FAILED" }`
|
||||
|
||||
**Verified by:** `src/routes/download/download.test.ts::"streams a completed CSV job as application/csv with attachment disposition"` AND `src/routes/download/download.test.ts::"returns 404 problem-details when the job is missing"` AND `src/routes/download/download.test.ts::"returns 409 problem-details when the job is not yet completed"` AND `src/routes/download/download.test.ts::"returns 404 problem-details when the provider was deregistered"` AND `src/routes/download/download.test.ts::"returns 502 problem-details when provider.fetch rejects"` AND `src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `JOB_NOT_READY` error code
|
||||
|
||||
`EXPORT_CODES` SHALL include the additional code `JOB_NOT_READY`. The code SHALL be thrown
|
||||
from at least one production path — the `runDownloadExport` not-ready branch — and SHALL be
|
||||
pinned by `api-plugin-export.spec-conformance.test.ts`. `ExportError` SHALL narrow
|
||||
`error.exportCode` through the public type union.
|
||||
|
||||
#### Scenario: registry membership
|
||||
|
||||
- **WHEN** `Object.values(EXPORT_CODES)` is enumerated
|
||||
- **THEN** the result contains `"EXPORT.JOB_NOT_READY"`
|
||||
|
||||
#### Scenario: code narrowing
|
||||
|
||||
- **WHEN** `throw new ExportError(EXPORT_CODES.JOB_NOT_READY, "…")` is caught
|
||||
- **THEN** `error.code === "EXPORT.JOB_NOT_READY"` and `error.exportCode` narrows to the union
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"JOB_NOT_READY narrows via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-package RFC 7807 problem-details renderer
|
||||
|
||||
The package SHALL ship a per-package RFC 7807 renderer at `src/routes/problem-json/problem-json.ts`
|
||||
exporting `PROBLEM_JSON_CONTENT_TYPE = "application/problem+json"`, a `ProblemDetails` shape,
|
||||
`renderProblem(err)` (pure mapping), and `respondProblemJson(c, err)` (writes the
|
||||
`Content-Type` header and JSON body to a Hono context). `SuaveplanError` instances SHALL
|
||||
surface their `code` and read `context.statusCode` for the HTTP status; non-numeric or
|
||||
sub-400 values SHALL coerce to `500`. Plain `Error` instances SHALL render as
|
||||
`{ type: "urn:suaveplan:error:internal", status: 500 }`. Unknown throwables SHALL render as
|
||||
`{ type: "urn:suaveplan:error:internal", title: "InternalError", status: 500 }`.
|
||||
|
||||
#### Scenario: SuaveplanError maps to typed problem-details
|
||||
|
||||
- **WHEN** `renderProblem(new ExportError(EXPORT_CODES.JOB_NOT_READY, "...", { context: { statusCode: 409 } }))` runs
|
||||
- **THEN** the returned object has `status: 409`, `code: "EXPORT.JOB_NOT_READY"`, and
|
||||
`type: "urn:suaveplan:error:export.job_not_ready"`
|
||||
|
||||
#### Scenario: plain Error falls through to internal 500
|
||||
|
||||
- **WHEN** `renderProblem(new Error("boom"))` runs
|
||||
- **THEN** the returned object has `status: 500`, `title: "Error"`, and no `code` field
|
||||
|
||||
#### Scenario: unknown throwable falls through to internal 500
|
||||
|
||||
- **WHEN** `renderProblem("raw-string")` runs
|
||||
- **THEN** the returned object has `status: 500`, `title: "InternalError"`, and
|
||||
`detail: "Internal Server Error"`
|
||||
|
||||
**Verified by:** `src/routes/problem-json/problem-json.test.ts::"maps SuaveplanError to typed problem-details with status from context"` AND `src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for plain Error"` AND `src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for unknown throwables"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Download OTel instruments
|
||||
|
||||
`getExportInstruments()` SHALL additionally expose `downloadServed: Counter` (registered as
|
||||
`export.download.served`, labelled `{ format }`) and `downloadFailed: Counter` (registered as
|
||||
`export.download.failed`, labelled `{ reason }`). The download handler SHALL emit exactly one
|
||||
counter increment per request — `downloadServed{format}` on the `ok` branch and
|
||||
`downloadFailed{reason}` on every failure branch with `reason ∈ { "not-found", "not-ready",
|
||||
"provider-not-found", "failed" }`.
|
||||
|
||||
#### Scenario: served counter increments on happy path
|
||||
|
||||
- **WHEN** a `200` download response is produced for a `csv` job
|
||||
- **THEN** an in-memory `MeterProvider`'s exporter sees one increment of
|
||||
`export.download.served{format=csv}`
|
||||
|
||||
#### Scenario: failed counter increments on not-ready
|
||||
|
||||
- **WHEN** a `409` download response is produced because the job is `processing`
|
||||
- **THEN** the exporter sees one increment of `export.download.failed{reason=not-ready}`
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"emits export.download.served on a successful CSV download"` AND `src/otel/instruments.test.ts::"emits export.download.failed{reason=not-ready} when the job is not completed"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin wires `exportJobStore` slot into routes
|
||||
|
||||
`createExportPlugin(...)` `onReady` SHALL look up `ctx.getService("exportJobStore")`. When
|
||||
the slot resolves to an `ExportJobStore`, the plugin SHALL pass it through to
|
||||
`createExportRoutes({ …, jobStore })` so the download handler reads job state from the
|
||||
persistent backend rather than the in-memory `Map`. When the slot is undefined, the route
|
||||
deps SHALL omit `jobStore` and the download handler SHALL fall back to the inline `deps.jobs`
|
||||
adapter — preserving the existing in-memory route surface for host apps that opt out of
|
||||
persistence.
|
||||
|
||||
#### Scenario: wired SurrealExportStore is honoured
|
||||
|
||||
- **WHEN** an app registers `MemoryExportJobStore` (interface-compatible with `SurrealExportStore`)
|
||||
under `ctx.registerService("exportJobStore", store)` and the plugin's `onReady` runs
|
||||
- **THEN** the download handler resolves the job through `store.get(id)`
|
||||
|
||||
#### Scenario: absent slot keeps in-memory fallback
|
||||
|
||||
- **WHEN** no `exportJobStore` slot is registered
|
||||
- **THEN** the download handler still resolves jobs through `deps.jobs.get(id)`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"download route uses exportJobStore service when wired"` AND `src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"`
|
||||
Reference in New Issue
Block a user