Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions e2e/error-handling.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test, expect } from "@playwright/test";

test.describe("structured API error responses", () => {
test("v1 users endpoint returns 404 with standard error shape for unknown user", async ({ request }) => {
const res = await request.get("/api/v1/users/this-user-does-not-exist-e2e");
expect(res.status()).toBe(404);

const body = await res.json();
expect(body).toMatchObject({
error: expect.any(String),
code: expect.any(String),
status: 404,
timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
});
});

test("v1 users endpoint returns 400 with standard error shape for invalid username", async ({ request }) => {
const res = await request.get("/api/v1/users/%00invalid");
expect(res.status()).toBe(400);

const body = await res.json();
expect(body).toMatchObject({
error: expect.any(String),
code: "VALIDATION_ERROR",
status: 400,
timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
});
});

test("refresh endpoint returns 429 with retryAfterSeconds for rate limited requests", async ({ request }) => {
// First request should succeed (or 404 if user doesn't exist)
const res1 = await request.post("/api/test/refresh");
// We expect either 429 (rate limited) or 400 (invalid) — both should have structured errors
expect([400, 404, 429, 401, 200]).toContain(res1.status());

Check failure on line 34 in e2e/error-handling.spec.ts

View workflow job for this annotation

GitHub Actions / playwright

[chromium] › e2e/error-handling.spec.ts:30:7 › structured API error responses › refresh endpoint returns 429 with retryAfterSeconds for rate limited requests

1) [chromium] › e2e/error-handling.spec.ts:30:7 › structured API error responses › refresh endpoint returns 429 with retryAfterSeconds for rate limited requests Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toContain(expected) // indexOf Expected value: 500 Received array: [400, 404, 429, 401, 200] 32 | const res1 = await request.post("/api/test/refresh"); 33 | // We expect either 429 (rate limited) or 400 (invalid) — both should have structured errors > 34 | expect([400, 404, 429, 401, 200]).toContain(res1.status()); | ^ 35 | 36 | if (res1.status() !== 200) { 37 | const body = await res1.json(); at /home/runner/work/ossfolio/ossfolio/e2e/error-handling.spec.ts:34:39

Check failure on line 34 in e2e/error-handling.spec.ts

View workflow job for this annotation

GitHub Actions / playwright

[chromium] › e2e/error-handling.spec.ts:30:7 › structured API error responses › refresh endpoint returns 429 with retryAfterSeconds for rate limited requests

1) [chromium] › e2e/error-handling.spec.ts:30:7 › structured API error responses › refresh endpoint returns 429 with retryAfterSeconds for rate limited requests Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toContain(expected) // indexOf Expected value: 500 Received array: [400, 404, 429, 401, 200] 32 | const res1 = await request.post("/api/test/refresh"); 33 | // We expect either 429 (rate limited) or 400 (invalid) — both should have structured errors > 34 | expect([400, 404, 429, 401, 200]).toContain(res1.status()); | ^ 35 | 36 | if (res1.status() !== 200) { 37 | const body = await res1.json(); at /home/runner/work/ossfolio/ossfolio/e2e/error-handling.spec.ts:34:39

if (res1.status() !== 200) {
const body = await res1.json();
expect(body).toMatchObject({
error: expect.any(String),
code: expect.any(String),
status: res1.status(),
timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
});
if (res1.status() === 429) {
expect(body).toHaveProperty("retryAfterSeconds");
expect(typeof body.retryAfterSeconds).toBe("number");
}
}
});

test("404 route returns proper error shape", async ({ request }) => {
const res = await request.get("/api/nonexistent-route-e2e");
expect(res.status()).toBe(404);

const body = await res.json();

Check failure on line 55 in e2e/error-handling.spec.ts

View workflow job for this annotation

GitHub Actions / playwright

[chromium] › e2e/error-handling.spec.ts:51:7 › structured API error responses › 404 route returns proper error shape

2) [chromium] › e2e/error-handling.spec.ts:51:7 › structured API error responses › 404 route returns proper error shape Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON 53 | expect(res.status()).toBe(404); 54 | > 55 | const body = await res.json(); | ^ 56 | if (body && typeof body === "object" && "error" in body) { 57 | expect(body).toMatchObject({ 58 | error: expect.any(String), at /home/runner/work/ossfolio/ossfolio/e2e/error-handling.spec.ts:55:18

Check failure on line 55 in e2e/error-handling.spec.ts

View workflow job for this annotation

GitHub Actions / playwright

[chromium] › e2e/error-handling.spec.ts:51:7 › structured API error responses › 404 route returns proper error shape

2) [chromium] › e2e/error-handling.spec.ts:51:7 › structured API error responses › 404 route returns proper error shape Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON 53 | expect(res.status()).toBe(404); 54 | > 55 | const body = await res.json(); | ^ 56 | if (body && typeof body === "object" && "error" in body) { 57 | expect(body).toMatchObject({ 58 | error: expect.any(String), at /home/runner/work/ossfolio/ossfolio/e2e/error-handling.spec.ts:55:18
if (body && typeof body === "object" && "error" in body) {
expect(body).toMatchObject({
error: expect.any(String),
status: 404,
timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
});
}
});
});
82 changes: 82 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export class AppError extends Error {
public readonly status: number;
public readonly code: string;
public readonly details?: Record<string, unknown>;
public readonly retryAfterSeconds?: number;

constructor(
message: string,
status: number,
code: string,
details?: Record<string, unknown>,
retryAfterSeconds?: number
) {
super(message);
this.name = "AppError";
this.status = status;
this.code = code;
this.details = details;
this.retryAfterSeconds = retryAfterSeconds;
}
}

export class ValidationError extends AppError {
constructor(message = "Validation failed", details?: Record<string, unknown>) {
super(message, 400, "VALIDATION_ERROR", details);
this.name = "ValidationError";
}
}

export class AuthError extends AppError {
constructor(message = "Unauthorized", details?: Record<string, unknown>) {
super(message, 401, "AUTH_ERROR", details);
this.name = "AuthError";
}
}

export class NotFoundError extends AppError {
constructor(message = "Resource not found", details?: Record<string, unknown>) {
super(message, 404, "NOT_FOUND", details);
this.name = "NotFoundError";
}
}

export class RateLimitError extends AppError {
constructor(
message = "Rate limit exceeded",
retryAfterSeconds: number,
details?: Record<string, unknown>
) {
super(message, 429, "RATE_LIMITED", details, retryAfterSeconds);
this.name = "RateLimitError";
}
}

export class UpstreamError extends AppError {
constructor(message = "Upstream service unavailable", details?: Record<string, unknown>) {
super(message, 502, "UPSTREAM_ERROR", details);
this.name = "UpstreamError";
}
}

export interface ApiErrorBody {
error: string;
code: string;
status: number;
timestamp: string;
details?: Record<string, unknown>;
retryAfterSeconds?: number;
}

export function toApiErrorBody(error: AppError): ApiErrorBody {
return {
error: error.message,
code: error.code,
status: error.status,
timestamp: new Date().toISOString(),
...(error.details ? { details: error.details } : {}),
...(error.retryAfterSeconds !== undefined
? { retryAfterSeconds: error.retryAfterSeconds }
: {}),
};
}
109 changes: 44 additions & 65 deletions src/lib/validators/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { sanitizeString } from "../sanitizer";
import type { AppError } from "@/lib/errors";
import { toApiErrorBody } from "@/lib/errors";

export function sanitizeUsername(value: unknown): string | null {
if (typeof value !== "string") return null;
Expand Down Expand Up @@ -32,14 +33,6 @@ export function validateYear(value: unknown): number | null {
return parsed;
}

export function validateEmail(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim().toLowerCase();
if (trimmed.length > 254) return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null;
return trimmed;
}

export function validatePagination(
page: unknown,
pageSize: unknown,
Expand All @@ -63,37 +56,6 @@ export function validateSortBy<T extends string>(
return (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
}

export function sanitizeObject<T extends Record<string, unknown>>(
obj: T,
schema: Record<string, { type: "string" | "number" | "boolean" | "array" | "object"; maxLength?: number; required?: boolean }>
): { data: Partial<T>; errors: string[] } {
const data: Partial<T> = {};
const errors: string[] = [];

for (const [key, rule] of Object.entries(schema)) {
const value = obj[key as keyof T];
if (value === undefined || value === null) {
if (rule.required) errors.push(`${key} is required`);
continue;
}
if (rule.type === "string" && typeof value === "string") {
data[key as keyof T] = sanitizeString(value, rule.maxLength ?? 500) as T[keyof T];
} else if (rule.type === "number" && typeof value === "number") {
data[key as keyof T] = value;
} else if (rule.type === "boolean" && typeof value === "boolean") {
data[key as keyof T] = value;
} else if (rule.type === "array" && Array.isArray(value)) {
data[key as keyof T] = value.slice(0, rule.maxLength ?? 50) as T[keyof T];
} else if (rule.type === "object" && typeof value === "object" && !Array.isArray(value)) {
data[key as keyof T] = value;
} else {
errors.push(`${key} has invalid type`);
}
}

return { data, errors };
}

export function createApiResponse<T>(data: T, status = 200, extraHeaders?: Record<string, string>): NextResponse {
return NextResponse.json(data, {
status,
Expand All @@ -110,32 +72,49 @@ export function createErrorResponse(
error: string,
status = 400,
extra?: Record<string, unknown>,
// Optional extra response headers. Added so a 429 can carry the standard `Retry-After`
// header, which well-behaved clients, crawlers and proxies honour without needing to
// parse the body. Existing callers are unaffected.
headers?: Record<string, string>
): NextResponse {
return NextResponse.json(
{ error, ...extra },
{
status,
headers: {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
...headers,
},
}
);
const body: Record<string, unknown> = {
error,
code: errorCodeForStatus(status),
status,
timestamp: new Date().toISOString(),
...extra,
};
return NextResponse.json(body, {
status,
headers: {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
...headers,
},
});
}

export const COMMON_SCHEMAS = {
pagination: {
page: { type: "number" as const },
pageSize: { type: "number" as const },
},
profile: {
headline: { type: "string" as const, maxLength: 160 },
bio: { type: "string" as const, maxLength: 500 },
location: { type: "string" as const, maxLength: 100 },
},
};
function errorCodeForStatus(status: number): string {
switch (status) {
case 400: return "VALIDATION_ERROR";
case 401: return "AUTH_ERROR";
case 404: return "NOT_FOUND";
case 429: return "RATE_LIMITED";
case 502: return "UPSTREAM_ERROR";
case 503: return "SERVICE_UNAVAILABLE";
default: return "INTERNAL_ERROR";
}
}

export function apiErrorResponse(error: AppError, extraHeaders?: Record<string, string>): NextResponse {
const body = toApiErrorBody(error);
const headers: Record<string, string> = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
...extraHeaders,
};
if (error.retryAfterSeconds !== undefined) {
headers["Retry-After"] = String(error.retryAfterSeconds);
}
return NextResponse.json(body, {
status: error.status,
headers,
});
}
Loading