|
@@ -0,0 +1,294 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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 });
|
|
|
|
|
+}
|