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