/ frontend / src / app / utils / api.js
api.js
  1  import { toast } from 'react-toastify';
  2  
  3  class ApiError extends Error {
  4    constructor(status, detail, fieldErrors = {}) {
  5      super(detail);
  6      this.status = status;
  7      this.detail = detail;
  8      // Map of field name -> first error message, parsed from FastAPI's
  9      // Pydantic-422 `detail` array. Forms can consume this to mark the
 10      // offending TextField with `error` + `helperText`.
 11      this.fieldErrors = fieldErrors;
 12    }
 13  }
 14  
 15  // Pull a field-path → message map out of FastAPI's 422 detail shape.
 16  // FastAPI returns `[{loc:[...], msg:"...", type:"..."}, ...]` where loc
 17  // is ["body", "field_name"] (or ["path", "..."] / ["query", "..."]).
 18  // We flatten multi-level body locs to "a.b" so nested models still work.
 19  function _extractFieldErrors(detail) {
 20    if (!Array.isArray(detail)) return {};
 21    const out = {};
 22    for (const err of detail) {
 23      if (!err || !Array.isArray(err.loc) || err.loc.length < 2) continue;
 24      const [scope, ...rest] = err.loc;
 25      if (scope !== "body" && scope !== "query" && scope !== "path") continue;
 26      const key = rest.join(".");
 27      if (!key) continue;
 28      let msg = err.msg || err.message || "";
 29      if (msg.startsWith("Value error, ")) msg = msg.slice("Value error, ".length);
 30      if (!(key in out)) out[key] = msg;
 31    }
 32    return out;
 33  }
 34  
 35  async function request(path, options = {}, token = null) {
 36    const url = process.env.REACT_APP_RESTAI_API_URL || "";
 37    const headers = new Headers(options.headers || {});
 38    if (token && !headers.has('Authorization')) {
 39      headers.set('Authorization', 'Basic ' + token);
 40    }
 41    if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
 42      headers.set('Content-Type', 'application/json');
 43    }
 44  
 45    const response = await fetch(url + path, { ...options, headers });
 46    if (!response.ok) {
 47      let detail = response.statusText;
 48      let fieldErrors = {};
 49      try {
 50        const data = await response.json();
 51        let d = data.detail || detail;
 52        if (Array.isArray(d)) {
 53          // FastAPI validation error shape — extract per-field before
 54          // collapsing to a toast string so forms can still show inline.
 55          fieldErrors = _extractFieldErrors(d);
 56          d = d.map(e => e.msg || e.message || JSON.stringify(e)).join("; ");
 57        }
 58        // Legacy: stringified dict with 'msg' key (older endpoints).
 59        if (typeof d === "string" && d.includes("'msg':")) {
 60          const match = d.match(/'msg':\s*'([^']+)'/);
 61          if (match) d = match[1];
 62        }
 63        detail = d;
 64      } catch {}
 65  
 66      // Session expired or no auth: redirect to login instead of showing the
 67      // misleading "wrong password" toast. Skip when we're already on /login
 68      // (so failed logins keep showing their normal error).
 69      if (response.status === 401 && typeof window !== "undefined"
 70          && !window.location.pathname.includes("/login")) {
 71        try {
 72          sessionStorage.setItem("session_expired", "1");
 73        } catch {}
 74        window.location.href = "/admin/login";
 75        throw new ApiError(401, "Session expired");
 76      }
 77  
 78      if (!options.silent) toast.error(detail);
 79      throw new ApiError(response.status, detail, fieldErrors);
 80    }
 81    if (response.status === 204) return null;
 82    return response.json();
 83  }
 84  
 85  const api = {
 86    get: (path, token, opts = {}) => request(path, { method: 'GET', ...opts }, token),
 87    post: (path, body, token, opts = {}) => request(path, {
 88      method: 'POST',
 89      body: body instanceof FormData ? body : JSON.stringify(body), ...opts
 90    }, token),
 91    patch: (path, body, token, opts = {}) => request(path, {
 92      method: 'PATCH', body: JSON.stringify(body), ...opts
 93    }, token),
 94    put: (path, body, token, opts = {}) => request(path, {
 95      method: 'PUT', body: JSON.stringify(body), ...opts
 96    }, token),
 97    delete: (path, token, opts = {}) => request(path, { method: 'DELETE', ...opts }, token),
 98    raw: request,
 99  };
100  
101  export { ApiError };
102  export default api;