import { useCallback, useState } from "react"; import { writeOutcomeUnknown } from "../lib/request-intent"; interface ApiState { data: T | null; loading: boolean; error: string | null; } /** * Safely parse JSON response, checking Content-Type first. */ async function parseJsonSafe( res: Response ): Promise<{ json: unknown; parseError: string | null }> { const ct = res.headers.get("content-type") ?? ""; const isJson = ct.includes("application/json"); if (!isJson) { // Non-JSON response - return text as error context const text = await res.text(); return { json: null, parseError: text.slice(0, 200) || `Non-JSON response: ${res.status}`, }; } try { const json = await res.json(); return { json, parseError: null }; } catch { return { json: null, parseError: "Invalid JSON response" }; } } export function useApi() { const [state, setState] = useState>({ data: null, loading: false, error: null, }); const request = useCallback( async (endpoint: string, options?: RequestInit): Promise => { setState({ data: null, loading: true, error: null }); try { const res = await fetch(endpoint, { headers: { "Content-Type": "application/json" }, ...options, }); const { json, parseError } = await parseJsonSafe(res); if (parseError) { setState({ data: null, loading: false, error: parseError }); return null; } if (!res.ok) { const apiError = json as { error?: { message?: string } }; const msg = apiError.error?.message || `Request failed: ${res.status}`; setState({ data: null, loading: false, error: msg }); return null; } setState({ data: json as T, loading: false, error: null }); return json as T; } catch (err) { const msg = err instanceof Error ? err.message : "Network error"; setState({ data: null, loading: false, error: msg }); return null; } }, [] ); return { ...state, request }; } export async function apiFetch( endpoint: string, options?: RequestInit ): Promise<{ data: T | null; error: string | null; /** `error.details` of the JSON error envelope, when the server sent one. */ details?: Record; /** * A write may or may not have committed: no readable response, a server * error, or the request is still pending under its request ID. */ outcomeUnknown?: true; }> { try { const res = await fetch(endpoint, { headers: { "Content-Type": "application/json" }, ...options, }); const { json, parseError } = await parseJsonSafe(res); if (parseError) { return { data: null, error: parseError, outcomeUnknown: true }; } if (!res.ok) { const apiError = json as { error?: { code?: string; message?: string; details?: Record; }; }; return { data: null, error: apiError.error?.message || `Request failed: ${res.status}`, details: apiError.error?.details, ...(writeOutcomeUnknown(res.status, apiError.error?.code) ? { outcomeUnknown: true as const } : {}), }; } return { data: json as T, error: null }; } catch (err) { return { data: null, error: err instanceof Error ? err.message : "Network error", outcomeUnknown: true, }; } }