diff --git a/TASK_API.md b/TASK_API.md index be5ae6e..77c65da 100644 --- a/TASK_API.md +++ b/TASK_API.md @@ -182,11 +182,22 @@ All REST API endpoints in this project return JSON with the following top-level - `ok` (boolean): `true` for success, `false` for errors - `error` (string): short human-readable summary +- `code` (string): optional machine-readable error code for programmatic handling - `details` (string[]): optional list of validation-specific messages - `limits` (object): optional object describing relevant request limits -This format is currently implemented for: -- `POST /api/task-submissions/validate` +This format is implemented for all REST API endpoints. Common error codes include: + +- `INVALID_FORM_DATA` - Request body is not valid multipart form data +- `INVALID_JSON` - Request body is not valid JSON +- `INVALID_PAYLOAD` - Request payload is invalid +- `FILE_VALIDATION_FAILED` - File validation failed +- `TASK_CREATION_FAILED` - Task creation failed +- `TASK_NOT_FOUND` - Task not found +- `SUBMISSION_FAILED` - Work submission failed +- `STATS_FETCH_FAILED` - Failed to retrieve dashboard statistics +- `HEALTH_CHECK_FAILED` - Failed to retrieve health status +- `RATE_LIMIT_EXCEEDED` - Rate limit exceeded --- diff --git a/frontend/src/app/api/dashboard/stats/route.ts b/frontend/src/app/api/dashboard/stats/route.ts index 8ae8f23..2737bb2 100644 --- a/frontend/src/app/api/dashboard/stats/route.ts +++ b/frontend/src/app/api/dashboard/stats/route.ts @@ -1,5 +1,5 @@ import { getDashboardStatistics } from "@/lib/dashboard-stats"; -import { buildNoStoreJson } from "@/lib/api-response"; +import { buildErrorResponse, buildNoStoreJson } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -16,15 +16,26 @@ export async function GET(request: Request) { return rateLimitResponse; } - const result = getDashboardStatistics(); + try { + const result = getDashboardStatistics(); - return buildNoStoreJson( - { - ok: true, - stats: result.stats, - meta: result.meta, - }, - 200, - rateLimitHeaders, - ); + return buildNoStoreJson( + { + ok: true, + stats: result.stats, + meta: result.meta, + }, + 200, + rateLimitHeaders, + ); + } catch (error) { + return buildErrorResponse( + "Failed to retrieve dashboard statistics.", + 500, + "STATS_FETCH_FAILED", + error instanceof Error ? [error.message] : undefined, + undefined, + rateLimitHeaders, + ); + } } diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index f235d62..0f8b414 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { buildHealthReport } from "@/lib/health"; +import { buildErrorResponse } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; // Health must always reflect the live process, never a cached/static response. @@ -13,8 +14,19 @@ export async function GET(request: Request) { return rateLimitResponse; } - return NextResponse.json(buildHealthReport(), { - status: 200, - headers: { "Cache-Control": "no-store", ...rateLimitHeaders }, - }); + try { + return NextResponse.json(buildHealthReport(), { + status: 200, + headers: { "Cache-Control": "no-store", ...rateLimitHeaders }, + }); + } catch (error) { + return buildErrorResponse( + "Failed to retrieve health status.", + 500, + "HEALTH_CHECK_FAILED", + error instanceof Error ? [error.message] : undefined, + undefined, + rateLimitHeaders, + ); + } } diff --git a/frontend/src/app/api/task-submissions/validate/route.ts b/frontend/src/app/api/task-submissions/validate/route.ts index d828f08..9df1520 100644 --- a/frontend/src/app/api/task-submissions/validate/route.ts +++ b/frontend/src/app/api/task-submissions/validate/route.ts @@ -7,6 +7,7 @@ import { MAX_TASK_SUBMISSION_TOTAL_SIZE_BYTES, validateTaskSubmissionFiles, } from "@/lib/task-submission-files"; +import { buildErrorResponse } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -35,12 +36,12 @@ export async function POST(request: Request) { try { formData = await request.formData(); } catch { - return buildNoStoreJson( - { - ok: false, - error: "Please upload files using a valid form.", - }, + return buildErrorResponse( + "Please upload files using a valid form.", 400, + "INVALID_FORM_DATA", + undefined, + undefined, rateLimitHeaders, ); } @@ -49,18 +50,16 @@ export async function POST(request: Request) { const validation = await validateTaskSubmissionFiles(files); if (!validation.ok) { - return buildNoStoreJson( + return buildErrorResponse( + "Invalid task submission upload.", + validation.status, + "FILE_VALIDATION_FAILED", + validation.errors, { - ok: false, - error: "Invalid task submission upload.", - details: validation.errors, - limits: { - maxFiles: MAX_TASK_SUBMISSION_FILES, - maxFileSizeBytes: MAX_TASK_SUBMISSION_FILE_SIZE_BYTES, - maxTotalSizeBytes: MAX_TASK_SUBMISSION_TOTAL_SIZE_BYTES, - }, + maxFiles: MAX_TASK_SUBMISSION_FILES, + maxFileSizeBytes: MAX_TASK_SUBMISSION_FILE_SIZE_BYTES, + maxTotalSizeBytes: MAX_TASK_SUBMISSION_TOTAL_SIZE_BYTES, }, - validation.status, rateLimitHeaders, ); } diff --git a/frontend/src/app/api/tasks/[taskId]/route.ts b/frontend/src/app/api/tasks/[taskId]/route.ts index 71f0d76..8a6c333 100644 --- a/frontend/src/app/api/tasks/[taskId]/route.ts +++ b/frontend/src/app/api/tasks/[taskId]/route.ts @@ -1,5 +1,5 @@ import { getTask } from "@/lib/task-workflow"; -import { buildNoStoreJson } from "@/lib/api-response"; +import { buildErrorResponse, buildNoStoreJson } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -20,12 +20,12 @@ export async function GET(request: Request, context: RouteContext) { const result = getTask(taskId); if (!result.ok) { - return buildNoStoreJson( - { - ok: false, - error: result.error, - }, + return buildErrorResponse( + result.error, result.status, + "TASK_NOT_FOUND", + undefined, + undefined, rateLimitHeaders, ); } diff --git a/frontend/src/app/api/tasks/[taskId]/submissions/route.ts b/frontend/src/app/api/tasks/[taskId]/submissions/route.ts index aa05403..105bee1 100644 --- a/frontend/src/app/api/tasks/[taskId]/submissions/route.ts +++ b/frontend/src/app/api/tasks/[taskId]/submissions/route.ts @@ -6,7 +6,7 @@ import { validateTaskSubmissionFiles, } from "@/lib/task-submission-files"; import { submitTaskWork } from "@/lib/task-workflow"; -import { buildNoStoreJson } from "@/lib/api-response"; +import { buildErrorResponse, buildNoStoreJson } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -30,12 +30,12 @@ export async function POST(request: Request, context: RouteContext) { try { formData = await request.formData(); } catch { - return buildNoStoreJson( - { - ok: false, - error: "Please submit work using a valid multipart form.", - }, + return buildErrorResponse( + "Please submit work using a valid multipart form.", 400, + "INVALID_FORM_DATA", + undefined, + undefined, rateLimitHeaders, ); } @@ -48,18 +48,16 @@ export async function POST(request: Request, context: RouteContext) { const validation = await validateTaskSubmissionFiles(files); if (!validation.ok) { - return buildNoStoreJson( + return buildErrorResponse( + "Invalid task submission upload.", + validation.status, + "FILE_VALIDATION_FAILED", + validation.errors, { - ok: false, - error: "Invalid task submission upload.", - details: validation.errors, - limits: { - maxFiles: MAX_TASK_SUBMISSION_FILES, - maxFileSizeBytes: MAX_TASK_SUBMISSION_FILE_SIZE_BYTES, - maxTotalSizeBytes: MAX_TASK_SUBMISSION_TOTAL_SIZE_BYTES, - }, + maxFiles: MAX_TASK_SUBMISSION_FILES, + maxFileSizeBytes: MAX_TASK_SUBMISSION_FILE_SIZE_BYTES, + maxTotalSizeBytes: MAX_TASK_SUBMISSION_TOTAL_SIZE_BYTES, }, - validation.status, rateLimitHeaders, ); } @@ -75,13 +73,12 @@ export async function POST(request: Request, context: RouteContext) { ); if (!result.ok) { - return buildNoStoreJson( - { - ok: false, - error: result.error, - details: result.details, - }, + return buildErrorResponse( + result.error, result.status, + "SUBMISSION_FAILED", + result.details, + undefined, rateLimitHeaders, ); } diff --git a/frontend/src/app/api/tasks/route.ts b/frontend/src/app/api/tasks/route.ts index 5746c85..85d7f2f 100644 --- a/frontend/src/app/api/tasks/route.ts +++ b/frontend/src/app/api/tasks/route.ts @@ -1,3 +1,5 @@ +import { createTask } from "@/lib/task-workflow"; +import { buildErrorResponse, buildNoStoreJson } from "@/lib/api-response"; import { createTask, listTasks } from "@/lib/task-workflow"; import { buildNoStoreJson } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -71,24 +73,23 @@ export async function POST(request: Request) { try { body = await request.json(); } catch { - return buildNoStoreJson( - { - ok: false, - error: "Request body must be valid JSON.", - }, + return buildErrorResponse( + "Request body must be valid JSON.", 400, + "INVALID_JSON", + undefined, + undefined, rateLimitHeaders, ); } if (!body || typeof body !== "object") { - return buildNoStoreJson( - { - ok: false, - error: "Invalid task payload.", - details: ["Request body must be a JSON object."], - }, + return buildErrorResponse( + "Invalid task payload.", 400, + "INVALID_PAYLOAD", + ["Request body must be a JSON object."], + undefined, rateLimitHeaders, ); } @@ -113,13 +114,12 @@ export async function POST(request: Request) { }); if (!result.ok) { - return buildNoStoreJson( - { - ok: false, - error: result.error, - details: result.details, - }, + return buildErrorResponse( + result.error, result.status, + "TASK_CREATION_FAILED", + result.details, + undefined, rateLimitHeaders, ); } diff --git a/frontend/src/lib/api-response.test.ts b/frontend/src/lib/api-response.test.ts new file mode 100644 index 0000000..bf5b46d --- /dev/null +++ b/frontend/src/lib/api-response.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { buildErrorResponse, ApiErrorResponse } from "./api-response"; + +describe("buildErrorResponse", () => { + it("returns a NextResponse with correct status", () => { + const response = buildErrorResponse("Test error", 400); + expect(response.status).toBe(400); + }); + + it("includes Cache-Control: no-store header", () => { + const response = buildErrorResponse("Test error", 400); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("includes Content-Type: application/json header", () => { + const response = buildErrorResponse("Test error", 400); + expect(response.headers.get("Content-Type")).toBe("application/json"); + }); + + it("returns JSON body with ok: false", async () => { + const response = buildErrorResponse("Test error", 400); + const body = await response.json(); + + expect(body.ok).toBe(false); + }); + + it("includes error message in body", async () => { + const response = buildErrorResponse("Test error message", 400); + const body = await response.json() as ApiErrorResponse; + + expect(body.error).toBe("Test error message"); + }); + + it("includes error code when provided", async () => { + const response = buildErrorResponse("Test error", 400, "INVALID_INPUT"); + const body = await response.json() as ApiErrorResponse; + + expect(body.code).toBe("INVALID_INPUT"); + }); + + it("omits error code when not provided", async () => { + const response = buildErrorResponse("Test error", 400); + const body = await response.json() as ApiErrorResponse; + + expect(body.code).toBeUndefined(); + }); + + it("includes details array when provided", async () => { + const details = ["Field required", "Invalid format"]; + const response = buildErrorResponse("Test error", 400, undefined, details); + const body = await response.json() as ApiErrorResponse; + + expect(body.details).toEqual(details); + }); + + it("omits details array when not provided", async () => { + const response = buildErrorResponse("Test error", 400); + const body = await response.json() as ApiErrorResponse; + + expect(body.details).toBeUndefined(); + }); + + it("includes limits object when provided", async () => { + const limits = { maxFiles: 5, maxSize: 10485760 }; + const response = buildErrorResponse("Test error", 413, undefined, undefined, limits); + const body = await response.json() as ApiErrorResponse; + + expect(body.limits).toEqual(limits); + }); + + it("omits limits object when not provided", async () => { + const response = buildErrorResponse("Test error", 400); + const body = await response.json() as ApiErrorResponse; + + expect(body.limits).toBeUndefined(); + }); + + it("merges extra headers into response", () => { + const response = buildErrorResponse( + "Test error", + 400, + undefined, + undefined, + undefined, + { "X-Custom-Header": "custom-value" }, + ); + + expect(response.headers.get("X-Custom-Header")).toBe("custom-value"); + }); + + it("includes all fields when fully populated", async () => { + const response = buildErrorResponse( + "Validation failed", + 422, + "VALIDATION_ERROR", + ["Email is invalid", "Password too short"], + { maxAttempts: 3 }, + { "X-Retry-After": "60" }, + ); + const body = await response.json() as ApiErrorResponse; + + expect(body).toEqual({ + ok: false, + error: "Validation failed", + code: "VALIDATION_ERROR", + details: ["Email is invalid", "Password too short"], + limits: { maxAttempts: 3 }, + }); + expect(response.headers.get("X-Retry-After")).toBe("60"); + }); + + it("defaults to status 500 when not provided", () => { + const response = buildErrorResponse("Server error"); + expect(response.status).toBe(500); + }); +}); + +describe("ApiErrorResponse interface", () => { + it("defines required fields correctly", () => { + const error: ApiErrorResponse = { + ok: false, + error: "Test error", + }; + + expect(error.ok).toBe(false); + expect(error.error).toBe("Test error"); + expect(error.code).toBeUndefined(); + expect(error.details).toBeUndefined(); + expect(error.limits).toBeUndefined(); + }); + + it("allows optional fields", () => { + const error: ApiErrorResponse = { + ok: false, + error: "Test error", + code: "ERROR_CODE", + details: ["Detail 1"], + limits: { limit: 10 }, + }; + + expect(error.code).toBe("ERROR_CODE"); + expect(error.details).toEqual(["Detail 1"]); + expect(error.limits).toEqual({ limit: 10 }); + }); +}); diff --git a/frontend/src/lib/api-response.ts b/frontend/src/lib/api-response.ts index 5dc5df1..ef7ac38 100644 --- a/frontend/src/lib/api-response.ts +++ b/frontend/src/lib/api-response.ts @@ -13,3 +13,40 @@ export function buildNoStoreJson( headers: { "Cache-Control": "no-store", ...extraHeaders }, }); } + +/** + * Standardized error response format for all API endpoints + */ +export interface ApiErrorResponse { + ok: false; + error: string; + code?: string; + details?: string[]; + limits?: Record; +} + +/** + * Builds a standardized error response + */ +export function buildErrorResponse( + error: string, + status: number = 500, + code?: string, + details?: string[], + limits?: Record, + extraHeaders: Record = {}, +): NextResponse { + const body: ApiErrorResponse = { + ok: false, + error, + }; + + if (code) body.code = code; + if (details) body.details = details; + if (limits) body.limits = limits; + + return NextResponse.json(body, { + status, + headers: { "Cache-Control": "no-store", ...extraHeaders }, + }); +} diff --git a/frontend/src/lib/rate-limit.ts b/frontend/src/lib/rate-limit.ts index b18092f..6db2000 100644 --- a/frontend/src/lib/rate-limit.ts +++ b/frontend/src/lib/rate-limit.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; +import { buildErrorResponse } from "./api-response"; + const DEFAULT_LIMIT = 60; const DEFAULT_WINDOW_MS = 60_000; const RATE_LIMIT_WINDOW_MS_ENV = "API_RATE_LIMIT_WINDOW_MS"; @@ -114,22 +116,20 @@ export function checkRateLimit(request: Request): RateLimitResult { if (existing.count >= maxRequests) { const retryAfterSeconds = Math.max(1, Math.ceil((existing.resetAt - now) / 1000)); + const headers = { + "Retry-After": String(retryAfterSeconds), + "X-RateLimit-Limit": String(maxRequests), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": String(Math.ceil(existing.resetAt / 1000)), + }; return { - response: new NextResponse( - JSON.stringify({ - ok: false, - error: "Too many requests. Please try again later.", - }), - { - status: 429, - headers: { - "Content-Type": "application/json", - "Retry-After": String(retryAfterSeconds), - "X-RateLimit-Limit": String(maxRequests), - "X-RateLimit-Remaining": "0", - "X-RateLimit-Reset": String(Math.ceil(existing.resetAt / 1000)), - }, - }, + response: buildErrorResponse( + "Too many requests. Please try again later.", + 429, + "RATE_LIMIT_EXCEEDED", + undefined, + undefined, + headers, ), headers: {}, };