Import source: app/src/lib/api

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:24:36 +03:30
parent 8e5793654d
commit 63daebea22
2 changed files with 146 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
// Frontend ↔ backend client.
// All requests are same-origin in prod (reverse-proxied at /api/*); in dev we
// rely on Vite's proxy or a fully-qualified backend URL. Set VITE_API_BASE
// in .env to override (e.g. "http://localhost:3010" during dev).
const BASE = (import.meta.env?.VITE_API_BASE ?? '').replace(/\/$/, '');
function versionPath(path) {
if (path.startsWith('/api/') && !path.startsWith('/api/v1/')) {
return '/api/v1' + path.slice(4); // '/api/articles' → '/api/v1/articles'
}
return path;
}
const CSRF_COOKIE = 'tek_csrf';
function readCookie(name) {
const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
class ApiError extends Error {
constructor(message, { status, body } = {}) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
async function request(path, { method = 'GET', body, headers = {} } = {}) {
const finalHeaders = { Accept: 'application/json', ...headers };
let payload;
if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
// Attach CSRF token on mutating methods if one is present.
if (method !== 'GET' && method !== 'HEAD') {
const csrf = readCookie(CSRF_COOKIE);
if (csrf) finalHeaders['X-CSRF-Token'] = csrf;
}
const res = await fetch(`${BASE}${versionPath(path)}`, {
method,
headers: finalHeaders,
body: payload,
credentials: 'include',
});
const text = await res.text();
const data = text ? safeJson(text) : null;
if (!res.ok) {
throw new ApiError(data?.error ?? `http_${res.status}`, { status: res.status, body: data });
}
return data;
}
function safeJson(s) {
try { return JSON.parse(s); } catch { return s; }
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body }),
put: (path, body) => request(path, { method: 'PUT', body }),
patch: (path, body) => request(path, { method: 'PATCH', body }),
del: (path) => request(path, { method: 'DELETE' }),
};
export { ApiError };
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { api, ApiError } from '../lib/api.js';
describe('ApiError', () => {
it('stores status and body', () => {
const err = new ApiError('Not found', { status: 404, body: { error: 'not_found' } });
expect(err.status).toBe(404);
expect(err.body).toEqual({ error: 'not_found' });
expect(err).toBeInstanceOf(Error);
});
});
describe('api client', () => {
beforeEach(() => {
vi.restoreAllMocks();
document.cookie = 'tek_csrf=test-csrf-token; path=/';
});
it('sends GET requests with Accept header', async () => {
const mockResponse = { items: [] };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(mockResponse)),
}));
const result = await api.get('/api/v1/articles');
expect(fetch).toHaveBeenCalledWith(
'/api/v1/articles',
expect.objectContaining({
headers: expect.objectContaining({ Accept: 'application/json' }),
credentials: 'include',
}),
);
});
it('sends CSRF token on POST requests', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ ok: true })),
}));
await api.post('/api/v1/auth/logout', {});
expect(fetch).toHaveBeenCalledWith(
'/api/v1/auth/logout',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-CSRF-Token': 'test-csrf-token',
'Content-Type': 'application/json',
}),
credentials: 'include',
}),
);
});
it('throws ApiError on non-OK responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve(JSON.stringify({ error: 'unauthenticated' })),
}));
await expect(api.get('/api/v1/auth/me')).rejects.toThrow(ApiError);
await expect(api.get('/api/v1/auth/me')).rejects.toThrow();
try {
await api.get('/api/v1/auth/me');
} catch (e) {
expect(e.status).toBe(401);
expect(e.body).toEqual({ error: 'unauthenticated' });
}
});
});