diff --git a/Makefile b/Makefile index c51fee26..bcf0b6f4 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,10 @@ db-process-fixtures: fe-build: cd web && npm run build +.PHONY: openapi-generate +openapi-generate: + cd web && npm run openapi:generate + # Formatting, linting, and type checking. .PHONY: py-check py-check: diff --git a/developer-docs/run-archives.md b/developer-docs/run-archives.md index 3e8b0007..f75c650f 100644 --- a/developer-docs/run-archives.md +++ b/developer-docs/run-archives.md @@ -4,7 +4,7 @@ The "Download all" actions on the run detail page and the runs table deliver eve Each archive can mix files from the raw bucket and the processed bucket in a single zip. This matters for instruments that produce processed artifacts via Lambda preprocessing (SpectraMax raw `.xls` → processed CSV; Hina `.nd2` → processed JPG; Azure 600 Gel Doc `.tif` → processed PNG): the run's file rows reference both buckets, and "Download all" zips them together. -This page covers the end-to-end flow, the cache + dedup model, and the on-call runbook. For the Lambda invocation contract, see [Lambda → Function URL (archive build)](lambda.md#function-url-archive-build). For the HTTP endpoints, see [REST API → Archive jobs](https://datahub.arcadiascience.com/docs/api-reference#archive-jobs). +This page covers the end-to-end flow, the cache + dedup model, and the on-call runbook. For the Lambda invocation contract, see [Lambda → Function URL (archive build)](lambda.md#function-url-archive-build). For the HTTP endpoints, see the [generated API docs](https://datahub.arcadiascience.com/docs/api) (Archive tag). ## Flow diff --git a/web/.gitignore b/web/.gitignore index c5f01335..83c3198c 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -36,3 +36,7 @@ pnpm-debug.log* *.tsbuildinfo next-env.d.ts .vercel + +# Local OpenAPI dump from `npm run openapi:generate` (not committed; +# production serves the schema from GET /api/v1/openapi.json). +/openapi.json diff --git a/web/app/api/v1/archive-jobs/[id]/route.ts b/web/app/api/v1/archive-jobs/[id]/route.ts index 19d52539..c02d619d 100644 --- a/web/app/api/v1/archive-jobs/[id]/route.ts +++ b/web/app/api/v1/archive-jobs/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { patchArchiveJobBody, readJsonBody } from "@/lib/api/openapi"; import { isValidUUID } from "@/lib/api/validators"; import { db } from "@/lib/db"; import { archiveJobs } from "@/lib/db/schema"; @@ -31,14 +32,6 @@ interface RouteContext { const TERMINAL_STATUSES = new Set(["ready", "failed"]); -interface PatchBody { - archive_bucket?: unknown; - archive_key?: unknown; - error_message?: unknown; - size_bytes?: unknown; - status?: unknown; -} - export async function PATCH(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "archive-jobs:write"); if (authResult instanceof Response) { @@ -50,31 +43,13 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return apiError(400, VALIDATION_ERROR, "Invalid job ID format"); } - let body: PatchBody; - try { - body = (await request.json()) as PatchBody; - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, patchArchiveJobBody); + if (body instanceof Response) { + return body; } + const status = body.status; - if ( - typeof body.status !== "string" || - !["pending", "building", "ready", "failed"].includes(body.status) - ) { - return apiError( - 400, - VALIDATION_ERROR, - "status must be one of pending|building|ready|failed" - ); - } - - const status = body.status as "pending" | "building" | "ready" | "failed"; - - if ( - status === "ready" && - (typeof body.archive_bucket !== "string" || - typeof body.archive_key !== "string") - ) { + if (status === "ready" && !(body.archive_bucket && body.archive_key)) { return apiError( 400, VALIDATION_ERROR, @@ -83,16 +58,16 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } const update: Partial = { status }; - if (typeof body.archive_bucket === "string") { + if (body.archive_bucket !== undefined) { update.archiveBucket = body.archive_bucket; } - if (typeof body.archive_key === "string") { + if (body.archive_key !== undefined) { update.archiveKey = body.archive_key; } - if (typeof body.size_bytes === "number") { + if (body.size_bytes !== undefined) { update.sizeBytes = body.size_bytes; } - if (typeof body.error_message === "string") { + if (body.error_message !== undefined) { update.errorMessage = body.error_message; } if (TERMINAL_STATUSES.has(status)) { diff --git a/web/app/api/v1/files/[fileId]/route.ts b/web/app/api/v1/files/[fileId]/route.ts index 0fcfe697..76063f60 100644 --- a/web/app/api/v1/files/[fileId]/route.ts +++ b/web/app/api/v1/files/[fileId]/route.ts @@ -9,6 +9,7 @@ import { VALIDATION_ERROR, } from "@/lib/api/errors"; import { dismissFile } from "@/lib/api/files"; +import { patchFileBody, readJsonBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; @@ -84,18 +85,16 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { ); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, patchFileBody); + if (body instanceof Response) { + return body; } const updates: Record = {}; const now = new Date(); // Status transition validation. - if ("status" in body && typeof body.status === "string") { + if (body.status !== undefined) { const allowed = VALID_TRANSITIONS[file.status]; if (!allowed?.includes(body.status)) { return apiError( @@ -130,31 +129,26 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } // S3 info — set when transitioning to "uploaded" (watcher path). - if (typeof body.s3_bucket === "string") { + if (body.s3_bucket !== undefined) { updates.s3Bucket = body.s3_bucket; } - if (typeof body.s3_key === "string") { + if (body.s3_key !== undefined) { updates.s3Key = body.s3_key; } - if (typeof body.content_type === "string") { + if (body.content_type !== undefined) { updates.contentType = body.content_type; } - if (typeof body.size_bytes === "number") { + if (body.size_bytes !== undefined) { updates.sizeBytes = body.size_bytes; } // Metadata — flat JSON object set by the Lambda after processing. - if ( - "metadata" in body && - typeof body.metadata === "object" && - body.metadata !== null && - !Array.isArray(body.metadata) - ) { + if (body.metadata !== undefined) { updates.metadata = body.metadata; } // Error message — set when status transitions to "failed". - if (typeof body.error_message === "string") { + if (body.error_message !== undefined) { updates.errorMessage = body.error_message; } diff --git a/web/app/api/v1/instruments/[instrumentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/route.ts index a9b0ccd0..4b377467 100644 --- a/web/app/api/v1/instruments/[instrumentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/route.ts @@ -2,14 +2,10 @@ import { and, count, eq, isNull } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize, requireAdminForSession } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { patchInstrumentBody, readJsonBody } from "@/lib/api/openapi"; import { deregisterInstrumentWatchers } from "@/lib/api/watchers"; import { db } from "@/lib/db"; -import { - instrumentRuns, - instruments, - VALID_INSTRUMENT_TYPES, - watchers, -} from "@/lib/db/schema"; +import { instrumentRuns, instruments, watchers } from "@/lib/db/schema"; export async function GET( request: NextRequest, @@ -63,12 +59,6 @@ export async function GET( }); } -const ALLOWED_PATCH_FIELDS = new Set([ - "status", - "display_name", - "instrument_type", -]); - export async function PATCH( request: NextRequest, { params }: { params: Promise<{ instrumentId: string }> } @@ -89,21 +79,9 @@ export async function PATCH( const { instrumentId } = await params; - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - const unknownKeys = Object.keys(body).filter( - (k) => !ALLOWED_PATCH_FIELDS.has(k) - ); - if (unknownKeys.length > 0) { - return apiError(400, VALIDATION_ERROR, "Unknown fields", { - unknown_fields: unknownKeys, - allowed_fields: [...ALLOWED_PATCH_FIELDS], - }); + const body = await readJsonBody(request, patchInstrumentBody); + if (body instanceof Response) { + return body; } const [existing] = await db @@ -116,33 +94,8 @@ export async function PATCH( return apiError(404, NOT_FOUND, `Instrument '${instrumentId}' not found`); } - const VALID_INSTRUMENT_STATUSES = ["pending", "active", "inactive"]; - if ( - "status" in body && - !VALID_INSTRUMENT_STATUSES.includes(body.status as string) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid status — must be one of: ${VALID_INSTRUMENT_STATUSES.join(", ")}` - ); - } - - if ( - "instrument_type" in body && - !(VALID_INSTRUMENT_TYPES as readonly string[]).includes( - body.instrument_type as string - ) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid instrument_type — must be one of: ${VALID_INSTRUMENT_TYPES.join(", ")}` - ); - } - const updates: Record = {}; - if ("status" in body) { + if (body.status !== undefined) { updates.status = body.status; // Keep the retirement audit fields in lockstep with the status: only an // `inactive` instrument has a retirer. @@ -154,10 +107,10 @@ export async function PATCH( updates.retiredBy = null; } } - if ("display_name" in body) { + if (body.display_name !== undefined) { updates.displayName = body.display_name; } - if ("instrument_type" in body) { + if (body.instrument_type !== undefined) { updates.instrumentType = body.instrument_type; } diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts index 5c62e2e4..33aedbb7 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts @@ -8,6 +8,7 @@ import { VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { commentBody, readJsonBody } from "@/lib/api/openapi"; import { getCommentForAuthorCheck, softDeleteComment, @@ -103,15 +104,9 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return pre.response; } - let payload: Record; - try { - payload = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if (typeof payload.body !== "string") { - return apiError(400, VALIDATION_ERROR, "body must be a string"); + const payload = await readJsonBody(request, commentBody); + if (payload instanceof Response) { + return payload; } const validated = validateCommentBody(payload.body); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts index d2ca91da..0939af08 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts @@ -7,6 +7,7 @@ import { VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { commentBody, readJsonBody } from "@/lib/api/openapi"; import { createCommentAndNotify, listCommentsForRun, @@ -72,15 +73,9 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot comment on a soft-deleted run"); } - let payload: Record; - try { - payload = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if (typeof payload.body !== "string") { - return apiError(400, VALIDATION_ERROR, "body must be a string"); + const payload = await readJsonBody(request, commentBody); + if (payload instanceof Response) { + return payload; } const validated = validateCommentBody(payload.body); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts index 79b7872e..12054c45 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts @@ -1,13 +1,9 @@ import { and, eq, isNull } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; -import { - apiError, - CONFLICT, - NOT_FOUND, - VALIDATION_ERROR, -} from "@/lib/api/errors"; +import { apiError, CONFLICT, NOT_FOUND } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { createFileBody, readJsonBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; @@ -52,33 +48,17 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot add files to a soft-deleted run"); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, createFileBody); + if (body instanceof Response) { + return body; } - const s3Bucket = - typeof body.s3_bucket === "string" ? body.s3_bucket.trim() : ""; - const s3Key = typeof body.s3_key === "string" ? body.s3_key.trim() : ""; - const filename = - typeof body.filename === "string" ? body.filename.trim() : ""; - - if (!(s3Bucket && s3Key && filename)) { - return apiError( - 400, - VALIDATION_ERROR, - "s3_bucket, s3_key, and filename are required" - ); - } - - const contentType = - typeof body.content_type === "string" ? body.content_type : null; - const sizeBytes = - typeof body.size_bytes === "number" ? body.size_bytes : null; - const category = - body.category === "processed" ? ("processed" as const) : ("raw" as const); + const s3Bucket = body.s3_bucket; + const s3Key = body.s3_key; + const filename = body.filename; + const contentType = body.content_type ?? null; + const sizeBytes = body.size_bytes ?? null; + const category = body.category ?? "raw"; const now = new Date(); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts index 1a8a1cfa..86c8a769 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts @@ -6,9 +6,9 @@ import { CONFLICT, INTERNAL_ERROR, NOT_FOUND, - VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; +import { readJsonBody, requestUploadUrlBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; import { getPresignedUploadUrl, getS3RawDataBucket } from "@/lib/s3"; @@ -56,27 +56,17 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot upload files to a soft-deleted run"); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - const filename = - typeof body.filename === "string" ? body.filename.trim() : ""; - if (!filename) { - return apiError(400, VALIDATION_ERROR, "filename is required"); + const body = await readJsonBody(request, requestUploadUrlBody); + if (body instanceof Response) { + return body; } - const contentType = - typeof body.content_type === "string" ? body.content_type : undefined; - const sizeBytes = - typeof body.size_bytes === "number" ? body.size_bytes : undefined; - const fileCreatedAt = - typeof body.file_created_at === "string" - ? new Date(body.file_created_at) - : null; + const filename = body.filename; + const contentType = body.content_type; + const sizeBytes = body.size_bytes; + const fileCreatedAt = body.file_created_at + ? new Date(body.file_created_at) + : null; // Look up existing file record by run + filename. The file may have been // created by report_run with a full relative_path (e.g. "EXP-001/data.csv"), diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts index c7315204..8c2b65bf 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts @@ -1,10 +1,7 @@ import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; -import { - apiError, - apiErrorFromResult, - VALIDATION_ERROR, -} from "@/lib/api/errors"; +import { apiErrorFromResult } from "@/lib/api/errors"; +import { readJsonBody, requestUploadBody } from "@/lib/api/openapi"; import { requestRunUploads } from "@/lib/api/run-uploads"; interface RouteContext { @@ -26,16 +23,14 @@ export async function POST(request: NextRequest, { params }: RouteContext) { const { instrumentId, runId } = await params; - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, requestUploadBody); + if (body instanceof Response) { + return body; } // Pass raw ids through; `requestRunUploads` fails closed on non-integers. // Filtering here would silently drop bad entries and queue the rest. - const fileIds: unknown[] = Array.isArray(body.file_ids) ? body.file_ids : []; + const fileIds = body.file_ids; const result = await requestRunUploads({ instrumentId, runId, fileIds }); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts index bacbe4e7..e31b17c6 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts @@ -6,12 +6,12 @@ import { apiErrorFromResult, CONFLICT, NOT_FOUND, - VALIDATION_ERROR, } from "@/lib/api/errors"; import { lookupRunByNaturalKey, parseAcquiredAt, } from "@/lib/api/instrument-runs"; +import { patchRunBody, readJsonBody } from "@/lib/api/openapi"; import { softDeleteRun } from "@/lib/api/run-lifecycle"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; @@ -126,25 +126,15 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { return apiError(409, CONFLICT, "Cannot update a soft-deleted run"); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, patchRunBody); + if (body instanceof Response) { + return body; } // Metadata is a full replacement (not a deep merge). The Lambda writes the // complete metadata object after processing. Patch-by-key would require a // read-then-merge cycle that adds complexity with no benefit at current scale. - if ("metadata" in body) { - if ( - typeof body.metadata !== "object" || - body.metadata === null || - Array.isArray(body.metadata) - ) { - return apiError(400, VALIDATION_ERROR, "metadata must be a JSON object"); - } - + if (body.metadata !== undefined) { await db .update(instrumentRuns) .set({ metadata: body.metadata }) @@ -171,9 +161,7 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } // Handle detected_files upsert (watcher reporting new files for a run). - const detectedFiles = Array.isArray(body.detected_files) - ? body.detected_files - : []; + const detectedFiles = body.detected_files ?? []; if (detectedFiles.length > 0) { const now = new Date(); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts index 594f54ef..2fd93ce7 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts @@ -4,6 +4,7 @@ import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { buildRunListQuery, parseAcquiredAt } from "@/lib/api/instrument-runs"; import { notifyRunCreated } from "@/lib/api/notifications"; +import { createRunBody, readJsonBody } from "@/lib/api/openapi"; import { parseRunMetadataFilters } from "@/lib/api/run-metadata-filters"; import { parseIntParam, parseRunStatusParam } from "@/lib/api/validators"; import { db } from "@/lib/db"; @@ -43,29 +44,14 @@ export async function POST(request: NextRequest, { params }: RouteContext) { return apiError(404, NOT_FOUND, `Instrument '${instrumentId}' not found`); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, createRunBody); + if (body instanceof Response) { + return body; } - - const runId = typeof body.run_id === "string" ? body.run_id.trim() : ""; - if (!runId) { - return apiError(400, VALIDATION_ERROR, "run_id is required"); - } - + const runId = body.run_id; const source = body.source; - if (source !== "lambda" && source !== "watcher") { - return apiError( - 400, - VALIDATION_ERROR, - 'source must be "lambda" or "watcher"' - ); - } - const watcherId = - typeof body.watcher_id === "string" ? body.watcher_id : null; + const watcherId = body.watcher_id ?? null; // Parse the watcher-supplied acquired_at, falling back to the floor of // any detected_files[].file_created_at when omitted. This is defense-in- @@ -151,9 +137,7 @@ export async function POST(request: NextRequest, { params }: RouteContext) { // Watcher payloads may include detected files to bulk-insert alongside // the run. Duplicates (same run + relative_path) are silently skipped. - const detectedFiles = Array.isArray(body.detected_files) - ? body.detected_files - : []; + const detectedFiles = body.detected_files ?? []; if (detectedFiles.length > 0) { const now = new Date(); diff --git a/web/app/api/v1/instruments/route.ts b/web/app/api/v1/instruments/route.ts index fa21ea7b..4850182f 100644 --- a/web/app/api/v1/instruments/route.ts +++ b/web/app/api/v1/instruments/route.ts @@ -1,14 +1,10 @@ import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; -import { apiError, CONFLICT, VALIDATION_ERROR } from "@/lib/api/errors"; -import { isValidKebabCase } from "@/lib/api/validators"; +import { apiError, CONFLICT } from "@/lib/api/errors"; +import { createInstrumentBody, readJsonBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; -import { - type InstrumentType, - instruments, - VALID_INSTRUMENT_TYPES, -} from "@/lib/db/schema"; +import { instruments } from "@/lib/db/schema"; export async function GET(request: NextRequest) { const authResult = await authorize(request, "instruments:read"); @@ -34,24 +30,11 @@ export async function POST(request: NextRequest) { return authResult; } - let body: { id?: string; display_name?: string; instrument_type?: string }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!id) { - return apiError(400, VALIDATION_ERROR, "id is required"); - } - if (!isValidKebabCase(id)) { - return apiError( - 400, - VALIDATION_ERROR, - "id must be lowercase kebab-case (e.g., my-instrument)" - ); + const body = await readJsonBody(request, createInstrumentBody); + if (body instanceof Response) { + return body; } + const id = body.id; const existing = await db .select({ id: instruments.id }) @@ -65,19 +48,15 @@ export async function POST(request: NextRequest) { // Default display name is derived from the kebab-case ID: // "spectramax-id3-plate-reader" → "Spectramax Id3 Plate Reader" - const displayName = - typeof body.display_name === "string" && body.display_name.trim() - ? body.display_name.trim() - : id - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" "); + // `display_name` is trimmed by the schema; empty string means "use default". + const displayName = body.display_name + ? body.display_name + : id + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); - const instrumentType: InstrumentType = - typeof body.instrument_type === "string" && - (VALID_INSTRUMENT_TYPES as readonly string[]).includes(body.instrument_type) - ? (body.instrument_type as InstrumentType) - : "generic"; + const instrumentType = body.instrument_type ?? "generic"; const [created] = await db .insert(instruments) diff --git a/web/app/api/v1/openapi.json/route.ts b/web/app/api/v1/openapi.json/route.ts new file mode 100644 index 00000000..00b78a5b --- /dev/null +++ b/web/app/api/v1/openapi.json/route.ts @@ -0,0 +1,13 @@ +import { buildOpenApiDocument } from "@/lib/api/openapi"; + +// Schema is pure (no DB/env); bake it into the build so every deploy +// serves a fixed document without recomputing on each request. +export const dynamic = "force-static"; + +export function GET() { + return Response.json(buildOpenApiDocument(), { + headers: { + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/web/app/api/v1/watchers/[watcherId]/config/route.ts b/web/app/api/v1/watchers/[watcherId]/config/route.ts index 70219fde..eb94a931 100644 --- a/web/app/api/v1/watchers/[watcherId]/config/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/config/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { readJsonBody, watcherConfigBody } from "@/lib/api/openapi"; import { isValidUUID } from "@/lib/api/validators"; import { extractWatchDirectory, @@ -30,22 +31,9 @@ export async function PUT( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - let body: { config_checksum?: string; config_yaml?: string }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if ( - typeof body.config_checksum !== "string" || - typeof body.config_yaml !== "string" - ) { - return apiError( - 400, - VALIDATION_ERROR, - "config_checksum and config_yaml are required" - ); + const body = await readJsonBody(request, watcherConfigBody); + if (body instanceof Response) { + return body; } const previousWatchDir = extractWatchDirectory(watcher.configYaml); diff --git a/web/app/api/v1/watchers/[watcherId]/events/route.ts b/web/app/api/v1/watchers/[watcherId]/events/route.ts index 55e111dd..7a47e800 100644 --- a/web/app/api/v1/watchers/[watcherId]/events/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/events/route.ts @@ -2,6 +2,7 @@ import { and, desc, eq, gte, inArray } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { readJsonBody, watcherEventBody } from "@/lib/api/openapi"; import { isValidUUID, parseDateParam, @@ -36,49 +37,14 @@ export async function POST( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - let body: { events?: unknown[] }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); - } - - if (!Array.isArray(body.events) || body.events.length === 0) { - return apiError( - 400, - VALIDATION_ERROR, - "events array is required and must not be empty" - ); - } - - if (body.events.length > 100) { - return apiError(400, VALIDATION_ERROR, "Maximum 100 events per request"); - } - - interface EventInput { - details?: Record; - event_type: string; - message: string; - timestamp: string; + const body = await readJsonBody(request, watcherEventBody); + if (body instanceof Response) { + return body; } const values: (typeof watcherEvents.$inferInsert)[] = []; for (let i = 0; i < body.events.length; i++) { - const evt = body.events[i] as EventInput; - if (!(evt.event_type && evt.timestamp && evt.message)) { - return apiError( - 400, - VALIDATION_ERROR, - `Event at index ${i} requires event_type, timestamp, and message` - ); - } - if (!VALID_EVENT_TYPES.has(evt.event_type)) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid event_type '${evt.event_type}' at index ${i}` - ); - } + const evt = body.events[i]; const ts = new Date(evt.timestamp); if (Number.isNaN(ts.getTime())) { return apiError(400, VALIDATION_ERROR, `Invalid timestamp at index ${i}`); diff --git a/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts b/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts index 5ea7cbb3..027bfa3c 100644 --- a/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts @@ -7,6 +7,7 @@ import { UPGRADE_REQUIRED, VALIDATION_ERROR, } from "@/lib/api/errors"; +import { heartbeatBody, readJsonBody } from "@/lib/api/openapi"; import { isValidUUID } from "@/lib/api/validators"; import { isBelowFloor } from "@/lib/api/watcher-versions"; import { @@ -39,33 +40,13 @@ export async function POST( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - let body: Record; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, heartbeatBody); + if (body instanceof Response) { + return body; } + const status = body.status; - const VALID_WATCHER_STATUSES = ["registered", "watching", "stopped"] as const; - const status = body.status as string | undefined; - if (!status) { - return apiError(400, VALIDATION_ERROR, "status is required"); - } - if ( - !VALID_WATCHER_STATUSES.includes( - status as (typeof VALID_WATCHER_STATUSES)[number] - ) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `Invalid status '${status}' — must be one of: ${VALID_WATCHER_STATUSES.join(", ")}` - ); - } - - const timestamp = body.timestamp - ? new Date(body.timestamp as string) - : new Date(); + const timestamp = body.timestamp ? new Date(body.timestamp) : new Date(); if (Number.isNaN(timestamp.getTime())) { return apiError(400, VALIDATION_ERROR, "Invalid timestamp"); } @@ -78,10 +59,7 @@ export async function POST( // heartbeat after a manual upgrade is judged on the new version, not // the stale stored one. The singleton constraint on // `watcher_release_config` keeps this a constant-cost select. - const reportedVersion = - typeof body.watcher_version === "string" && body.watcher_version - ? body.watcher_version - : null; + const reportedVersion = body.watcher_version || null; const [releaseRow] = await db .select({ minSupportedVersion: watcherReleaseConfig.minSupportedVersion, @@ -119,19 +97,17 @@ export async function POST( watcherId, timestamp, status, - uploadMode: (body.upload_mode as "auto" | "manual") ?? null, - filesUploadedSinceLast: - (body.files_uploaded_since_last_heartbeat as number) ?? 0, - runsReportedSinceLast: - (body.runs_reported_since_last_heartbeat as number) ?? 0, - errorsSinceLast: (body.errors_since_last_heartbeat as number) ?? 0, - uptimeSeconds: (body.uptime_seconds as number) ?? null, + uploadMode: body.upload_mode ?? null, + filesUploadedSinceLast: body.files_uploaded_since_last_heartbeat ?? 0, + runsReportedSinceLast: body.runs_reported_since_last_heartbeat ?? 0, + errorsSinceLast: body.errors_since_last_heartbeat ?? 0, + uptimeSeconds: body.uptime_seconds ?? null, }), db .update(watchers) .set({ lastHeartbeatAt: new Date(), - status: status as "registered" | "watching" | "stopped", + status, // Older watchers (pre-version-reporting) won't include this field — // fall back to the existing value rather than nulling it out so the // dashboard keeps the last-known version visible. diff --git a/web/app/api/v1/watchers/register/route.ts b/web/app/api/v1/watchers/register/route.ts index cb8bfbc8..d0e8d906 100644 --- a/web/app/api/v1/watchers/register/route.ts +++ b/web/app/api/v1/watchers/register/route.ts @@ -7,6 +7,7 @@ import { NOT_FOUND, VALIDATION_ERROR, } from "@/lib/api/errors"; +import { readJsonBody, registerWatcherBody } from "@/lib/api/openapi"; import { db } from "@/lib/db"; import { instruments, watchers } from "@/lib/db/schema"; @@ -16,22 +17,12 @@ export async function POST(request: NextRequest) { return authResult; } - let body: { - instrument_id?: string; - hostname?: string; - os_info?: string; - }; - try { - body = await request.json(); - } catch { - return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + const body = await readJsonBody(request, registerWatcherBody); + if (body instanceof Response) { + return body; } - const instrumentId = - typeof body.instrument_id === "string" ? body.instrument_id.trim() : ""; - if (!instrumentId) { - return apiError(400, VALIDATION_ERROR, "instrument_id is required"); - } + const instrumentId = body.instrument_id; const [instrument] = await db .select({ id: instruments.id, status: instruments.status }) diff --git a/web/lib/api/openapi/document.ts b/web/lib/api/openapi/document.ts new file mode 100644 index 00000000..573908bc --- /dev/null +++ b/web/lib/api/openapi/document.ts @@ -0,0 +1,38 @@ +import { OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi"; +import { registry } from "./registry"; +import "./paths/meta"; +import "./paths/instruments"; +import "./paths/runs"; +import "./paths/files"; +import "./paths/watchers"; +import "./paths/archive"; + +const DOCUMENT_TAGS = [ + { name: "Meta", description: "Schema discovery" }, + { name: "Instruments", description: "Instrument catalog" }, + { + name: "Runs", + description: "Instrument runs, comments, attributions, uploads, and search", + }, + { name: "Files", description: "Run files, downloads, and reprocessing" }, + { name: "Watchers", description: "Watcher registration and telemetry" }, + { name: "Archive", description: "Run archive builds" }, +] as const; + +export function buildOpenApiDocument() { + const generator = new OpenApiGeneratorV31(registry.definitions); + return generator.generateDocument({ + openapi: "3.1.0", + info: { + title: "Data Hub API", + version: "1.0.0", + description: + "Integrator REST API for Data Hub. Authenticate with a personal access token (`Authorization: Bearer dhub_…`). Session cookies also work for browser callers.", + }, + servers: [ + { url: "/api/v1", description: "Relative to your Data Hub host" }, + ], + security: [{ bearerAuth: [] }], + tags: [...DOCUMENT_TAGS], + }); +} diff --git a/web/lib/api/openapi/index.ts b/web/lib/api/openapi/index.ts new file mode 100644 index 00000000..a6353f98 --- /dev/null +++ b/web/lib/api/openapi/index.ts @@ -0,0 +1,59 @@ +// Routes use this single public boundary for schemas and document generation. +// Response schemas are exported here too so tests can assert real responses +// against them (drift detection) while going through this module, which loads +// the registry — and thus the Zod OpenAPI extension — before any schema. +// biome-ignore lint/performance/noBarrelFile: This is the package entry point. +export { buildOpenApiDocument } from "./document"; +export { readJsonBody } from "./parse"; +export { archiveJobDetail, patchArchiveJobBody } from "./schemas/archive"; +export { + createFileBody, + fileDetail, + fileDismissed, + fileReprocessed, + patchFileBody, +} from "./schemas/files"; +export { + createInstrumentBody, + instrumentDetail, + instrumentListItem, + patchInstrumentBody, +} from "./schemas/instruments"; +export { + attributionsResponse, + commentBody, + commentDeleted, + commentsListResponse, + createRunBody, + patchRunBody, + requestUploadBody, + requestUploadUrlBody, + runComment, + runCreated, + runDeleted, + runDetail, + runListResponse, + runReprocessed, + runRestored, + runUpdated, + uploadAllQueued, + uploadQueued, + uploadUrlResponse, +} from "./schemas/runs"; +export { + heartbeatBody, + registerWatcherBody, + watcherChecksumResponse, + watcherConfigBody, + watcherDeleted, + watcherDetail, + watcherEventBody, + watcherEventCreated, + watcherEventsListResponse, + watcherHeartbeatAck, + watcherHeartbeatsListResponse, + watcherListResponse, + watcherRegistered, + watcherUpdateCheckResponse, + watcherUploadQueueResponse, +} from "./schemas/watchers"; diff --git a/web/lib/api/openapi/parse.ts b/web/lib/api/openapi/parse.ts new file mode 100644 index 00000000..d73ec201 --- /dev/null +++ b/web/lib/api/openapi/parse.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; +import { apiError, VALIDATION_ERROR } from "@/lib/api/errors"; + +export async function readJsonBody( + request: Request, + schema: z.ZodType +): Promise { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + } + const parsed = schema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const message = issue + ? `${issue.path.length ? `${issue.path.join(".")}: ` : ""}${issue.message}` + : "Invalid request body"; + return apiError(400, VALIDATION_ERROR, message, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} diff --git a/web/lib/api/openapi/paths/archive.ts b/web/lib/api/openapi/paths/archive.ts new file mode 100644 index 00000000..52e68df4 --- /dev/null +++ b/web/lib/api/openapi/paths/archive.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { + archiveJobIdParam, + bearerSecurity, + errorResponses, + jsonResponse, + registry, +} from "../registry"; +import { archiveJobDetail, patchArchiveJobBody } from "../schemas/archive"; + +registry.registerPath({ + method: "patch", + path: "/archive-jobs/{id}", + operationId: "updateArchiveJob", + summary: "Update an archive job", + description: "Requires scope `archive-jobs:write`.", + tags: ["Archive"], + security: bearerSecurity, + request: { + params: z.object({ id: archiveJobIdParam }), + body: { content: { "application/json": { schema: patchArchiveJobBody } } }, + }, + responses: { + 200: jsonResponse("Updated archive job.", archiveJobDetail), + ...errorResponses(), + }, +}); diff --git a/web/lib/api/openapi/paths/files.ts b/web/lib/api/openapi/paths/files.ts new file mode 100644 index 00000000..5c5b03ef --- /dev/null +++ b/web/lib/api/openapi/paths/files.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { + bearerSecurity, + errorResponses, + fileIdParam, + instrumentIdParam, + jsonResponse, + registry, + runIdParam, +} from "../registry"; +import { + createFileBody, + fileDetail, + fileDismissed, + fileReprocessed, + patchFileBody, +} from "../schemas/files"; + +const fileParams = z.object({ fileId: fileIdParam }); +const runParams = z.object({ + instrumentId: instrumentIdParam, + runId: runIdParam, +}); +const body = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, +}); +const responses = (description: string, schema: z.ZodType = z.unknown()) => ({ + 200: jsonResponse(description, schema), + ...errorResponses(), +}); + +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/files", + operationId: "createRunFile", + summary: "Create a run file record", + description: "Requires scope `files:create`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: runParams, body: body(createFileBody) }, + responses: { + 200: jsonResponse("Existing file.", fileDetail), + 201: jsonResponse("Created file.", fileDetail), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "patch", + path: "/files/{fileId}", + operationId: "updateFile", + summary: "Update a file", + description: "Requires scope `files:update`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams, body: body(patchFileBody) }, + responses: responses("Updated file.", fileDetail), +}); +registry.registerPath({ + method: "delete", + path: "/files/{fileId}", + operationId: "dismissFile", + summary: "Dismiss a file", + description: "Requires scope `files:delete`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams }, + responses: responses("Dismissal result.", fileDismissed), +}); +registry.registerPath({ + method: "get", + path: "/files/{fileId}/download", + operationId: "downloadFile", + summary: "Redirect to file download", + description: "Requires scope `files:read`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams }, + responses: { + 302: { description: "Redirect to a presigned download URL." }, + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "post", + path: "/files/{fileId}/reprocess", + operationId: "reprocessFile", + summary: "Reprocess a file", + description: "Requires scope `files:reprocess`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: fileParams }, + responses: responses("Reprocessing result.", fileReprocessed), +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs/{runId}/download-archive", + operationId: "downloadRunArchive", + summary: "Download a run archive", + description: "Requires scope `files:read`.", + tags: ["Files"], + security: bearerSecurity, + request: { params: runParams }, + responses: { + 302: { description: "Redirect to a presigned archive URL." }, + ...errorResponses(), + }, +}); diff --git a/web/lib/api/openapi/paths/instruments.ts b/web/lib/api/openapi/paths/instruments.ts new file mode 100644 index 00000000..3422f0db --- /dev/null +++ b/web/lib/api/openapi/paths/instruments.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { + bearerSecurity, + errorResponses, + instrumentIdParam, + jsonResponse, + registry, +} from "../registry"; +import { + createInstrumentBody, + instrumentDetail, + instrumentListItem, + patchInstrumentBody, +} from "../schemas/instruments"; + +const instrumentParams = z.object({ instrumentId: instrumentIdParam }); + +registry.registerPath({ + method: "get", + path: "/instruments", + operationId: "listInstruments", + summary: "List instruments", + description: "Requires scope `instruments:read`.", + tags: ["Instruments"], + security: bearerSecurity, + responses: { + 200: jsonResponse("Instruments.", z.array(instrumentListItem)), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "post", + path: "/instruments", + operationId: "createInstrument", + summary: "Create an instrument", + description: "Requires scope `instruments:write`.", + tags: ["Instruments"], + security: bearerSecurity, + request: { + body: { content: { "application/json": { schema: createInstrumentBody } } }, + }, + responses: { + 201: jsonResponse("Created instrument.", instrumentDetail), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}", + operationId: "getInstrument", + summary: "Get an instrument", + description: "Requires scope `instruments:read`.", + tags: ["Instruments"], + security: bearerSecurity, + request: { params: instrumentParams }, + responses: { + 200: jsonResponse("Instrument.", instrumentDetail), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "patch", + path: "/instruments/{instrumentId}", + operationId: "updateInstrument", + summary: "Update an instrument", + description: "Requires scope `instruments:write`.", + tags: ["Instruments"], + security: bearerSecurity, + request: { + params: instrumentParams, + body: { content: { "application/json": { schema: patchInstrumentBody } } }, + }, + responses: { + 200: jsonResponse("Updated instrument.", instrumentDetail), + ...errorResponses(), + }, +}); diff --git a/web/lib/api/openapi/paths/meta.ts b/web/lib/api/openapi/paths/meta.ts new file mode 100644 index 00000000..00c1a8eb --- /dev/null +++ b/web/lib/api/openapi/paths/meta.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { jsonResponse, registry } from "../registry"; + +registry.registerPath({ + method: "get", + path: "/openapi.json", + operationId: "getOpenApiDocument", + summary: "Get the OpenAPI document", + tags: ["Meta"], + security: [], + responses: { 200: jsonResponse("OpenAPI 3.1 document.", z.unknown()) }, +}); diff --git a/web/lib/api/openapi/paths/runs.ts b/web/lib/api/openapi/paths/runs.ts new file mode 100644 index 00000000..dd146143 --- /dev/null +++ b/web/lib/api/openapi/paths/runs.ts @@ -0,0 +1,260 @@ +import { z } from "zod"; +import { + bearerSecurity, + commentIdParam, + errorResponses, + instrumentIdParam, + jsonResponse, + registry, + runIdParam, +} from "../registry"; +import { + attributionsResponse, + commentBody, + commentDeleted, + commentsListResponse, + createRunBody, + patchRunBody, + requestUploadBody, + requestUploadUrlBody, + runComment, + runCreated, + runDeleted, + runDetail, + runListQuery, + runListResponse, + runReprocessed, + runRestored, + runUpdated, + uploadAllQueued, + uploadQueued, + uploadUrlResponse, +} from "../schemas/runs"; +import { searchQuery, searchResponse } from "../schemas/search"; + +const runParams = z.object({ + instrumentId: instrumentIdParam, + runId: runIdParam, +}); +const commentParams = runParams.extend({ commentId: commentIdParam }); +const tag = ["Runs"]; +const scoped = (scope: string) => `Requires scope \`${scope}\`.`; +const body = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, +}); +const ok = (description: string, schema: z.ZodType) => ({ + 200: jsonResponse(description, schema), + ...errorResponses(), +}); + +registry.registerPath({ + method: "get", + path: "/instrument-runs", + operationId: "listInstrumentRuns", + summary: "List runs across instruments", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { query: runListQuery }, + responses: ok("Paginated runs.", runListResponse), +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs", + operationId: "listInstrumentRunsForInstrument", + summary: "List an instrument's runs", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { + params: z.object({ instrumentId: instrumentIdParam }), + query: runListQuery, + }, + responses: ok("Paginated runs.", runListResponse), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs", + operationId: "createInstrumentRun", + summary: "Create an instrument run", + description: scoped("runs:create"), + tags: tag, + security: bearerSecurity, + request: { + params: z.object({ instrumentId: instrumentIdParam }), + body: body(createRunBody), + }, + responses: { + 200: jsonResponse("Existing run.", runCreated), + 201: jsonResponse("Created run.", runCreated), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs/{runId}", + operationId: "getInstrumentRun", + summary: "Get a run", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Run detail.", runDetail), +}); +registry.registerPath({ + method: "patch", + path: "/instruments/{instrumentId}/runs/{runId}", + operationId: "updateInstrumentRun", + summary: "Update a run", + description: scoped("runs:update"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(patchRunBody) }, + responses: ok("Updated run.", runUpdated), +}); +registry.registerPath({ + method: "delete", + path: "/instruments/{instrumentId}/runs/{runId}", + operationId: "deleteInstrumentRun", + summary: "Soft-delete a run", + description: scoped("runs:delete"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Deletion result.", runDeleted), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/restore", + operationId: "restoreInstrumentRun", + summary: "Restore a run", + description: scoped("runs:delete"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Restored run.", runRestored), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/reprocess", + operationId: "reprocessInstrumentRun", + summary: "Reprocess a run", + description: scoped("runs:reprocess"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Reprocessing result.", runReprocessed), +}); +registry.registerPath({ + method: "get", + path: "/search", + operationId: "search", + summary: "Search Data Hub", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { query: searchQuery }, + responses: ok("Search results.", searchResponse), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/request-upload", + operationId: "requestRunUpload", + summary: "Queue selected files for upload", + description: scoped("runs:upload"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(requestUploadBody) }, + responses: ok("Queued files.", uploadQueued), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/request-upload-all", + operationId: "requestRunUploadAll", + summary: "Queue all files for upload", + description: scoped("runs:upload"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Queued files.", uploadAllQueued), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/request-upload-url", + operationId: "requestRunUploadUrl", + summary: "Request a presigned upload URL", + description: scoped("runs:upload"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(requestUploadUrlBody) }, + responses: ok("Upload URL.", uploadUrlResponse), +}); +registry.registerPath({ + method: "put", + path: "/instruments/{instrumentId}/runs/{runId}/attributions/me", + operationId: "claimRun", + summary: "Claim a run", + description: scoped("runs:attribute"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Attributions.", attributionsResponse), +}); +registry.registerPath({ + method: "delete", + path: "/instruments/{instrumentId}/runs/{runId}/attributions/me", + operationId: "unclaimRun", + summary: "Remove your run claim", + description: scoped("runs:attribute"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Attributions.", attributionsResponse), +}); +registry.registerPath({ + method: "get", + path: "/instruments/{instrumentId}/runs/{runId}/comments", + operationId: "listRunComments", + summary: "List run comments", + description: scoped("runs:read"), + tags: tag, + security: bearerSecurity, + request: { params: runParams }, + responses: ok("Comments.", commentsListResponse), +}); +registry.registerPath({ + method: "post", + path: "/instruments/{instrumentId}/runs/{runId}/comments", + operationId: "createRunComment", + summary: "Create a run comment", + description: scoped("runs:comment"), + tags: tag, + security: bearerSecurity, + request: { params: runParams, body: body(commentBody) }, + responses: { + 201: jsonResponse("Created comment.", runComment), + ...errorResponses(), + }, +}); +registry.registerPath({ + method: "patch", + path: "/instruments/{instrumentId}/runs/{runId}/comments/{commentId}", + operationId: "updateRunComment", + summary: "Update a run comment", + description: scoped("runs:comment"), + tags: tag, + security: bearerSecurity, + request: { params: commentParams, body: body(commentBody) }, + responses: ok("Updated comment.", runComment), +}); +registry.registerPath({ + method: "delete", + path: "/instruments/{instrumentId}/runs/{runId}/comments/{commentId}", + operationId: "deleteRunComment", + summary: "Delete a run comment", + description: scoped("runs:comment"), + tags: tag, + security: bearerSecurity, + request: { params: commentParams }, + responses: ok("Deleted comment.", commentDeleted), +}); diff --git a/web/lib/api/openapi/paths/watchers.ts b/web/lib/api/openapi/paths/watchers.ts new file mode 100644 index 00000000..39fd5abf --- /dev/null +++ b/web/lib/api/openapi/paths/watchers.ts @@ -0,0 +1,179 @@ +import { z } from "zod"; +import { + bearerSecurity, + errorResponses, + jsonResponse, + registry, + watcherIdParam, +} from "../registry"; +import { + heartbeatBody, + registerWatcherBody, + watcherChecksumResponse, + watcherConfigBody, + watcherDeleted, + watcherDetail, + watcherEventBody, + watcherEventCreated, + watcherEventsListResponse, + watcherHeartbeatAck, + watcherHeartbeatsListResponse, + watcherListResponse, + watcherRegistered, + watcherUpdateCheckResponse, + watcherUploadQueueResponse, +} from "../schemas/watchers"; + +const watcherParams = z.object({ watcherId: watcherIdParam }); +const body = (schema: z.ZodType) => ({ + content: { "application/json": { schema } }, +}); +const responses = (description: string, schema: z.ZodType = z.unknown()) => ({ + 200: jsonResponse(description, schema), + ...errorResponses(), +}); +const operation = ( + method: "get" | "post" | "put" | "delete", + path: string, + operationId: string, + summary: string, + scope: string, + responseSchema: z.ZodType, + request?: object +) => + registry.registerPath({ + method, + path, + operationId, + summary, + description: `Requires scope \`${scope}\`.`, + tags: ["Watchers"], + security: bearerSecurity, + ...(request ? { request } : {}), + responses: responses(`${summary}.`, responseSchema), + }); + +operation( + "get", + "/watchers", + "listWatchers", + "List watchers", + "watchers:read", + watcherListResponse, + { + query: z.object({ + instrument_id: z.string().optional(), + status: z.enum(["registered", "watching", "stopped", "stale"]).optional(), + include_deleted: z.coerce.boolean().optional(), + }), + } +); +registry.registerPath({ + method: "post", + path: "/watchers/register", + operationId: "registerWatcher", + summary: "Register a watcher", + description: "Requires scope `watchers:report`.", + tags: ["Watchers"], + security: bearerSecurity, + request: { body: body(registerWatcherBody) }, + responses: { + 201: jsonResponse("Registered watcher.", watcherRegistered), + ...errorResponses(), + }, +}); +operation( + "get", + "/watchers/{watcherId}", + "getWatcher", + "Get a watcher", + "watchers:read", + watcherDetail, + { params: watcherParams } +); +operation( + "delete", + "/watchers/{watcherId}", + "deleteWatcher", + "Deregister a watcher", + "watchers:admin", + watcherDeleted, + { params: watcherParams } +); +operation( + "put", + "/watchers/{watcherId}/config", + "updateWatcherConfig", + "Update watcher configuration", + "watchers:report", + watcherChecksumResponse, + { params: watcherParams, body: body(watcherConfigBody) } +); +operation( + "get", + "/watchers/{watcherId}/config-checksum", + "getWatcherConfigChecksum", + "Get watcher configuration checksum", + "watchers:read", + watcherChecksumResponse, + { params: watcherParams } +); +registry.registerPath({ + method: "post", + path: "/watchers/{watcherId}/events", + operationId: "createWatcherEvent", + summary: "Report a watcher event", + description: "Requires scope `watchers:report`.", + tags: ["Watchers"], + security: bearerSecurity, + request: { params: watcherParams, body: body(watcherEventBody) }, + responses: { + 201: jsonResponse("Created event.", watcherEventCreated), + ...errorResponses(), + }, +}); +operation( + "get", + "/watchers/{watcherId}/events", + "listWatcherEvents", + "List watcher events", + "watchers:read", + watcherEventsListResponse, + { params: watcherParams } +); +operation( + "post", + "/watchers/{watcherId}/heartbeat", + "recordWatcherHeartbeat", + "Record a watcher heartbeat", + "watchers:report", + watcherHeartbeatAck, + { params: watcherParams, body: body(heartbeatBody) } +); +operation( + "get", + "/watchers/{watcherId}/heartbeats", + "listWatcherHeartbeats", + "List watcher heartbeats", + "watchers:read", + watcherHeartbeatsListResponse, + { params: watcherParams } +); +operation( + "get", + "/watchers/{watcherId}/upload-queue", + "getWatcherUploadQueue", + "Get watcher upload queue", + "watchers:read", + watcherUploadQueueResponse, + { params: watcherParams } +); +operation( + "get", + "/watchers/{watcherId}/update-check", + "checkWatcherUpdate", + "Check watcher update availability", + "watchers:read", + watcherUpdateCheckResponse, + { params: watcherParams } +); diff --git a/web/lib/api/openapi/registry.ts b/web/lib/api/openapi/registry.ts new file mode 100644 index 00000000..c5bfa504 --- /dev/null +++ b/web/lib/api/openapi/registry.ts @@ -0,0 +1,76 @@ +import { + extendZodWithOpenApi, + OpenAPIRegistry, + type ResponseConfig, +} from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); + +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + description: + "Personal access tokens begin with `dhub_…` and are restricted by their assigned scopes.", +}); + +export const ErrorBody = z + .object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + }), + }) + .openapi("ErrorResponse"); + +registry.register("ErrorResponse", ErrorBody); + +export const instrumentIdParam = z + .string() + .openapi({ example: "plate-reader" }); +export const runIdParam = z.string().openapi({ example: "run-2026-001" }); +export const watcherIdParam = z.string().uuid(); +// Path params are always present; avoid z.coerce.number() which Zod 4 treats +// as nullable and which zod-to-openapi then emits as required: false. +export const fileIdParam = z + .number() + .int() + .openapi({ + param: { required: true }, + example: 1, + }); +export const commentIdParam = z + .string() + .uuid() + .openapi({ example: "3f1a2b4c-5d6e-7f80-9a1b-2c3d4e5f6071" }); +export const archiveJobIdParam = z.string().uuid(); + +export function jsonResponse( + description: string, + schema: z.ZodType, + status?: number +): ResponseConfig { + return { + description, + ...(status ? { status } : {}), + content: { "application/json": { schema } }, + }; +} + +export function errorResponses(): Record< + 401 | 403 | 404 | 400 | 409, + ResponseConfig +> { + return { + 400: jsonResponse("Invalid request.", ErrorBody), + 401: jsonResponse("Authentication is required.", ErrorBody), + 403: jsonResponse("The token does not have the required scope.", ErrorBody), + 404: jsonResponse("The requested resource was not found.", ErrorBody), + 409: jsonResponse("The request conflicts with resource state.", ErrorBody), + }; +} + +export const bearerSecurity = [{ bearerAuth: [] as string[] }]; diff --git a/web/lib/api/openapi/schemas/archive.ts b/web/lib/api/openapi/schemas/archive.ts new file mode 100644 index 00000000..d08a0c83 --- /dev/null +++ b/web/lib/api/openapi/schemas/archive.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { archiveJobStatusSchema, isoDateTime } from "./common"; + +// Lambda callback body. `archive_bucket` / `archive_key` are required when +// status is `ready` (enforced in the route after parse). +export const patchArchiveJobBody = z.object({ + status: archiveJobStatusSchema, + archive_bucket: z.string().min(1).optional(), + archive_key: z.string().min(1).optional(), + size_bytes: z.number().int().nonnegative().optional(), + error_message: z.string().nullable().optional(), +}); + +export const archiveJobDetail = z + .object({ + id: z.string().uuid(), + status: archiveJobStatusSchema, + archive_bucket: z.string().nullable(), + archive_key: z.string().nullable(), + size_bytes: z.number().int().nullable(), + error_message: z.string().nullable(), + completed_at: isoDateTime.nullable(), + }) + .openapi("ArchiveJobDetail"); diff --git a/web/lib/api/openapi/schemas/common.ts b/web/lib/api/openapi/schemas/common.ts new file mode 100644 index 00000000..c19f90dd --- /dev/null +++ b/web/lib/api/openapi/schemas/common.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { + archiveJobStatusEnum, + fileCategoryEnum, + fileStatusEnum, + VALID_INSTRUMENT_TYPES, + watcherEventTypeEnum, +} from "@/lib/db/schema"; +import { RUN_STATUS_VALUES } from "@/lib/runs/run-status"; + +export const isoDateTime = z.string().datetime({ offset: true }); +export const instrumentTypeSchema = z.enum(VALID_INSTRUMENT_TYPES); +export const instrumentStatusSchema = z.enum(["pending", "active", "inactive"]); +export const runStatusSchema = z.enum(RUN_STATUS_VALUES); +export const runSourceSchema = z.enum(["lambda", "watcher"]); +export const fileCategorySchema = z.enum(fileCategoryEnum.enumValues); +export const fileStatusSchema = z.enum(fileStatusEnum.enumValues); +export const watcherStatusSchema = z.enum([ + "registered", + "watching", + "stopped", + "stale", +]); +export const watcherEventTypeSchema = z.enum(watcherEventTypeEnum.enumValues); +export const uploadModeSchema = z.enum(["auto", "manual"]); +export const archiveJobStatusSchema = z.enum(archiveJobStatusEnum.enumValues); +export const paginationSchema = z.object({ + page: z.number().int(), + per_page: z.number().int(), + total: z.number().int(), + total_pages: z.number().int(), +}); diff --git a/web/lib/api/openapi/schemas/files.ts b/web/lib/api/openapi/schemas/files.ts new file mode 100644 index 00000000..22cce890 --- /dev/null +++ b/web/lib/api/openapi/schemas/files.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { fileCategorySchema, fileStatusSchema, isoDateTime } from "./common"; + +export const createFileBody = z.object({ + s3_bucket: z.string().min(1), + s3_key: z.string().min(1), + filename: z.string().min(1), + content_type: z.string().optional(), + size_bytes: z.number().optional(), + category: fileCategorySchema.optional(), +}); + +export const patchFileBody = z.object({ + status: fileStatusSchema.optional(), + s3_bucket: z.string().optional(), + s3_key: z.string().optional(), + content_type: z.string().optional(), + size_bytes: z.number().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + error_message: z.string().optional(), +}); + +// Shared by the create (`formatFileResponse`) and update responses. Create +// omits `detected_at` / `upload_requested_at`, so both are optional here. +export const fileDetail = z + .object({ + id: z.number().int(), + instrument_run_id: z.string().uuid(), + filename: z.string(), + relative_path: z.string(), + s3_bucket: z.string().nullable(), + s3_key: z.string().nullable(), + content_type: z.string().nullable(), + size_bytes: z.number().nullable(), + category: fileCategorySchema, + status: fileStatusSchema, + metadata: z.record(z.string(), z.unknown()).nullable(), + error_message: z.string().nullable(), + detected_at: isoDateTime.nullable().optional(), + upload_requested_at: isoDateTime.nullable().optional(), + uploaded_at: isoDateTime.nullable(), + processed_at: isoDateTime.nullable(), + created_at: isoDateTime, + file_created_at: isoDateTime.nullable(), + }) + .openapi("FileDetail"); + +export const fileDismissed = z.object({ + id: z.number().int(), + filename: z.string(), + deleted_at: isoDateTime.nullable(), + already_applied: z.boolean(), +}); + +export const fileReprocessed = z.object({ + status: z.literal("processing"), + file_id: z.number().int(), +}); diff --git a/web/lib/api/openapi/schemas/instruments.ts b/web/lib/api/openapi/schemas/instruments.ts new file mode 100644 index 00000000..41a1dea4 --- /dev/null +++ b/web/lib/api/openapi/schemas/instruments.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { + instrumentStatusSchema, + instrumentTypeSchema, + isoDateTime, +} from "./common"; + +export const createInstrumentBody = z.object({ + id: z + .string() + .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Must be lowercase kebab-case"), + // Trim so whitespace-only values fall through to the id-derived default. + display_name: z.string().trim().optional(), + instrument_type: instrumentTypeSchema.optional(), +}); + +export const patchInstrumentBody = z.object({ + status: instrumentStatusSchema.optional(), + display_name: z.string().trim().min(1).optional(), + instrument_type: instrumentTypeSchema.optional(), +}); + +export const instrumentListItem = z + .object({ + id: z.string(), + display_name: z.string(), + status: instrumentStatusSchema, + instrument_type: instrumentTypeSchema, + }) + .openapi("InstrumentListItem"); + +export const instrumentDetail = instrumentListItem + .extend({ + created_at: isoDateTime, + updated_at: isoDateTime.optional(), + run_count: z.number().int().optional(), + watcher_count: z.number().int().optional(), + }) + .openapi("InstrumentDetail"); diff --git a/web/lib/api/openapi/schemas/runs.ts b/web/lib/api/openapi/schemas/runs.ts new file mode 100644 index 00000000..79fdbae7 --- /dev/null +++ b/web/lib/api/openapi/schemas/runs.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; +import { + fileCategorySchema, + fileStatusSchema, + isoDateTime, + paginationSchema, + runSourceSchema, + runStatusSchema, +} from "./common"; + +export const detectedFileSchema = z.object({ + relative_path: z.string().min(1), + filename: z.string().min(1), + size_bytes: z.number().int().nonnegative().optional(), + file_created_at: isoDateTime.optional(), +}); + +export const createRunBody = z.object({ + run_id: z.string().trim().min(1), + source: runSourceSchema, + watcher_id: z.string().uuid().optional(), + acquired_at: isoDateTime.optional(), + detected_files: z.array(detectedFileSchema).optional(), +}); + +export const patchRunBody = z.object({ + metadata: z.record(z.string(), z.unknown()).optional(), + acquired_at: isoDateTime.optional(), + detected_files: z.array(detectedFileSchema).optional(), +}); + +export const runListQuery = z.object({ + instrument_id: z.string().optional(), + source: runSourceSchema.optional(), + search: z.string().optional(), + sort: z.string().optional(), + order: z.enum(["asc", "desc"]).optional(), + date_from: isoDateTime.optional(), + date_to: isoDateTime.optional(), + page: z.coerce.number().int().min(1).optional(), + per_page: z.coerce.number().int().min(1).max(100).optional(), + include_deleted: z.coerce.boolean().optional(), + ran_by: z.string().optional(), + status: z.union([runStatusSchema, z.array(runStatusSchema)]).optional(), + metadata_key: z + .string() + .optional() + .openapi({ description: "Metadata key filter." }), + metadata_value: z + .string() + .optional() + .openapi({ description: "Metadata value filter." }), +}); + +const metadataObject = z.record(z.string(), z.unknown()); + +// A user who has claimed a run. Wire shape mixes `userId` with camelCase +// display fields — mirror it exactly rather than "fixing" the casing here. +export const attribution = z + .object({ + userId: z.string(), + displayName: z.string(), + initials: z.string(), + avatarUrl: z.string().nullable(), + }) + .openapi("RunAttribution"); + +export const runCreated = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + run_id: z.string(), + source: runSourceSchema, + }) + .openapi("RunCreated"); + +export const runUpdated = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable(), + run_id: z.string(), + source: runSourceSchema, + watcher_id: z.string().uuid().nullable(), + metadata: metadataObject.nullable(), + created_at: isoDateTime, + acquired_at: isoDateTime.nullable(), + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable(), + }) + .openapi("RunUpdated"); + +// File sub-object embedded in run detail. Distinct from the top-level file +// resource: it carries a presigned `download_url` and omits `s3_bucket`. +export const runDetailFile = z + .object({ + id: z.number().int(), + filename: z.string(), + relative_path: z.string(), + s3_key: z.string().nullable(), + content_type: z.string().nullable(), + size_bytes: z.number().nullable(), + category: fileCategorySchema, + status: fileStatusSchema, + metadata: metadataObject.nullable(), + error_message: z.string().nullable(), + detected_at: isoDateTime.nullable(), + upload_requested_at: isoDateTime.nullable(), + uploaded_at: isoDateTime.nullable(), + processed_at: isoDateTime.nullable(), + download_url: z.string().url().nullable(), + created_at: isoDateTime, + file_created_at: isoDateTime.nullable(), + }) + .openapi("RunDetailFile"); + +export const runDetail = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable(), + run_id: z.string(), + source: runSourceSchema, + watcher_id: z.string().uuid().nullable(), + created_at: isoDateTime, + acquired_at: isoDateTime.nullable(), + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable(), + deleted_by: z.string().nullable(), + metadata: metadataObject.nullable(), + attributions: z.array(attribution), + files: z.array(runDetailFile), + }) + .openapi("RunDetail"); + +export const runListItem = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable(), + run_id: z.string(), + source: runSourceSchema, + metadata: metadataObject.nullable(), + created_at: isoDateTime, + acquired_at: isoDateTime.nullable(), + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable(), + file_count: z.number().int(), + files_completed: z.number().int(), + files_failed: z.number().int(), + files_pending_upload: z.number().int(), + files_uploaded: z.number().int(), + files_processing: z.number().int(), + // Aggregated with a `bigint` cast, which the driver may surface as a + // string on large totals. + total_size_bytes: z.union([z.number(), z.string()]), + error_messages: z.array(z.string()), + attributions: z.array(attribution), + }) + .openapi("RunListItem"); + +export const runListResponse = z.object({ + data: z.array(runListItem), + pagination: paginationSchema, +}); + +export const runDeleted = z.object({ + instrument_id: z.string(), + run_id: z.string(), + deleted_at: isoDateTime.nullable(), + deleted_by: z.string().nullable(), + already_applied: z.boolean(), +}); + +export const runRestored = z.object({ + id: z.string().uuid(), + instrument_id: z.string(), + run_id: z.string(), + deleted_at: isoDateTime.nullable(), + already_applied: z.boolean(), +}); + +export const runReprocessed = z.object({ + instrument_id: z.string(), + run_id: z.string(), + files_queued: z.number().int(), + files_failed: z.number().int(), +}); + +export const requestUploadBody = z.object({ + file_ids: z.array(z.union([z.string(), z.number()])).min(1), +}); +export const requestUploadUrlBody = z.object({ + filename: z.string().min(1), + content_type: z.string().optional(), + size_bytes: z.number().optional(), + file_created_at: isoDateTime.optional(), +}); +export const commentBody = z.object({ body: z.string().min(1).max(10_000) }); + +export const uploadQueued = z.object({ + instrument_id: z.string(), + run_id: z.string(), + files_queued: z.number().int(), + files: z + .array( + z.object({ + id: z.number().int(), + filename: z.string(), + upload_requested_at: isoDateTime.nullable(), + }) + ) + .optional(), +}); + +export const uploadAllQueued = z.object({ + instrument_id: z.string(), + run_id: z.string(), + files_queued: z.number().int(), +}); + +export const uploadUrlResponse = z + .union([ + z.object({ + already_uploaded: z.literal(true), + file_id: z.number().int(), + s3_bucket: z.string().nullable(), + s3_key: z.string().nullable(), + }), + z.object({ + already_uploaded: z.literal(false), + upload_url: z.string().url(), + s3_bucket: z.string(), + s3_key: z.string(), + file_id: z.number().int(), + expires_in: z.number().int(), + }), + ]) + .openapi("UploadUrlResponse"); + +export const attributionsResponse = z.object({ + attributions: z.array(attribution), +}); + +const commentUser = z.object({ + id: z.string(), + displayName: z.string(), + initials: z.string(), + avatarUrl: z.string().nullable(), +}); + +export const runComment = z + .object({ + id: z.string().uuid(), + body: z.string(), + user: commentUser, + created_at: isoDateTime, + edited_at: isoDateTime.nullable(), + }) + .openapi("RunComment"); + +export const commentsListResponse = z.object({ + comments: z.array(runComment), +}); + +export const commentDeleted = z.object({ + id: z.string(), + deleted: z.literal(true), +}); diff --git a/web/lib/api/openapi/schemas/search.ts b/web/lib/api/openapi/schemas/search.ts new file mode 100644 index 00000000..f66ca8f9 --- /dev/null +++ b/web/lib/api/openapi/schemas/search.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const searchQuery = z.object({ + q: z.string().optional(), + scope: z.enum(["all", "runs", "files", "instruments"]).optional(), +}); + +export const searchResponse = z.object({ + runs: z.array(z.record(z.string(), z.unknown())), + files: z.array(z.record(z.string(), z.unknown())), + instruments: z.array(z.record(z.string(), z.unknown())), +}); diff --git a/web/lib/api/openapi/schemas/watchers.ts b/web/lib/api/openapi/schemas/watchers.ts new file mode 100644 index 00000000..c54a91c3 --- /dev/null +++ b/web/lib/api/openapi/schemas/watchers.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; +import { + isoDateTime, + uploadModeSchema, + watcherEventTypeSchema, + watcherStatusSchema, +} from "./common"; + +export const registerWatcherBody = z.object({ + instrument_id: z.string().min(1), + hostname: z.string().optional(), + os_info: z.string().optional(), +}); + +export const watcherConfigBody = z.object({ + config_checksum: z.string().min(1), + config_yaml: z.string().min(1), +}); + +const watcherEventItem = z.object({ + event_type: watcherEventTypeSchema, + timestamp: z.string().min(1), + message: z.string().min(1), + details: z.record(z.string(), z.unknown()).optional(), +}); + +export const watcherEventBody = z.object({ + events: z.array(watcherEventItem).min(1).max(100), +}); + +export const heartbeatBody = z.object({ + status: z.enum(["registered", "watching", "stopped"]), + timestamp: z.string().optional(), + watcher_version: z.string().optional(), + upload_mode: uploadModeSchema.optional(), + files_uploaded_since_last_heartbeat: z + .number() + .int() + .nonnegative() + .optional(), + runs_reported_since_last_heartbeat: z.number().int().nonnegative().optional(), + errors_since_last_heartbeat: z.number().int().nonnegative().optional(), + uptime_seconds: z.number().int().nonnegative().optional(), +}); + +// Shared by the detail (`GET /watchers/{id}`) and list responses. Detail +// includes `config_*` and omits `deleted_at`; the list is the reverse — so +// the fields that differ are optional. +export const watcherDetail = z + .object({ + id: z.string().uuid(), + instrument_id: z.string(), + instrument_display_name: z.string().nullable().optional(), + hostname: z.string().nullable(), + os_info: z.string().nullable(), + status: watcherStatusSchema, + config_yaml: z.string().nullable().optional(), + config_checksum: z.string().nullable().optional(), + last_heartbeat_at: isoDateTime.nullable(), + created_at: isoDateTime, + updated_at: isoDateTime, + deleted_at: isoDateTime.nullable().optional(), + }) + .openapi("WatcherDetail"); + +export const watcherListResponse = z.object({ + data: z.array(watcherDetail), +}); + +export const watcherRegistered = z.object({ + watcher_id: z.string().uuid(), +}); + +export const watcherDeleted = z.object({ + id: z.string().uuid(), + deleted_at: isoDateTime, +}); + +// Both `PUT /config` and `GET /config-checksum` return only the checksum. +export const watcherChecksumResponse = z.object({ + config_checksum: z.string(), +}); + +export const watcherEventCreated = z.object({ + received: z.number().int(), +}); + +const watcherEvent = z.object({ + id: z.number().int(), + event_type: watcherEventTypeSchema, + message: z.string(), + details: z.record(z.string(), z.unknown()).nullable(), + timestamp: isoDateTime, + created_at: isoDateTime, +}); + +export const watcherEventsListResponse = z.object({ + data: z.array(watcherEvent), +}); + +export const watcherHeartbeatAck = z.object({ + ok: z.literal(true), +}); + +const watcherHeartbeat = z.object({ + id: z.number().int(), + timestamp: isoDateTime, + status: z.string(), + upload_mode: uploadModeSchema.nullable(), + files_uploaded_since_last: z.number().int().nullable(), + runs_reported_since_last: z.number().int().nullable(), + errors_since_last: z.number().int().nullable(), + uptime_seconds: z.number().int().nullable(), + created_at: isoDateTime, +}); + +export const watcherHeartbeatsListResponse = z.object({ + data: z.array(watcherHeartbeat), +}); + +export const watcherUploadQueueResponse = z.object({ + files: z.array( + z.object({ + id: z.number().int(), + instrument_id: z.string(), + run_id: z.string(), + relative_path: z.string(), + filename: z.string(), + size_bytes: z.number().nullable(), + }) + ), +}); + +export const watcherUpdateCheckResponse = z.object({ + channel: z.string(), + latest_version: z.string().nullable(), + mandatory: z.boolean(), + min_supported_version: z.string().nullable(), +}); diff --git a/web/package-lock.json b/web/package-lock.json index d5404e5b..80387a11 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@auth/drizzle-adapter": "^1.11.1", "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", @@ -80,6 +81,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@auth/core": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", @@ -12326,6 +12339,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.0.tgz", + "integrity": "sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==", + "license": "MIT", + "dependencies": { + "yaml": "^2.9.0" + } + }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", diff --git a/web/package.json b/web/package.json index f3c67a43..fc74dcf9 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "db:reseed": "npm run db:reset && npm run db:push && npm run db:seed", "db:process-fixtures": "tsx scripts/process-seeded-fixtures.ts", "db:clear-runs": "tsx scripts/clear-run-file-records.ts", + "openapi:generate": "tsx scripts/generate-openapi.ts", "test:unit": "vitest run --config vitest.unit.config.ts", "test:unit:watch": "vitest --config vitest.unit.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -29,6 +30,7 @@ "check-all": "npm run check && npm run test-all && npm run build" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", "@auth/drizzle-adapter": "^1.11.1", "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", diff --git a/web/scripts/generate-openapi.ts b/web/scripts/generate-openapi.ts new file mode 100644 index 00000000..a64dd580 --- /dev/null +++ b/web/scripts/generate-openapi.ts @@ -0,0 +1,9 @@ +// Optional local dump for inspection. Output is gitignored; production +// serves the schema from GET /api/v1/openapi.json (built statically). +import { writeFile } from "node:fs/promises"; +import { buildOpenApiDocument } from "@/lib/api/openapi"; + +await writeFile( + "openapi.json", + `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n` +); diff --git a/web/tests/integration/archive-jobs.test.ts b/web/tests/integration/archive-jobs.test.ts index d7cf0e24..34b2065e 100644 --- a/web/tests/integration/archive-jobs.test.ts +++ b/web/tests/integration/archive-jobs.test.ts @@ -5,6 +5,7 @@ import { STUCK_BUILD_ERROR_MESSAGE, STUCK_BUILD_TIMEOUT_MS, } from "@/lib/api/archive-jobs"; +import { archiveJobDetail } from "@/lib/api/openapi"; import { archiveJobs, instrumentRuns, instruments } from "@/lib/db/schema"; import { closeTestDb, @@ -161,6 +162,9 @@ describe("Archive Jobs API", () => { const body = await res.json(); expect(body.status).toBe("ready"); expect(body.completed_at).not.toBeNull(); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + archiveJobDetail.parse(body); // Verify the row was actually updated rather than just the response shaped. const [stored] = await db diff --git a/web/tests/integration/files.test.ts b/web/tests/integration/files.test.ts index 01d07d1a..ddaaea2f 100644 --- a/web/tests/integration/files.test.ts +++ b/web/tests/integration/files.test.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { fileDetail, fileDismissed } from "@/lib/api/openapi"; import { instruments, files as schemaFiles } from "@/lib/db/schema"; import { api, @@ -117,6 +118,9 @@ describe("Files API", () => { expect(data.category).toBe("processed"); expect(data.relative_path).toBe("processed_output.csv"); lambdaFileId = data.id; + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + fileDetail.parse(data); }); // Reconcile case 1: the watcher reported a detected row first, then the @@ -304,6 +308,7 @@ describe("Files API", () => { expect(data.status).toBe("uploaded"); expect(data.s3_bucket).toBe("test-bucket"); expect(data.uploaded_at).toBeTruthy(); + fileDetail.parse(data); }); it("PATCH transitions uploaded → processing", async () => { @@ -412,6 +417,7 @@ describe("Files API", () => { const data = await res.json(); expect(data.deleted_at).toBeTruthy(); expect(data.already_applied).toBe(false); + fileDismissed.parse(data); }); it("DELETE is idempotent — re-dismissing an already-deleted file succeeds", async () => { diff --git a/web/tests/integration/instrument-runs.test.ts b/web/tests/integration/instrument-runs.test.ts index 160f9286..048fa3b9 100644 --- a/web/tests/integration/instrument-runs.test.ts +++ b/web/tests/integration/instrument-runs.test.ts @@ -1,4 +1,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + runCreated, + runDeleted, + runDetail, + runListResponse, + runUpdated, +} from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -47,6 +54,9 @@ describe("Instrument Runs API", () => { expect(data.run_id).toBe("run-001"); expect(data.source).toBe("lambda"); expect(data.id).toBeTruthy(); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + runCreated.parse(data); }); // Run creation is idempotent on (instrument_id, run_id). This handles the @@ -61,6 +71,7 @@ describe("Instrument Runs API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.run_id).toBe("run-001"); + runCreated.parse(data); }); it("POST rejects missing run_id", async () => { @@ -137,6 +148,7 @@ describe("Instrument Runs API", () => { expect(body.data.length).toBeGreaterThanOrEqual(2); expect(body.pagination).toBeTruthy(); expect(body.pagination.total).toBeGreaterThanOrEqual(2); + runListResponse.parse(body); }); it("GET supports source filter", async () => { @@ -161,6 +173,7 @@ describe("Instrument Runs API", () => { const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(2); expect(body.pagination).toBeTruthy(); + runListResponse.parse(body); }); it("GET /api/v1/instrument-runs supports instrument_id filter", async () => { @@ -189,6 +202,7 @@ describe("Instrument Runs API", () => { expect(data.instrument_id).toBe(instrumentId); expect(data).toHaveProperty("files"); expect(data).toHaveProperty("metadata"); + runDetail.parse(data); }); it("GET returns 404 for nonexistent run", async () => { @@ -214,6 +228,7 @@ describe("Instrument Runs API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.metadata).toEqual({ assay: "Bradford", plate: "96-well" }); + runUpdated.parse(data); }); it("PATCH rejects non-object metadata", async () => { @@ -251,6 +266,7 @@ describe("Instrument Runs API", () => { // The acting user (the PAT's owner) is recorded as the deleter. expect(data.deleted_by).toBe(userId); expect(data.already_applied).toBe(false); + runDeleted.parse(data); }); it("DELETE is idempotent — re-deleting an already-deleted run succeeds", async () => { diff --git a/web/tests/integration/instruments.test.ts b/web/tests/integration/instruments.test.ts index e899b087..af6da477 100644 --- a/web/tests/integration/instruments.test.ts +++ b/web/tests/integration/instruments.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { instrumentDetail, instrumentListItem } from "@/lib/api/openapi"; import { api, closeTestDb, @@ -35,6 +36,9 @@ describe("Instruments API", () => { expect(data.id).toBe("test-instrument"); expect(data.display_name).toBe("Test Instrument"); expect(data.status).toBe("pending"); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + instrumentDetail.parse(data); }); // When no display_name is provided, the API derives one from the kebab-case @@ -95,6 +99,7 @@ describe("Instruments API", () => { expect( data.find((i: { id: string }) => i.id === "test-instrument") ).toBeTruthy(); + instrumentListItem.array().parse(data); }); // ------------------------------------------------------------------------- @@ -109,6 +114,7 @@ describe("Instruments API", () => { expect(data.display_name).toBe("Test Instrument"); expect(data).toHaveProperty("run_count"); expect(data).toHaveProperty("watcher_count"); + instrumentDetail.parse(data); }); it("GET /api/v1/instruments/:id returns 404 for nonexistent id", async () => { @@ -131,6 +137,7 @@ describe("Instruments API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("active"); + instrumentDetail.parse(data); }); it("PATCH /api/v1/instruments/:id updates display_name", async () => { diff --git a/web/tests/integration/request-upload-url.test.ts b/web/tests/integration/request-upload-url.test.ts index 6d60065d..cde3667e 100644 --- a/web/tests/integration/request-upload-url.test.ts +++ b/web/tests/integration/request-upload-url.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { uploadUrlResponse } from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -66,6 +67,9 @@ describe("Request Upload URL API", () => { expect(data.file_id).toBeGreaterThan(0); expect(data.expires_in).toBe(3600); expect(data.already_uploaded).toBe(false); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + uploadUrlResponse.parse(data); }); it("creates a file record if none exists", async () => { @@ -135,6 +139,7 @@ describe("Request Upload URL API", () => { const data = await res.json(); expect(data.already_uploaded).toBe(true); expect(data.file_id).toBe(fileId); + uploadUrlResponse.parse(data); }); // ------------------------------------------------------------------------- diff --git a/web/tests/integration/request-upload-validation.test.ts b/web/tests/integration/request-upload-validation.test.ts index 494ed650..4856b062 100644 --- a/web/tests/integration/request-upload-validation.test.ts +++ b/web/tests/integration/request-upload-validation.test.ts @@ -1,5 +1,6 @@ import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { uploadQueued } from "@/lib/api/openapi"; import { files, instrumentRuns, instruments, watchers } from "@/lib/db/schema"; import { api, @@ -129,6 +130,9 @@ describe("Request Upload — file_ids validation", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.files_queued).toBe(fileIds.length); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + uploadQueued.parse(data); expect(await fileStatuses()).toEqual([ "upload_requested", "upload_requested", diff --git a/web/tests/integration/run-attributions.test.ts b/web/tests/integration/run-attributions.test.ts index 58afb21b..744c83d0 100644 --- a/web/tests/integration/run-attributions.test.ts +++ b/web/tests/integration/run-attributions.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { attributionsResponse, runDetail } from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -93,6 +94,9 @@ describe("Run Attributions API", () => { expect(body.attributions[0].userId).toBe(userIdA); expect(body.attributions[0].displayName).toBeTruthy(); expect(body.attributions[0].initials).toBeTruthy(); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + attributionsResponse.parse(body); }); it("PUT is idempotent — claiming twice still yields one entry", async () => { @@ -141,6 +145,7 @@ describe("Run Attributions API", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.attributions).toEqual([]); + attributionsResponse.parse(body); }); it("DELETE is idempotent — deleting when no attribution exists is a no-op", async () => { @@ -251,5 +256,6 @@ describe("Run Attributions API", () => { expect(Array.isArray(body.attributions)).toBe(true); expect(body.attributions).toHaveLength(1); expect(body.attributions[0].userId).toBe(userIdA); + runDetail.parse(body); }); }); diff --git a/web/tests/integration/run-comments.test.ts b/web/tests/integration/run-comments.test.ts index 32ddc5ed..976c8a23 100644 --- a/web/tests/integration/run-comments.test.ts +++ b/web/tests/integration/run-comments.test.ts @@ -1,4 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + commentDeleted, + commentsListResponse, + runComment, +} from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -100,6 +105,9 @@ describe("Run Comments API", () => { expect(created.id).toBeTruthy(); expect(created.body).toBe("Hello **world**"); expect(created.edited_at).toBeNull(); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + runComment.parse(created); const res = await api(commentsPath(runId), { token: tokenA }); expect(res.status).toBe(200); @@ -109,6 +117,7 @@ describe("Run Comments API", () => { expect(body.comments[0].user.id).toBe(userIdA); expect(body.comments[0].user.displayName).toBeTruthy(); expect(body.comments[0].user.initials).toBeTruthy(); + commentsListResponse.parse(body); }); it("POST returns 400 for missing body field", async () => { @@ -167,6 +176,7 @@ describe("Run Comments API", () => { const updated = await res.json(); expect(updated.body).toBe("second draft"); expect(updated.edited_at).toBeTruthy(); + runComment.parse(updated); }); it("PATCH by another user returns 403 FORBIDDEN", async () => { @@ -205,6 +215,7 @@ describe("Run Comments API", () => { token: tokenA, }); expect(del.status).toBe(200); + commentDeleted.parse(await del.json()); const list = await api(commentsPath(runId), { token: tokenA }); const body = await list.json(); diff --git a/web/tests/integration/run-lifecycle.test.ts b/web/tests/integration/run-lifecycle.test.ts index 9dd7cdf2..428d0668 100644 --- a/web/tests/integration/run-lifecycle.test.ts +++ b/web/tests/integration/run-lifecycle.test.ts @@ -1,5 +1,6 @@ import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runDeleted, runRestored } from "@/lib/api/openapi"; import { instrumentRuns, instruments } from "@/lib/db/schema"; import { api, @@ -70,6 +71,9 @@ describe("Run lifecycle API (delete / restore)", () => { expect(body.run_id).toBe(runId); expect(body.deleted_at).toBeNull(); expect(body.already_applied).toBe(false); + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + runRestored.parse(body); }); it("restore on a run that isn't deleted is an idempotent no-op", async () => { @@ -92,6 +96,7 @@ describe("Run lifecycle API (delete / restore)", () => { const firstBody = await first.json(); expect(firstBody.already_applied).toBe(false); expect(firstBody.deleted_at).toBeTruthy(); + runDeleted.parse(firstBody); const second = await api( `/api/v1/instruments/${instrumentId}/runs/${runId}`, diff --git a/web/tests/integration/upload-request-cancellation.test.ts b/web/tests/integration/upload-request-cancellation.test.ts index 1e7407e4..ac846081 100644 --- a/web/tests/integration/upload-request-cancellation.test.ts +++ b/web/tests/integration/upload-request-cancellation.test.ts @@ -1,5 +1,6 @@ import { inArray } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { watcherUploadQueueResponse } from "@/lib/api/openapi"; import { files, instruments } from "@/lib/db/schema"; import { api, @@ -107,7 +108,11 @@ describe("Upload request cancellation on watch-directory change", () => { const before = await api(`/api/v1/watchers/${watcherId}/upload-queue`, { token, }); - expect((await before.json()).files).toHaveLength(2); + const beforeBody = await before.json(); + expect(beforeBody.files).toHaveLength(2); + // Drift guard: the live response must match its documented OpenAPI schema + // (responses aren't validated at runtime, so this is the only backstop). + watcherUploadQueueResponse.parse(beforeBody); const res = await api(`/api/v1/watchers/${watcherId}/config`, { method: "PUT", diff --git a/web/tests/integration/watchers.test.ts b/web/tests/integration/watchers.test.ts index bbe0feed..715de8bb 100644 --- a/web/tests/integration/watchers.test.ts +++ b/web/tests/integration/watchers.test.ts @@ -1,4 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + watcherChecksumResponse, + watcherDeleted, + watcherDetail, + watcherEventCreated, + watcherEventsListResponse, + watcherHeartbeatAck, + watcherHeartbeatsListResponse, + watcherListResponse, + watcherRegistered, + watcherUpdateCheckResponse, +} from "@/lib/api/openapi"; import { instruments } from "@/lib/db/schema"; import { api, @@ -51,6 +63,9 @@ describe("Watchers API", () => { const data = await res.json(); expect(data.watcher_id).toBeTruthy(); watcherId = data.watcher_id; + // Drift guard: live responses must match their documented OpenAPI schemas + // (responses aren't validated at runtime, so this is the only backstop). + watcherRegistered.parse(data); }); // Enforces the 1:1 active-watcher-per-instrument invariant. The DB-level @@ -126,6 +141,7 @@ describe("Watchers API", () => { expect(watcher).toBeTruthy(); expect(watcher.instrument_id).toBe(instrumentId); expect(watcher.hostname).toBe("lab-pc-01"); + watcherListResponse.parse(body); }); it("GET /api/v1/watchers filters by instrument_id", async () => { @@ -160,6 +176,7 @@ describe("Watchers API", () => { expect(data.instrument_id).toBe(instrumentId); expect(data.hostname).toBe("lab-pc-01"); expect(data.os_info).toBe("Windows 11"); + watcherDetail.parse(data); }); it("GET /api/v1/watchers/:id returns 404 for nonexistent watcher", async () => { @@ -199,6 +216,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.ok).toBe(true); + watcherHeartbeatAck.parse(data); }); it("POST /api/v1/watchers/:id/heartbeat rejects missing status", async () => { @@ -222,6 +240,7 @@ describe("Watchers API", () => { const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(1); expect(body.data[0].status).toBe("watching"); + watcherHeartbeatsListResponse.parse(body); }); // ------------------------------------------------------------------------- @@ -253,6 +272,7 @@ describe("Watchers API", () => { expect(res.status).toBe(201); const data = await res.json(); expect(data.received).toBe(2); + watcherEventCreated.parse(data); }); it("POST /api/v1/watchers/:id/events rejects empty events array", async () => { @@ -326,6 +346,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(2); + watcherEventsListResponse.parse(body); }); it("GET /api/v1/watchers/:id/events filters by event_type", async () => { @@ -359,6 +380,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.config_checksum).toBe("abc123"); + watcherChecksumResponse.parse(data); }); it("PUT /api/v1/watchers/:id/config rejects missing fields", async () => { @@ -381,6 +403,7 @@ describe("Watchers API", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.config_checksum).toBe("abc123"); + watcherChecksumResponse.parse(data); }); // ------------------------------------------------------------------------- @@ -403,6 +426,7 @@ describe("Watchers API", () => { channel: "stable", mandatory: false, }); + watcherUpdateCheckResponse.parse(data); }); it("GET /api/v1/watchers/:id/update-check requires authentication", async () => { @@ -440,6 +464,7 @@ describe("Watchers API", () => { const data = await res.json(); expect(data.id).toBe(watcherId); expect(data.deleted_at).toBeTruthy(); + watcherDeleted.parse(data); }); it("DELETE /api/v1/watchers/:id returns 409 for already-deleted watcher", async () => { diff --git a/web/tests/unit/openapi-document.test.ts b/web/tests/unit/openapi-document.test.ts new file mode 100644 index 00000000..04cc73ac --- /dev/null +++ b/web/tests/unit/openapi-document.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + buildOpenApiDocument, + createInstrumentBody, + createRunBody, + requestUploadBody, +} from "@/lib/api/openapi"; + +describe("OpenAPI document", () => { + it("generates the documented API", () => { + const document = buildOpenApiDocument(); + + expect(document.openapi).toBe("3.1.0"); + expect(document.paths).toHaveProperty("/instruments"); + expect(document.paths).toHaveProperty("/openapi.json"); + expect(document.components?.securitySchemes).toHaveProperty("bearerAuth"); + expect(document.tags?.map((tag) => tag.name)).toEqual( + expect.arrayContaining([ + "Meta", + "Instruments", + "Runs", + "Files", + "Watchers", + "Archive", + ]) + ); + }); + + it("documents restore and archive download with the route scopes", () => { + const document = buildOpenApiDocument(); + const restore = + document.paths?.["/instruments/{instrumentId}/runs/{runId}/restore"] + ?.post; + const archive = + document.paths?.[ + "/instruments/{instrumentId}/runs/{runId}/download-archive" + ]?.get; + + expect(restore?.description).toContain("`runs:delete`"); + expect(archive?.description).toContain("`files:read`"); + }); + + it("marks numeric path params as required integers", () => { + const document = buildOpenApiDocument(); + const params = document.paths?.["/files/{fileId}"]?.patch?.parameters ?? []; + const fileId = params.find( + (param) => "name" in param && param.name === "fileId" + ); + + expect(fileId).toMatchObject({ + in: "path", + required: true, + schema: { type: "integer" }, + }); + }); +}); + +describe("request body schemas", () => { + it("trims run_id and rejects blank values", () => { + expect( + createRunBody.parse({ run_id: " abc ", source: "lambda" }).run_id + ).toBe("abc"); + expect( + createRunBody.safeParse({ run_id: " ", source: "lambda" }).success + ).toBe(false); + }); + + it("trims display_name so whitespace falls back in the route", () => { + expect( + createInstrumentBody.parse({ id: "my-instrument", display_name: " " }) + .display_name + ).toBe(""); + }); + + it("requires at least one file_id for upload requests", () => { + expect(requestUploadBody.safeParse({ file_ids: [] }).success).toBe(false); + expect(requestUploadBody.parse({ file_ids: [1] }).file_ids).toEqual([1]); + }); +});