/** * Minimal frontend-facing API client for the Lieferscheine backend. * * Goals: * - Centralize fetch defaults (credentials + no-store) to match backend caching rules. * - Provide a single, predictable error shape via ApiClientError. * - Offer thin domain helpers (login/logout/branches/...). * * Notes: * - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention". * - This client is intentionally small. It is not an SDK. */ /** * @typedef {Object} ApiErrorPayload * @property {{ message: string, code: string, details?: any }} error */ /** * A standardized client-side error type for API failures. * - status: HTTP status code (e.g. 401, 403, 404, 500) * - code: machine-readable error code (e.g. AUTH_UNAUTHENTICATED) * - message: human-readable message (safe to show in UI) * - details: optional structured payload (validation params etc.) */ export class ApiClientError extends Error { /** * @param {{ * status: number, * code: string, * message: string, * details?: any, * url?: string, * method?: string, * cause?: any * }} input */ constructor({ status, code, message, details, url, method, cause }) { super(message, cause ? { cause } : undefined); this.name = "ApiClientError"; this.status = status; this.code = code; if (details !== undefined) this.details = details; if (url) this.url = url; if (method) this.method = method; } } const DEFAULT_HEADERS = { Accept: "application/json", }; /** * Resolve a request URL. * - If `path` is absolute (http/https), return as-is. * - If `baseUrl` is provided, resolve relative to it. * - Otherwise return the relative path (browser-friendly: "/api/..."). * * @param {string} path * @param {string=} baseUrl * @returns {string} */ function resolveUrl(path, baseUrl) { if (/^https?:\/\//i.test(path)) return path; const base = (baseUrl || "").trim(); if (!base) return path; return new URL(path, base.endsWith("/") ? base : `${base}/`).toString(); } /** * Best-effort detection if response is JSON. * * @param {Response} response * @returns {boolean} */ function isJsonResponse(response) { const ct = response.headers.get("content-type") || ""; return ct.toLowerCase().includes("application/json"); } /** * Core fetch helper with: * - credentials: "include" (cookie-based session) * - cache: "no-store" (match backend freshness strategy) * - standardized error mapping into ApiClientError * * @param {string} path * @param {{ * method?: string, * headers?: Record, * body?: any, * baseUrl?: string, * fetchImpl?: typeof fetch * }=} options * @returns {Promise} parsed JSON payload (or null for empty responses) */ export async function apiFetch(path, options = {}) { const { method = "GET", headers = {}, body, baseUrl, fetchImpl = fetch, } = options; const url = resolveUrl(path, baseUrl); // Build request init. We always set credentials + no-store. // For JSON bodies, we serialize and set Content-Type. const init = { method, credentials: "include", cache: "no-store", headers: { ...DEFAULT_HEADERS, ...headers }, }; if (body !== undefined) { // If the caller passes a string, we assume it is already serialized. // Otherwise we serialize as JSON. if (typeof body === "string") { init.body = body; } else { init.body = JSON.stringify(body); // Only set Content-Type if caller didn't provide it. if (!init.headers["Content-Type"]) { init.headers["Content-Type"] = "application/json"; } } } let response; try { response = await fetchImpl(url, init); } catch (err) { // Network errors, DNS errors, connection refused, etc. throw new ApiClientError({ status: 0, code: "CLIENT_NETWORK_ERROR", message: "Network error", url, method, cause: err, }); } // Handle empty responses explicitly (e.g. some endpoints might return 204 later). if (response.status === 204) return null; // Prefer JSON when the server says it's JSON. if (isJsonResponse(response)) { let payload; try { payload = await response.json(); } catch (err) { throw new ApiClientError({ status: response.status, code: "CLIENT_INVALID_JSON", message: "Invalid JSON response", url, method, cause: err, }); } if (response.ok) return payload; /** @type {ApiErrorPayload|any} */ const maybeError = payload; // Map standardized backend errors if (maybeError?.error?.code && maybeError?.error?.message) { throw new ApiClientError({ status: response.status, code: maybeError.error.code, message: maybeError.error.message, details: maybeError.error.details, url, method, }); } // Fallback for non-standard error JSON throw new ApiClientError({ status: response.status, code: "CLIENT_HTTP_ERROR", message: `Request failed with status ${response.status}`, details: payload, url, method, }); } // Non-JSON response fallback (should be rare for current endpoints) const text = await response.text().catch(() => ""); if (response.ok) return text || null; throw new ApiClientError({ status: response.status, code: "CLIENT_HTTP_ERROR", message: text || `Request failed with status ${response.status}`, url, method, }); } /* -------------------------------------------------------------------------- */ /* Domain helpers (thin wrappers) */ /* -------------------------------------------------------------------------- */ /** * @param {{ username: string, password: string }} input * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function login(input, options) { return apiFetch("/api/auth/login", { method: "POST", body: input, ...options, }); } /** * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function logout(options) { return apiFetch("/api/auth/logout", { method: "GET", ...options }); } /** * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getBranches(options) { return apiFetch("/api/branches", { method: "GET", ...options }); } /** * @param {string} branch * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getYears(branch, options) { return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, { method: "GET", ...options, }); } /** * @param {string} branch * @param {string} year * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getMonths(branch, year, options) { return apiFetch( `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent( year )}/months`, { method: "GET", ...options } ); } /** * @param {string} branch * @param {string} year * @param {string} month * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getDays(branch, year, month, options) { return apiFetch( `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent( year )}/${encodeURIComponent(month)}/days`, { method: "GET", ...options } ); } /** * @param {string} branch * @param {string} year * @param {string} month * @param {string} day * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options */ export function getFiles(branch, year, month, day, options) { const qs = new URLSearchParams({ branch: String(branch), year: String(year), month: String(month), day: String(day), }); return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options }); }