apiClient.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. /**
  2. * Minimal frontend-facing API client for the Lieferscheine backend.
  3. *
  4. * Goals:
  5. * - Centralize fetch defaults (credentials + no-store) to match backend caching rules.
  6. * - Provide a single, predictable error shape via ApiClientError.
  7. * - Offer thin domain helpers (login/logout/me/branches/...).
  8. *
  9. * Notes:
  10. * - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention".
  11. * - This client is intentionally small. It is not an SDK.
  12. */
  13. /**
  14. * @typedef {Object} ApiErrorPayload
  15. * @property {{ message: string, code: string, details?: any }} error
  16. */
  17. /**
  18. * A standardized client-side error type for API failures.
  19. * - status: HTTP status code (e.g. 401, 403, 404, 500)
  20. * - code: machine-readable error code (e.g. AUTH_UNAUTHENTICATED)
  21. * - message: human-readable message (safe to show in UI)
  22. * - details: optional structured payload (validation params etc.)
  23. */
  24. export class ApiClientError extends Error {
  25. /**
  26. * @param {{
  27. * status: number,
  28. * code: string,
  29. * message: string,
  30. * details?: any,
  31. * url?: string,
  32. * method?: string,
  33. * cause?: any
  34. * }} input
  35. */
  36. constructor({ status, code, message, details, url, method, cause }) {
  37. // We attach `cause` to preserve error chains (supported in modern Node).
  38. super(message, cause ? { cause } : undefined);
  39. this.name = "ApiClientError";
  40. this.status = status;
  41. this.code = code;
  42. // Only attach optional properties when provided (keeps error objects clean).
  43. if (details !== undefined) this.details = details;
  44. if (url) this.url = url;
  45. if (method) this.method = method;
  46. }
  47. }
  48. const DEFAULT_HEADERS = {
  49. Accept: "application/json",
  50. };
  51. /**
  52. * Resolve a request URL.
  53. * - If `path` is absolute (http/https), return as-is.
  54. * - If `baseUrl` is provided, resolve relative to it.
  55. * - Otherwise return the relative path (browser-friendly: "/api/...").
  56. *
  57. * Why this exists:
  58. * - The same client can be used:
  59. * - in the browser (relative "/api/..." calls)
  60. * - in Node scripts (absolute baseUrl like "http://127.0.0.1:3000")
  61. *
  62. * @param {string} path
  63. * @param {string=} baseUrl
  64. * @returns {string}
  65. */
  66. function resolveUrl(path, baseUrl) {
  67. // If someone passes a full URL, keep it unchanged.
  68. if (/^https?:\/\//i.test(path)) return path;
  69. const base = (baseUrl || "").trim();
  70. // Browser usage: baseUrl omitted -> use relative path.
  71. if (!base) return path;
  72. // Ensure baseUrl ends with a slash so URL() resolves correctly.
  73. return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
  74. }
  75. /**
  76. * Best-effort detection if response is JSON.
  77. *
  78. * Why we need this:
  79. * - Our API is intended to always respond with JSON.
  80. * - But in error cases (misconfig, reverse proxy, 404 HTML), we might receive non-JSON.
  81. * - We want robust parsing behavior and a predictable client-side error.
  82. *
  83. * @param {Response} response
  84. * @returns {boolean}
  85. */
  86. function isJsonResponse(response) {
  87. const ct = response.headers.get("content-type") || "";
  88. return ct.toLowerCase().includes("application/json");
  89. }
  90. /**
  91. * Core fetch helper with:
  92. * - credentials: "include" (cookie-based session)
  93. * - cache: "no-store" (match backend freshness strategy)
  94. * - standardized error mapping into ApiClientError
  95. *
  96. * Clean code rule:
  97. * - UI code should NOT call fetch directly.
  98. * - Instead, it should call domain helpers that route through apiFetch().
  99. *
  100. * @param {string} path
  101. * @param {{
  102. * method?: string,
  103. * headers?: Record<string, string>,
  104. * body?: any,
  105. * baseUrl?: string,
  106. * fetchImpl?: typeof fetch
  107. * }=} options
  108. * @returns {Promise<any>} parsed JSON payload (or null for empty responses)
  109. */
  110. export async function apiFetch(path, options = {}) {
  111. const {
  112. method = "GET",
  113. headers = {},
  114. body,
  115. baseUrl,
  116. fetchImpl = fetch,
  117. } = options;
  118. const url = resolveUrl(path, baseUrl);
  119. // Build request init. We always set credentials + no-store.
  120. // For JSON bodies, we serialize and set Content-Type.
  121. const init = {
  122. method,
  123. credentials: "include",
  124. cache: "no-store",
  125. headers: { ...DEFAULT_HEADERS, ...headers },
  126. };
  127. if (body !== undefined) {
  128. // If the caller passes a string, we assume it is already serialized.
  129. // Otherwise we serialize as JSON.
  130. if (typeof body === "string") {
  131. init.body = body;
  132. } else {
  133. init.body = JSON.stringify(body);
  134. // Only set Content-Type if caller didn't provide it.
  135. // This allows callers to send non-JSON payloads later if needed.
  136. if (!init.headers["Content-Type"]) {
  137. init.headers["Content-Type"] = "application/json";
  138. }
  139. }
  140. }
  141. let response;
  142. try {
  143. response = await fetchImpl(url, init);
  144. } catch (err) {
  145. // Network errors, DNS errors, connection refused, etc.
  146. // We use status=0 to indicate "no HTTP response".
  147. throw new ApiClientError({
  148. status: 0,
  149. code: "CLIENT_NETWORK_ERROR",
  150. message: "Network error",
  151. url,
  152. method,
  153. cause: err,
  154. });
  155. }
  156. // Handle empty responses explicitly (e.g. some endpoints might return 204 later).
  157. if (response.status === 204) return null;
  158. // Prefer JSON when the server says it's JSON.
  159. if (isJsonResponse(response)) {
  160. let payload;
  161. try {
  162. payload = await response.json();
  163. } catch (err) {
  164. // Server said JSON but response body was not parseable JSON.
  165. throw new ApiClientError({
  166. status: response.status,
  167. code: "CLIENT_INVALID_JSON",
  168. message: "Invalid JSON response",
  169. url,
  170. method,
  171. cause: err,
  172. });
  173. }
  174. // Happy path: return parsed JSON.
  175. if (response.ok) return payload;
  176. /** @type {ApiErrorPayload|any} */
  177. const maybeError = payload;
  178. // Map standardized backend errors into ApiClientError
  179. if (maybeError?.error?.code && maybeError?.error?.message) {
  180. throw new ApiClientError({
  181. status: response.status,
  182. code: maybeError.error.code,
  183. message: maybeError.error.message,
  184. details: maybeError.error.details,
  185. url,
  186. method,
  187. });
  188. }
  189. // Fallback: error is JSON but not in our standardized shape.
  190. throw new ApiClientError({
  191. status: response.status,
  192. code: "CLIENT_HTTP_ERROR",
  193. message: `Request failed with status ${response.status}`,
  194. details: payload,
  195. url,
  196. method,
  197. });
  198. }
  199. // Non-JSON response fallback (should be rare for current endpoints)
  200. const text = await response.text().catch(() => "");
  201. if (response.ok) return text || null;
  202. throw new ApiClientError({
  203. status: response.status,
  204. code: "CLIENT_HTTP_ERROR",
  205. message: text || `Request failed with status ${response.status}`,
  206. url,
  207. method,
  208. });
  209. }
  210. /* -------------------------------------------------------------------------- */
  211. /* Domain helpers (thin wrappers) */
  212. /* -------------------------------------------------------------------------- */
  213. /**
  214. * Login:
  215. * - Sends credentials to the backend.
  216. * - Backend sets an HTTP-only cookie when successful.
  217. *
  218. * @param {{ username: string, password: string }} input
  219. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  220. */
  221. export function login(input, options) {
  222. return apiFetch("/api/auth/login", {
  223. method: "POST",
  224. body: input,
  225. ...options,
  226. });
  227. }
  228. /**
  229. * Logout:
  230. * - Clears the session cookie (idempotent).
  231. *
  232. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  233. */
  234. export function logout(options) {
  235. return apiFetch("/api/auth/logout", { method: "GET", ...options });
  236. }
  237. /**
  238. * Get current session identity (frontend-friendly):
  239. * - Always returns HTTP 200 with:
  240. * - { user: null } when unauthenticated
  241. * - { user: { userId, role, branchId } } when authenticated
  242. *
  243. * Why we want this:
  244. * - The UI should not use 401 as basic control-flow to determine "am I logged in?"
  245. * - This endpoint enables a clean "session check" UX (RHL-020 AuthGate).
  246. *
  247. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  248. */
  249. export function getMe(options) {
  250. return apiFetch("/api/auth/me", { method: "GET", ...options });
  251. }
  252. /**
  253. * List branches visible to the current session (RBAC is enforced server-side).
  254. *
  255. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  256. */
  257. export function getBranches(options) {
  258. return apiFetch("/api/branches", { method: "GET", ...options });
  259. }
  260. /**
  261. * @param {string} branch
  262. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  263. */
  264. export function getYears(branch, options) {
  265. return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, {
  266. method: "GET",
  267. ...options,
  268. });
  269. }
  270. /**
  271. * @param {string} branch
  272. * @param {string} year
  273. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  274. */
  275. export function getMonths(branch, year, options) {
  276. return apiFetch(
  277. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
  278. year
  279. )}/months`,
  280. { method: "GET", ...options }
  281. );
  282. }
  283. /**
  284. * @param {string} branch
  285. * @param {string} year
  286. * @param {string} month
  287. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  288. */
  289. export function getDays(branch, year, month, options) {
  290. return apiFetch(
  291. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
  292. year
  293. )}/${encodeURIComponent(month)}/days`,
  294. { method: "GET", ...options }
  295. );
  296. }
  297. /**
  298. * @param {string} branch
  299. * @param {string} year
  300. * @param {string} month
  301. * @param {string} day
  302. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  303. */
  304. export function getFiles(branch, year, month, day, options) {
  305. const qs = new URLSearchParams({
  306. branch: String(branch),
  307. year: String(year),
  308. month: String(month),
  309. day: String(day),
  310. });
  311. return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options });
  312. }