// Typed fetch wrapper for the GuruConnect API. // // Responsibilities: // - Resolve the base URL (VITE_API_URL, default same-origin). // - Attach `Authorization: Bearer ` from a pluggable token provider. // - Normalize the *two* inconsistent server error envelopes into one // ApiError shape so callers/UI never have to branch on which one came back. const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); /** A normalized API error. `code` is present only for the structured envelope. */ export class ApiError extends Error { readonly status: number; readonly code?: string; constructor(message: string, status: number, code?: string) { super(message); this.name = "ApiError"; this.status = status; this.code = code; } } // The token lives in memory in the auth layer. We read it through a provider so // the client has no hard dependency on React state and stays testable. let tokenProvider: () => string | null = () => null; export function setTokenProvider(provider: () => string | null): void { tokenProvider = provider; } // Called when any request returns 401 — lets the auth layer tear down session // state and bounce to /login. Set by AuthProvider. let onUnauthorized: (() => void) | null = null; export function setUnauthorizedHandler(handler: (() => void) | null): void { onUnauthorized = handler; } interface RequestOptions { method?: string; body?: unknown; // Suppress the global 401 handler (used by the login call itself). skipAuthRedirect?: boolean; signal?: AbortSignal; } /** The server's two error envelopes, unioned. We extract a message from either. */ interface ErrorEnvelope { error?: string; detail?: string; error_code?: string; status_code?: number; } function buildUrl(path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) return path; return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; } async function extractError(res: Response): Promise { let message = `Request failed (${res.status})`; let code: string | undefined; const raw = await res.text(); if (raw) { try { const env = JSON.parse(raw) as ErrorEnvelope; // Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`. const msg = env.detail ?? env.error; if (typeof msg === "string" && msg.length > 0) message = msg; if (typeof env.error_code === "string") code = env.error_code; } catch { // Non-JSON body (e.g. the machines routes return plain &'static str on // error). Use the trimmed text as the message if it looks sane. const trimmed = raw.trim(); if (trimmed && trimmed.length < 300) message = trimmed; } } return new ApiError(message, res.status, code); } async function request(path: string, opts: RequestOptions = {}): Promise { const headers: Record = {}; const token = tokenProvider(); if (token) headers["Authorization"] = `Bearer ${token}`; let body: BodyInit | undefined; if (opts.body !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(opts.body); } let res: Response; try { res = await fetch(buildUrl(path), { method: opts.method ?? "GET", headers, body, signal: opts.signal, }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") throw err; throw new ApiError("Network error — could not reach the server.", 0); } if (res.status === 401 && !opts.skipAuthRedirect) { onUnauthorized?.(); } if (!res.ok) { throw await extractError(res); } // 204 No Content / empty body. if (res.status === 204) return undefined as T; const text = await res.text(); if (!text) return undefined as T; // Most success responses are JSON, but some routes return a plain-text body // on 200 (e.g. cancel returns "Code cancelled"). Tolerate non-JSON so a // successful call isn't surfaced as a SyntaxError failure. try { return JSON.parse(text) as T; } catch { return undefined as T; } } export const http = { get: (path: string, signal?: AbortSignal) => request(path, { method: "GET", signal }), post: (path: string, body?: unknown, opts?: Partial) => request(path, { method: "POST", body, ...opts }), put: (path: string, body?: unknown) => request(path, { method: "PUT", body }), del: (path: string) => request(path, { method: "DELETE" }), };