/** * @fileoverview Utility functions for Aurora API route handlers. * Provides helpers for response formatting, parameter parsing, and validation. */ import { z, ZodError, type ZodSchema } from "zod"; import type { ApiErrorResponse } from "./types"; /** * JSON replacer function that handles BigInt serialization. * Converts BigInt values to strings for JSON compatibility. */ export function jsonReplacer(_key: string, value: unknown): unknown { return typeof value === "bigint" ? value.toString() : value; } /** * Creates a JSON response with proper content-type header and BigInt handling. * * @param data - The data to serialize as JSON * @param status - HTTP status code (default: 200) * @returns A Response object with JSON content * * @example * return jsonResponse({ items: [...], total: 10 }); * return jsonResponse({ success: true, item }, 201); */ export function jsonResponse(data: T, status: number = 200): Response { return new Response(JSON.stringify(data, jsonReplacer), { status, headers: { "Content-Type": "application/json" } }); } /** * Creates a standardized error response. * * @param error - Error message * @param status - HTTP status code (default: 500) * @param details - Optional additional error details * @returns A Response object with error JSON * * @example * return errorResponse("Item not found", 404); * return errorResponse("Validation failed", 400, "Name is required"); */ export function errorResponse( error: string, status: number = 500, details?: string ): Response { const body: ApiErrorResponse = { error }; if (details) body.details = details; return Response.json(body, { status }); } /** * Creates a validation error response from a ZodError. * * @param zodError - The ZodError from a failed parse * @returns A 400 Response with validation issue details */ export function validationErrorResponse(zodError: ZodError): Response { return Response.json( { error: "Invalid payload", issues: zodError.issues.map(issue => ({ path: issue.path, message: issue.message })) }, { status: 400 } ); } /** * Parses and validates a request body against a Zod schema. * * @param req - The HTTP request * @param schema - Zod schema to validate against * @returns Validated data or an error Response * * @example * const result = await parseBody(req, CreateItemSchema); * if (result instanceof Response) return result; // Validation failed * const data = result; // Type-safe validated data */ export async function parseBody( req: Request, schema: T ): Promise | Response> { try { const rawBody = await req.json(); const parsed = schema.safeParse(rawBody); if (!parsed.success) { return validationErrorResponse(parsed.error); } return parsed.data; } catch (e) { return errorResponse("Invalid JSON body", 400); } } /** * Parses query parameters against a Zod schema. * * @param url - The URL containing query parameters * @param schema - Zod schema to validate against * @returns Validated query params or an error Response */ export function parseQuery( url: URL, schema: T ): z.infer | Response { const params: Record = {}; url.searchParams.forEach((value, key) => { params[key] = value; }); const parsed = schema.safeParse(params); if (!parsed.success) { return validationErrorResponse(parsed.error); } return parsed.data; } /** * Extracts a numeric ID from a URL path segment. * * @param pathname - The URL pathname * @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.) * @returns The parsed integer ID or null if invalid * * @example * parseIdFromPath("/api/items/123") // returns 123 * parseIdFromPath("/api/items/abc") // returns null * parseIdFromPath("/api/users/456/inventory", 1) // returns 456 */ export function parseIdFromPath(pathname: string, position: number = 0): number | null { const segments = pathname.split("/").filter(Boolean); const segment = segments[segments.length - 1 - position]; if (!segment) return null; const id = parseInt(segment, 10); return isNaN(id) ? null : id; } /** * Extracts a string ID (like Discord snowflake) from a URL path segment. * * @param pathname - The URL pathname * @param position - Position from the end (0 = last segment) * @returns The string ID or null if segment doesn't exist */ export function parseStringIdFromPath(pathname: string, position: number = 0): string | null { const segments = pathname.split("/").filter(Boolean); const segment = segments[segments.length - 1 - position]; return segment || null; } /** * Checks if a pathname matches a pattern with optional parameter placeholders. * * @param pathname - The actual URL pathname * @param pattern - The pattern to match (use :id for numeric params, :param for string params) * @returns True if the pattern matches * * @example * matchPath("/api/items/123", "/api/items/:id") // true * matchPath("/api/items", "/api/items/:id") // false */ export function matchPath(pathname: string, pattern: string): boolean { const pathParts = pathname.split("/").filter(Boolean); const patternParts = pattern.split("/").filter(Boolean); if (pathParts.length !== patternParts.length) return false; return patternParts.every((part, i) => { if (part.startsWith(":")) return true; // Matches any value return part === pathParts[i]; }); } /** * Wraps an async route handler with consistent error handling. * Catches all errors and returns appropriate error responses. * * @param handler - The async handler function * @param logContext - Context string for error logging * @returns A wrapped handler with error handling */ export function withErrorHandling( handler: () => Promise, logContext: string ): Promise { return handler().catch((error: unknown) => { // Dynamic import to avoid circular dependencies return import("@shared/lib/logger").then(({ logger }) => { logger.error("web", `Error in ${logContext}`, error); return errorResponse( `Failed to ${logContext.toLowerCase()}`, 500, error instanceof Error ? error.message : String(error) ); }); }); }