From 63daebea2202303aafe7c82722b9004555ea2844 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:24:36 +0330 Subject: [PATCH] Import source: app/src/lib/api Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- app/src/lib/api.js | 72 +++++++++++++++++++++++++++++++++++++++ app/src/lib/api.test.js | 74 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 app/src/lib/api.js create mode 100644 app/src/lib/api.test.js diff --git a/app/src/lib/api.js b/app/src/lib/api.js new file mode 100644 index 0000000..85fa238 --- /dev/null +++ b/app/src/lib/api.js @@ -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 }; diff --git a/app/src/lib/api.test.js b/app/src/lib/api.test.js new file mode 100644 index 0000000..ef187e4 --- /dev/null +++ b/app/src/lib/api.test.js @@ -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' }); + } + }); +});