Introduces a new /api/choices/ endpoint for fetching model choices with multilingual labels. Updates Django models to use 'cz#' prefix for Czech labels. Adds OpenAPI client generation via orval, refactors frontend API structure, and provides documentation and helper scripts for dynamic choices and OpenAPI usage.
276 lines
7.6 KiB
TypeScript
276 lines
7.6 KiB
TypeScript
import axios from "axios";
|
|
|
|
// --- ENV CONFIG ---
|
|
const API_BASE_URL =
|
|
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
|
|
|
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
|
|
|
|
|
|
// --- ERROR EVENT BUS ---
|
|
const ERROR_EVENT = "api:error";
|
|
type ApiErrorDetail = {
|
|
message: string;
|
|
status?: number;
|
|
url?: string;
|
|
data?: unknown;
|
|
};
|
|
|
|
// Use interface instead of arrow function types for readability
|
|
interface ApiErrorHandler {
|
|
(e: CustomEvent<ApiErrorDetail>): void;
|
|
}
|
|
|
|
function notifyError(detail: ApiErrorDetail) {
|
|
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
|
|
// eslint-disable-next-line no-console
|
|
console.error("[API ERROR]", detail);
|
|
}
|
|
function onError(handler: ApiErrorHandler) {
|
|
const wrapped = handler as EventListener;
|
|
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
|
|
|
|
return () => window.removeEventListener(ERROR_EVENT, wrapped);
|
|
}
|
|
|
|
// --- AXIOS INSTANCES ---
|
|
// Always send cookies. Django will set auth cookies; browser will include them automatically.
|
|
function createAxios(baseURL: string): any {
|
|
const instance = axios.create({
|
|
baseURL,
|
|
withCredentials: true, // cookies
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout: 20000,
|
|
});
|
|
return instance;
|
|
}
|
|
|
|
// Use a single behavior for both: cookies are always sent
|
|
const apiPublic = createAxios(API_BASE_URL);
|
|
const apiAuth = createAxios(API_BASE_URL);
|
|
|
|
|
|
// --- REQUEST INTERCEPTOR (PUBLIC) ---
|
|
// Ensure no Authorization header is ever sent by the public client
|
|
apiPublic.interceptors.request.use(function (config: any) {
|
|
if (config?.headers && (config.headers as any).Authorization) {
|
|
delete (config.headers as any).Authorization;
|
|
}
|
|
|
|
return config;
|
|
});
|
|
|
|
// --- REQUEST INTERCEPTOR (AUTH) ---
|
|
// Do not attach Authorization header; rely on cookies set by Django.
|
|
apiAuth.interceptors.request.use(function (config: any) {
|
|
(config as any)._retryCount = (config as any)._retryCount || 0;
|
|
|
|
return config;
|
|
});
|
|
|
|
// --- RESPONSE INTERCEPTOR (AUTH) ---
|
|
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
|
|
apiAuth.interceptors.response.use(
|
|
function (response: any) {
|
|
return response;
|
|
},
|
|
async function (error: any) {
|
|
if (!error.response) {
|
|
alert("Backend connection is unavailable. Please check your network.");
|
|
|
|
notifyError({
|
|
message: "Network error or backend unavailable",
|
|
url: error.config?.url,
|
|
});
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
const status = error.response.status;
|
|
if (status === 401) {
|
|
ClearTokens();
|
|
window.location.assign(LOGIN_PATH);
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
notifyError({
|
|
message:
|
|
(error.response.data as any)?.detail ||
|
|
(error.response.data as any)?.message ||
|
|
`Request failed with status ${status}`,
|
|
status,
|
|
url: error.config?.url,
|
|
data: error.response.data,
|
|
});
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
|
|
apiPublic.interceptors.response.use(
|
|
function (response: any) {
|
|
return response;
|
|
},
|
|
async function (error: any) {
|
|
if (!error.response) {
|
|
alert("Backend connection is unavailable. Please check your network.");
|
|
notifyError({
|
|
message: "Network error or backend unavailable",
|
|
url: error.config?.url,
|
|
});
|
|
return Promise.reject(error);
|
|
}
|
|
notifyError({
|
|
message:
|
|
(error.response.data as any)?.detail ||
|
|
(error.response.data as any)?.message ||
|
|
`Request failed with status ${error.response.status}`,
|
|
status: error.response.status,
|
|
url: error.config?.url,
|
|
data: error.response.data,
|
|
});
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
|
|
|
|
|
|
function Logout() {
|
|
try {
|
|
const LogOutResponse = apiAuth.post("/api/logout/");
|
|
|
|
if (LogOutResponse.body.detail != "Logout successful") {
|
|
throw new Error("Logout failed");
|
|
}
|
|
|
|
ClearTokens();
|
|
|
|
} catch (error) {
|
|
console.error("Error during logout:", error);
|
|
}
|
|
}
|
|
|
|
function ClearTokens(){
|
|
document.cookie = "access_token=; Max-Age=0; path=/";
|
|
document.cookie = "refresh_token=; Max-Age=0; path=/";
|
|
}
|
|
|
|
|
|
// --- EXPORT DEFAULT API WRAPPER ---
|
|
const Client = {
|
|
// Axios instances
|
|
auth: apiAuth,
|
|
public: apiPublic,
|
|
|
|
Logout,
|
|
|
|
// Error subscription
|
|
onError,
|
|
};
|
|
|
|
export default Client;
|
|
|
|
/**
|
|
USAGE EXAMPLES (TypeScript/React)
|
|
|
|
Import the client
|
|
--------------------------------------------------
|
|
import Client from "@/api/Client";
|
|
|
|
|
|
Login: obtain tokens and persist to cookies
|
|
--------------------------------------------------
|
|
async function login(username: string, password: string) {
|
|
// SimpleJWT default login endpoint (adjust if your backend differs)
|
|
// Example backend endpoint: POST /api/token/ -> { access, refresh }
|
|
const res = await Client.public.post("/api/token/", { username, password });
|
|
const { access, refresh } = res.data;
|
|
Client.setTokens(access, refresh);
|
|
// After this, Client.auth will automatically attach Authorization header
|
|
// and refresh when receiving a 401 (up to 2 retries).
|
|
}
|
|
|
|
|
|
Public request (no cookies, no Authorization)
|
|
--------------------------------------------------
|
|
// The public client does NOT send cookies or Authorization.
|
|
async function listPublicItems() {
|
|
const res = await Client.public.get("/api/public/items/");
|
|
return res.data;
|
|
}
|
|
|
|
|
|
Authenticated requests (auto Bearer header + refresh on 401)
|
|
--------------------------------------------------
|
|
async function fetchProfile() {
|
|
const res = await Client.auth.get("/api/users/me/");
|
|
return res.data;
|
|
}
|
|
|
|
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
|
|
const res = await Client.auth.patch("/api/users/me/", payload);
|
|
return res.data;
|
|
}
|
|
|
|
|
|
Global error handling (UI notifications)
|
|
--------------------------------------------------
|
|
import { useEffect } from "react";
|
|
|
|
function useApiErrors(showToast: (msg: string) => void) {
|
|
useEffect(function () {
|
|
const unsubscribe = Client.onError(function (e) {
|
|
const { message, status } = e.detail;
|
|
showToast(status ? String(status) + ": " + message : message);
|
|
});
|
|
return unsubscribe;
|
|
}, [showToast]);
|
|
}
|
|
|
|
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
|
|
// All errors are logged to console for developers.
|
|
|
|
|
|
Logout
|
|
--------------------------------------------------
|
|
function logout() {
|
|
Client.clearTokens();
|
|
window.location.assign("/login");
|
|
}
|
|
|
|
|
|
Route protection (PrivateRoute)
|
|
--------------------------------------------------
|
|
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
|
|
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
|
|
|
|
// Example:
|
|
// <Routes>
|
|
// <Route element={<PrivateRoute />} >
|
|
// <Route element={<MainLayout />}>
|
|
// <Route path="/" element={<Dashboard />} />
|
|
// <Route path="/profile" element={<Profile />} />
|
|
// </Route>
|
|
// </Route>
|
|
// <Route path="/login" element={<Login />} />
|
|
// </Routes>
|
|
|
|
|
|
Refresh and retry flow (what happens on 401)
|
|
--------------------------------------------------
|
|
// 1) Client.auth request receives 401 from backend
|
|
// 2) Client tries to refresh access token using refresh_token cookie
|
|
// 3) If refresh succeeds, original request is retried (max 2 times)
|
|
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
|
|
|
|
|
|
Environment variables (optional overrides)
|
|
--------------------------------------------------
|
|
// VITE_API_BASE_URL default: "http://localhost:8000"
|
|
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
|
|
// VITE_LOGIN_PATH default: "/login"
|
|
*/
|