Files
crowdlending-app/frontend/src/api.js
T

140 lines
5.1 KiB
JavaScript

// Tiny fetch wrapper. Reads token + investisseurId from localStorage.
const BASE = import.meta.env.VITE_API_URL || '/api';
function authHeaders() {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const deviceToken = localStorage.getItem('cl_device_token');
// Même clé que WorkspaceContext.jsx (KEY_ACTIVE) — miroir de X-Investisseur-Id,
// sert à flêcher côté serveur les nouvelles lignes vers le workspace actif
// (Étape 2a du retrofit workspace_id, cf. workspaceScope.js).
const workspaceId = localStorage.getItem('cl_active_workspace_id');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
if (deviceToken) h['X-Device-Token'] = deviceToken;
if (workspaceId) h['X-Workspace-Id'] = workspaceId;
return h;
}
async function handle(res) {
if (res.status === 204) return null;
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Request failed';
const err = new Error(msg);
err.status = res.status;
err.code = body && body.code;
err.details = body && body.details;
throw err;
}
return body;
}
export const api = {
get: (path, params) => {
const qs = params ? '?' + new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
).toString() : '';
return fetch(BASE + path + qs, { headers: authHeaders(), cache: 'no-store' }).then(handle);
},
post: (path, body) =>
fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
put: (path, body) =>
fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
patch: (path, body) =>
fetch(BASE + path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
del: (path, body) =>
fetch(BASE + path, {
method: 'DELETE',
headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(),
...(body ? { body: JSON.stringify(body) } : {}),
}).then(handle),
upload: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
postForm: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
/** Flux SSE (Server-Sent Events) en POST — utilisé par l'assistant IA
* (POST /api/ai/chat). EventSource ne permet pas d'en-têtes personnalisés
* (Authorization, X-Investisseur-Id) : on lit donc le flux manuellement
* via fetch + ReadableStream, en parsant les trames "event:"/"data:"
* séparées par une ligne vide, au format écrit par le backend. Générateur
* asynchrone : `for await (const {event, data} of api.stream(...))`.
*/
stream: async function* (path, body) {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
});
if (!res.ok || !res.body) {
let msg = res.statusText || 'Requête échouée';
try {
const text = await res.text();
const parsed = text ? JSON.parse(text) : null;
if (parsed && parsed.error) msg = parsed.error;
} catch { /* corps non-JSON, on garde statusText */ }
const err = new Error(msg);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sepIdx;
while ((sepIdx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, sepIdx);
buf = buf.slice(sepIdx + 2);
let event = 'message';
const dataLines = [];
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) continue;
let data;
try { data = JSON.parse(dataLines.join('\n')); }
catch { data = dataLines.join('\n'); }
yield { event, data };
}
}
},
blob: (path) =>
fetch(BASE + path, { headers: authHeaders(), cache: 'no-store' }).then(async res => {
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
return res.blob();
}),
exportUrl: (path, params) => {
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
const token = localStorage.getItem('cl_token');
return BASE + path + qs + (token ? `${qs ? '&' : '?'}token=${token}` : '');
},
};