apiClient.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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/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. super(message, cause ? { cause } : undefined);
  38. this.name = "ApiClientError";
  39. this.status = status;
  40. this.code = code;
  41. if (details !== undefined) this.details = details;
  42. if (url) this.url = url;
  43. if (method) this.method = method;
  44. }
  45. }
  46. const DEFAULT_HEADERS = {
  47. Accept: "application/json",
  48. };
  49. /**
  50. * Resolve a request URL.
  51. * - If `path` is absolute (http/https), return as-is.
  52. * - If `baseUrl` is provided, resolve relative to it.
  53. * - Otherwise return the relative path (browser-friendly: "/api/...").
  54. *
  55. * @param {string} path
  56. * @param {string=} baseUrl
  57. * @returns {string}
  58. */
  59. function resolveUrl(path, baseUrl) {
  60. if (/^https?:\/\//i.test(path)) return path;
  61. const base = (baseUrl || "").trim();
  62. if (!base) return path;
  63. return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
  64. }
  65. /**
  66. * Best-effort detection if response is JSON.
  67. *
  68. * @param {Response} response
  69. * @returns {boolean}
  70. */
  71. function isJsonResponse(response) {
  72. const ct = response.headers.get("content-type") || "";
  73. return ct.toLowerCase().includes("application/json");
  74. }
  75. /**
  76. * Core fetch helper with:
  77. * - credentials: "include" (cookie-based session)
  78. * - cache: "no-store" (match backend freshness strategy)
  79. * - standardized error mapping into ApiClientError
  80. *
  81. * @param {string} path
  82. * @param {{
  83. * method?: string,
  84. * headers?: Record<string, string>,
  85. * body?: any,
  86. * baseUrl?: string,
  87. * fetchImpl?: typeof fetch
  88. * }=} options
  89. * @returns {Promise<any>} parsed JSON payload (or null for empty responses)
  90. */
  91. export async function apiFetch(path, options = {}) {
  92. const {
  93. method = "GET",
  94. headers = {},
  95. body,
  96. baseUrl,
  97. fetchImpl = fetch,
  98. } = options;
  99. const url = resolveUrl(path, baseUrl);
  100. // Build request init. We always set credentials + no-store.
  101. // For JSON bodies, we serialize and set Content-Type.
  102. const init = {
  103. method,
  104. credentials: "include",
  105. cache: "no-store",
  106. headers: { ...DEFAULT_HEADERS, ...headers },
  107. };
  108. if (body !== undefined) {
  109. // If the caller passes a string, we assume it is already serialized.
  110. // Otherwise we serialize as JSON.
  111. if (typeof body === "string") {
  112. init.body = body;
  113. } else {
  114. init.body = JSON.stringify(body);
  115. // Only set Content-Type if caller didn't provide it.
  116. if (!init.headers["Content-Type"]) {
  117. init.headers["Content-Type"] = "application/json";
  118. }
  119. }
  120. }
  121. let response;
  122. try {
  123. response = await fetchImpl(url, init);
  124. } catch (err) {
  125. // Network errors, DNS errors, connection refused, etc.
  126. throw new ApiClientError({
  127. status: 0,
  128. code: "CLIENT_NETWORK_ERROR",
  129. message: "Network error",
  130. url,
  131. method,
  132. cause: err,
  133. });
  134. }
  135. // Handle empty responses explicitly (e.g. some endpoints might return 204 later).
  136. if (response.status === 204) return null;
  137. // Prefer JSON when the server says it's JSON.
  138. if (isJsonResponse(response)) {
  139. let payload;
  140. try {
  141. payload = await response.json();
  142. } catch (err) {
  143. throw new ApiClientError({
  144. status: response.status,
  145. code: "CLIENT_INVALID_JSON",
  146. message: "Invalid JSON response",
  147. url,
  148. method,
  149. cause: err,
  150. });
  151. }
  152. if (response.ok) return payload;
  153. /** @type {ApiErrorPayload|any} */
  154. const maybeError = payload;
  155. // Map standardized backend errors
  156. if (maybeError?.error?.code && maybeError?.error?.message) {
  157. throw new ApiClientError({
  158. status: response.status,
  159. code: maybeError.error.code,
  160. message: maybeError.error.message,
  161. details: maybeError.error.details,
  162. url,
  163. method,
  164. });
  165. }
  166. // Fallback for non-standard error JSON
  167. throw new ApiClientError({
  168. status: response.status,
  169. code: "CLIENT_HTTP_ERROR",
  170. message: `Request failed with status ${response.status}`,
  171. details: payload,
  172. url,
  173. method,
  174. });
  175. }
  176. // Non-JSON response fallback (should be rare for current endpoints)
  177. const text = await response.text().catch(() => "");
  178. if (response.ok) return text || null;
  179. throw new ApiClientError({
  180. status: response.status,
  181. code: "CLIENT_HTTP_ERROR",
  182. message: text || `Request failed with status ${response.status}`,
  183. url,
  184. method,
  185. });
  186. }
  187. /* -------------------------------------------------------------------------- */
  188. /* Domain helpers (thin wrappers) */
  189. /* -------------------------------------------------------------------------- */
  190. /**
  191. * @param {{ username: string, password: string }} input
  192. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  193. */
  194. export function login(input, options) {
  195. return apiFetch("/api/auth/login", {
  196. method: "POST",
  197. body: input,
  198. ...options,
  199. });
  200. }
  201. /**
  202. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  203. */
  204. export function logout(options) {
  205. return apiFetch("/api/auth/logout", { method: "GET", ...options });
  206. }
  207. /**
  208. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  209. */
  210. export function getBranches(options) {
  211. return apiFetch("/api/branches", { method: "GET", ...options });
  212. }
  213. /**
  214. * @param {string} branch
  215. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  216. */
  217. export function getYears(branch, options) {
  218. return apiFetch(`/api/branches/${encodeURIComponent(branch)}/years`, {
  219. method: "GET",
  220. ...options,
  221. });
  222. }
  223. /**
  224. * @param {string} branch
  225. * @param {string} year
  226. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  227. */
  228. export function getMonths(branch, year, options) {
  229. return apiFetch(
  230. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
  231. year
  232. )}/months`,
  233. { method: "GET", ...options }
  234. );
  235. }
  236. /**
  237. * @param {string} branch
  238. * @param {string} year
  239. * @param {string} month
  240. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  241. */
  242. export function getDays(branch, year, month, options) {
  243. return apiFetch(
  244. `/api/branches/${encodeURIComponent(branch)}/${encodeURIComponent(
  245. year
  246. )}/${encodeURIComponent(month)}/days`,
  247. { method: "GET", ...options }
  248. );
  249. }
  250. /**
  251. * @param {string} branch
  252. * @param {string} year
  253. * @param {string} month
  254. * @param {string} day
  255. * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
  256. */
  257. export function getFiles(branch, year, month, day, options) {
  258. const qs = new URLSearchParams({
  259. branch: String(branch),
  260. year: String(year),
  261. month: String(month),
  262. day: String(day),
  263. });
  264. return apiFetch(`/api/files?${qs.toString()}`, { method: "GET", ...options });
  265. }