|
@@ -4,7 +4,7 @@
|
|
|
* Goals:
|
|
* Goals:
|
|
|
* - Centralize fetch defaults (credentials + no-store) to match backend caching rules.
|
|
* - Centralize fetch defaults (credentials + no-store) to match backend caching rules.
|
|
|
* - Provide a single, predictable error shape via ApiClientError.
|
|
* - Provide a single, predictable error shape via ApiClientError.
|
|
|
- * - Offer thin domain helpers (login/logout/branches/...).
|
|
|
|
|
|
|
+ * - Offer thin domain helpers (login/logout/me/branches/...).
|
|
|
*
|
|
*
|
|
|
* Notes:
|
|
* Notes:
|
|
|
* - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention".
|
|
* - JavaScript only (no TypeScript). We use JSDoc for "typed-by-convention".
|
|
@@ -36,10 +36,14 @@ export class ApiClientError extends Error {
|
|
|
* }} input
|
|
* }} input
|
|
|
*/
|
|
*/
|
|
|
constructor({ status, code, message, details, url, method, cause }) {
|
|
constructor({ status, code, message, details, url, method, cause }) {
|
|
|
|
|
+ // We attach `cause` to preserve error chains (supported in modern Node).
|
|
|
super(message, cause ? { cause } : undefined);
|
|
super(message, cause ? { cause } : undefined);
|
|
|
|
|
+
|
|
|
this.name = "ApiClientError";
|
|
this.name = "ApiClientError";
|
|
|
this.status = status;
|
|
this.status = status;
|
|
|
this.code = code;
|
|
this.code = code;
|
|
|
|
|
+
|
|
|
|
|
+ // Only attach optional properties when provided (keeps error objects clean).
|
|
|
if (details !== undefined) this.details = details;
|
|
if (details !== undefined) this.details = details;
|
|
|
if (url) this.url = url;
|
|
if (url) this.url = url;
|
|
|
if (method) this.method = method;
|
|
if (method) this.method = method;
|
|
@@ -56,22 +60,36 @@ const DEFAULT_HEADERS = {
|
|
|
* - If `baseUrl` is provided, resolve relative to it.
|
|
* - If `baseUrl` is provided, resolve relative to it.
|
|
|
* - Otherwise return the relative path (browser-friendly: "/api/...").
|
|
* - Otherwise return the relative path (browser-friendly: "/api/...").
|
|
|
*
|
|
*
|
|
|
|
|
+ * Why this exists:
|
|
|
|
|
+ * - The same client can be used:
|
|
|
|
|
+ * - in the browser (relative "/api/..." calls)
|
|
|
|
|
+ * - in Node scripts (absolute baseUrl like "http://127.0.0.1:3000")
|
|
|
|
|
+ *
|
|
|
* @param {string} path
|
|
* @param {string} path
|
|
|
* @param {string=} baseUrl
|
|
* @param {string=} baseUrl
|
|
|
* @returns {string}
|
|
* @returns {string}
|
|
|
*/
|
|
*/
|
|
|
function resolveUrl(path, baseUrl) {
|
|
function resolveUrl(path, baseUrl) {
|
|
|
|
|
+ // If someone passes a full URL, keep it unchanged.
|
|
|
if (/^https?:\/\//i.test(path)) return path;
|
|
if (/^https?:\/\//i.test(path)) return path;
|
|
|
|
|
|
|
|
const base = (baseUrl || "").trim();
|
|
const base = (baseUrl || "").trim();
|
|
|
|
|
+
|
|
|
|
|
+ // Browser usage: baseUrl omitted -> use relative path.
|
|
|
if (!base) return path;
|
|
if (!base) return path;
|
|
|
|
|
|
|
|
|
|
+ // Ensure baseUrl ends with a slash so URL() resolves correctly.
|
|
|
return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
|
|
return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Best-effort detection if response is JSON.
|
|
* Best-effort detection if response is JSON.
|
|
|
*
|
|
*
|
|
|
|
|
+ * Why we need this:
|
|
|
|
|
+ * - Our API is intended to always respond with JSON.
|
|
|
|
|
+ * - But in error cases (misconfig, reverse proxy, 404 HTML), we might receive non-JSON.
|
|
|
|
|
+ * - We want robust parsing behavior and a predictable client-side error.
|
|
|
|
|
+ *
|
|
|
* @param {Response} response
|
|
* @param {Response} response
|
|
|
* @returns {boolean}
|
|
* @returns {boolean}
|
|
|
*/
|
|
*/
|
|
@@ -86,6 +104,10 @@ function isJsonResponse(response) {
|
|
|
* - cache: "no-store" (match backend freshness strategy)
|
|
* - cache: "no-store" (match backend freshness strategy)
|
|
|
* - standardized error mapping into ApiClientError
|
|
* - standardized error mapping into ApiClientError
|
|
|
*
|
|
*
|
|
|
|
|
+ * Clean code rule:
|
|
|
|
|
+ * - UI code should NOT call fetch directly.
|
|
|
|
|
+ * - Instead, it should call domain helpers that route through apiFetch().
|
|
|
|
|
+ *
|
|
|
* @param {string} path
|
|
* @param {string} path
|
|
|
* @param {{
|
|
* @param {{
|
|
|
* method?: string,
|
|
* method?: string,
|
|
@@ -123,7 +145,9 @@ export async function apiFetch(path, options = {}) {
|
|
|
init.body = body;
|
|
init.body = body;
|
|
|
} else {
|
|
} else {
|
|
|
init.body = JSON.stringify(body);
|
|
init.body = JSON.stringify(body);
|
|
|
|
|
+
|
|
|
// Only set Content-Type if caller didn't provide it.
|
|
// Only set Content-Type if caller didn't provide it.
|
|
|
|
|
+ // This allows callers to send non-JSON payloads later if needed.
|
|
|
if (!init.headers["Content-Type"]) {
|
|
if (!init.headers["Content-Type"]) {
|
|
|
init.headers["Content-Type"] = "application/json";
|
|
init.headers["Content-Type"] = "application/json";
|
|
|
}
|
|
}
|
|
@@ -135,6 +159,7 @@ export async function apiFetch(path, options = {}) {
|
|
|
response = await fetchImpl(url, init);
|
|
response = await fetchImpl(url, init);
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
// Network errors, DNS errors, connection refused, etc.
|
|
// Network errors, DNS errors, connection refused, etc.
|
|
|
|
|
+ // We use status=0 to indicate "no HTTP response".
|
|
|
throw new ApiClientError({
|
|
throw new ApiClientError({
|
|
|
status: 0,
|
|
status: 0,
|
|
|
code: "CLIENT_NETWORK_ERROR",
|
|
code: "CLIENT_NETWORK_ERROR",
|
|
@@ -151,9 +176,11 @@ export async function apiFetch(path, options = {}) {
|
|
|
// Prefer JSON when the server says it's JSON.
|
|
// Prefer JSON when the server says it's JSON.
|
|
|
if (isJsonResponse(response)) {
|
|
if (isJsonResponse(response)) {
|
|
|
let payload;
|
|
let payload;
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
payload = await response.json();
|
|
payload = await response.json();
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
|
|
+ // Server said JSON but response body was not parseable JSON.
|
|
|
throw new ApiClientError({
|
|
throw new ApiClientError({
|
|
|
status: response.status,
|
|
status: response.status,
|
|
|
code: "CLIENT_INVALID_JSON",
|
|
code: "CLIENT_INVALID_JSON",
|
|
@@ -164,12 +191,13 @@ export async function apiFetch(path, options = {}) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Happy path: return parsed JSON.
|
|
|
if (response.ok) return payload;
|
|
if (response.ok) return payload;
|
|
|
|
|
|
|
|
/** @type {ApiErrorPayload|any} */
|
|
/** @type {ApiErrorPayload|any} */
|
|
|
const maybeError = payload;
|
|
const maybeError = payload;
|
|
|
|
|
|
|
|
- // Map standardized backend errors
|
|
|
|
|
|
|
+ // Map standardized backend errors into ApiClientError
|
|
|
if (maybeError?.error?.code && maybeError?.error?.message) {
|
|
if (maybeError?.error?.code && maybeError?.error?.message) {
|
|
|
throw new ApiClientError({
|
|
throw new ApiClientError({
|
|
|
status: response.status,
|
|
status: response.status,
|
|
@@ -181,7 +209,7 @@ export async function apiFetch(path, options = {}) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Fallback for non-standard error JSON
|
|
|
|
|
|
|
+ // Fallback: error is JSON but not in our standardized shape.
|
|
|
throw new ApiClientError({
|
|
throw new ApiClientError({
|
|
|
status: response.status,
|
|
status: response.status,
|
|
|
code: "CLIENT_HTTP_ERROR",
|
|
code: "CLIENT_HTTP_ERROR",
|
|
@@ -194,6 +222,7 @@ export async function apiFetch(path, options = {}) {
|
|
|
|
|
|
|
|
// Non-JSON response fallback (should be rare for current endpoints)
|
|
// Non-JSON response fallback (should be rare for current endpoints)
|
|
|
const text = await response.text().catch(() => "");
|
|
const text = await response.text().catch(() => "");
|
|
|
|
|
+
|
|
|
if (response.ok) return text || null;
|
|
if (response.ok) return text || null;
|
|
|
|
|
|
|
|
throw new ApiClientError({
|
|
throw new ApiClientError({
|
|
@@ -210,6 +239,10 @@ export async function apiFetch(path, options = {}) {
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
|
|
+ * Login:
|
|
|
|
|
+ * - Sends credentials to the backend.
|
|
|
|
|
+ * - Backend sets an HTTP-only cookie when successful.
|
|
|
|
|
+ *
|
|
|
* @param {{ username: string, password: string }} input
|
|
* @param {{ username: string, password: string }} input
|
|
|
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
|
*/
|
|
*/
|
|
@@ -222,6 +255,9 @@ export function login(input, options) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
|
|
+ * Logout:
|
|
|
|
|
+ * - Clears the session cookie (idempotent).
|
|
|
|
|
+ *
|
|
|
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
|
*/
|
|
*/
|
|
|
export function logout(options) {
|
|
export function logout(options) {
|
|
@@ -229,6 +265,24 @@ export function logout(options) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
|
|
+ * Get current session identity (frontend-friendly):
|
|
|
|
|
+ * - Always returns HTTP 200 with:
|
|
|
|
|
+ * - { user: null } when unauthenticated
|
|
|
|
|
+ * - { user: { userId, role, branchId } } when authenticated
|
|
|
|
|
+ *
|
|
|
|
|
+ * Why we want this:
|
|
|
|
|
+ * - The UI should not use 401 as basic control-flow to determine "am I logged in?"
|
|
|
|
|
+ * - This endpoint enables a clean "session check" UX (RHL-020 AuthGate).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
|
|
|
+ */
|
|
|
|
|
+export function getMe(options) {
|
|
|
|
|
+ return apiFetch("/api/auth/me", { method: "GET", ...options });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * List branches visible to the current session (RBAC is enforced server-side).
|
|
|
|
|
+ *
|
|
|
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }=} options
|
|
|
*/
|
|
*/
|
|
|
export function getBranches(options) {
|
|
export function getBranches(options) {
|