prvi kod za live uporabu
Some checks failed
ERP CI Pipeline / test (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-09 21:21:17 +02:00
parent 857bb65d52
commit 5d39e048ba
239 changed files with 25151 additions and 0 deletions

View 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' }),
};