This commit is contained in:
133
frontend/src/services/apiClient.js
Normal file
133
frontend/src/services/apiClient.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// src/services/apiClient.js
|
||||
|
||||
/**
|
||||
* 🌐 CENTRALIZIRANI API KLIJENT / MREŽNI POSREDNIK (API INTERCEPTOR WRAPPER)
|
||||
* * * [Arhitektonska uloga]:
|
||||
* Služi kao jedinstveni apstraktni sloj (Wrapper) oko nativne pregledničke `fetch` funkcije.
|
||||
* Njegova je zadaća izolirati kompletnu logiku mrežne komunikacije, zaglavlja i serijalizacije
|
||||
* iz Preact UI komponenti i NanoStores skladišta, osiguravajući stopostotni DRY princip.
|
||||
* * * [Ključne tehničke funkcionalnosti]:
|
||||
* 1. Dinamička konfiguracija okruženja: Automatski čita `PUBLIC_API_URL` preko Vite kompajlera.
|
||||
* 2. Pametno upravljanje zaglavljima: Automatski presreće mrežne zahtjeve i injektira
|
||||
* `Authorization: Bearer <token>` zaglavlje iz klijentovog memorijskog `authStore`-a.
|
||||
* 3. Detekcija polimorfnih paketa: Prepoznaje razliku između običnog `application/json` unosa
|
||||
* i `FormData` objekata (slike s terena), sprječavajući korupciju mrežnog paketa.
|
||||
* 4. Robusni parser validacijskih grešaka: Izvlači i tekstualno formatira kompleksne DRF
|
||||
* strukture grešaka (npr. pogreške po poljima ili nizove) kako bi Toast sustav stabilno radio.
|
||||
*/
|
||||
|
||||
import { $accessToken, setToken } from '../stores/authStore';
|
||||
|
||||
const KLIJENT_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||
const POSLUZITELJ_URL = 'http://backend:8000/api/'; // 💡 Promijeni 'backend' u točan naziv tvog Django servisa iz docker-compose.yml!
|
||||
|
||||
const BASE_URL = typeof window === 'undefined' ? POSLUZITELJ_URL : KLIJENT_URL;
|
||||
let refreshPromise = null;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[API_CLIENT]: Pokrenut mod. Lokacija: ${typeof window === 'undefined' ? 'SERVER (Docker)' : 'CLIENT (Browser)'} -> Endpoint: ${BASE_URL}`);
|
||||
}
|
||||
|
||||
async function request(endpoint, options = {}) {
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
const url = new URL(endpoint.replace(/^\/+/, ''), BASE_URL).toString();
|
||||
const headers = { ...options.headers };
|
||||
|
||||
const isFormData = options.body && (
|
||||
options.body instanceof FormData ||
|
||||
typeof options.body.append === 'function'
|
||||
);
|
||||
|
||||
if (options.body && !isFormData) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// 🔑 PAMETNA AUTORIZACIJA: Ako je proslijeđen eksplicitni serverToken (iz Astro.cookies), koristi njega.
|
||||
// U suprotnom, povuci iz NanoStores (klijent mod).
|
||||
let token = options.serverToken || $accessToken.get();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let body = options.body;
|
||||
if (body && !isFormData && typeof body !== 'string') {
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const config = { ...options, headers, body };
|
||||
|
||||
try {
|
||||
let response = await fetch(url, config);
|
||||
|
||||
// Presretač za tihi refresh (Radi samo na klijentu, jer server ne radi automatski tihi refresh)
|
||||
if (response.status === 401 && token && !cleanEndpoint.includes('token/refresh/') && typeof window !== 'undefined') {
|
||||
console.warn("Access token istekao. Pokrećem tihi refresh...");
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const refreshResponse = await fetch(`${BASE_URL}token/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken }),
|
||||
});
|
||||
|
||||
if (!refreshResponse.ok) {
|
||||
throw new Error("Sesija istekla.");
|
||||
}
|
||||
|
||||
const refreshData = await refreshResponse.json();
|
||||
const newAccessToken = refreshData.access;
|
||||
setToken(newAccessToken, refreshData.refresh || refreshToken);
|
||||
return newAccessToken;
|
||||
})();
|
||||
}
|
||||
|
||||
try {
|
||||
const newAccessToken = await refreshPromise;
|
||||
config.headers['Authorization'] = `Bearer ${newAccessToken}`;
|
||||
response = await fetch(url, config);
|
||||
} catch (refreshError) {
|
||||
setToken(null);
|
||||
window.location.href = '/login?session=expired';
|
||||
throw refreshError;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
let errorMessage = `API Greška [${response.status}]`;
|
||||
if (response.status === 400 && typeof errData === 'object') {
|
||||
// Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
|
||||
throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
|
||||
}
|
||||
if (errData.detail) errorMessage = errData.detail;
|
||||
throw { message: String(errorMessage), status: response.status };
|
||||
}
|
||||
|
||||
if (options.responseType === 'blob') return await response.blob();
|
||||
if (response.status === 204 || config.method === 'DELETE') return { success: true };
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
// 401 obrađuje tihi refresh interceptor iznad (ne logiramo — nije bug).
|
||||
// 404 na ručno pozvanim provjerama (heartbeat, itd.) nije pogreška arhitekture.
|
||||
const silenced = error?.status === 401 || error?.status === 404;
|
||||
if (!silenced) {
|
||||
console.error(`Mrežni problem na ${endpoint}:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (endpoint, options) => request(endpoint, { ...options, method: 'GET' }),
|
||||
post: (endpoint, body, options) => request(endpoint, { ...options, method: 'POST', body }),
|
||||
put: (endpoint, body, options) => request(endpoint, { ...options, method: 'PUT', body }),
|
||||
patch: (endpoint, body, options) => request(endpoint, { ...options, method: 'PATCH', body }),
|
||||
delete: (endpoint, options) => request(endpoint, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
Reference in New Issue
Block a user