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;