| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- /**
- * 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<string, string>,
- * body?: any,
- * baseUrl?: string,
- * fetchImpl?: typeof fetch
- * }=} options
- * @returns {Promise<any>} 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 });
- }
|