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:
@@ -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 };
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user