123 lines
3.7 KiB
TypeScript
123 lines
3.7 KiB
TypeScript
import Cookies from 'js-cookie';
|
|
|
|
/**
|
|
* Thin wrapper around fetch that:
|
|
* - prepends the configured API base URL
|
|
* - includes Django session cookies
|
|
* - attaches the CSRF token from cookies
|
|
* - parses structured DRF errors into a single ApiError
|
|
*
|
|
* The 401 handler is pluggable (see `setUnauthorizedHandler`) so the auth
|
|
* layer can react (clear session, route to /login) without this module
|
|
* importing from $auth (which would create a cycle).
|
|
*
|
|
* Default base URL is the relative path `/api` so the app works out of the
|
|
* box against either the Vite dev proxy, the mock plugin, or a same-origin
|
|
* production deployment. Set VITE_API_BASE_URL to point directly at a
|
|
* cross-origin backend if needed.
|
|
*/
|
|
|
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
message: string,
|
|
public details?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
type UnauthorizedHandler = () => void;
|
|
let onUnauthorized: UnauthorizedHandler | null = null;
|
|
export function setUnauthorizedHandler(fn: UnauthorizedHandler | null) {
|
|
onUnauthorized = fn;
|
|
}
|
|
|
|
async function ensureCsrf(): Promise<string> {
|
|
const existing = Cookies.get('csrftoken');
|
|
if (existing) return existing;
|
|
|
|
await fetch(`${API_BASE_URL}/csrf/`, { method: 'GET', credentials: 'include' });
|
|
return Cookies.get('csrftoken') ?? '';
|
|
}
|
|
|
|
function extractError(body: unknown, fallback: string): string {
|
|
if (body && typeof body === 'object') {
|
|
const b = body as Record<string, unknown>;
|
|
if (typeof b.detail === 'string') return b.detail;
|
|
if (b.non_field_errors && Array.isArray(b.non_field_errors)) {
|
|
return b.non_field_errors.join(', ');
|
|
}
|
|
if (b.field_errors && typeof b.field_errors === 'object') {
|
|
return Object.values(b.field_errors as Record<string, unknown>)
|
|
.flat()
|
|
.join(', ');
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
|
body?: unknown;
|
|
query?: Record<string, string | number | boolean | undefined>;
|
|
}
|
|
|
|
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
|
let url = `${API_BASE_URL}${path}`;
|
|
if (!query) return url;
|
|
const params = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (v !== undefined) params.set(k, String(v));
|
|
}
|
|
const qs = params.toString();
|
|
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
|
|
return url;
|
|
}
|
|
|
|
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const csrf = await ensureCsrf();
|
|
|
|
const init: RequestInit = {
|
|
...options,
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrf,
|
|
...options.headers,
|
|
},
|
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
};
|
|
|
|
const res = await fetch(buildUrl(path, options.query), init);
|
|
|
|
if (res.status === 401) onUnauthorized?.();
|
|
|
|
if (!res.ok) {
|
|
let body: unknown = null;
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
throw new ApiError(res.status, extractError(body, res.statusText), body);
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string, options?: RequestOptions) =>
|
|
request<T>(path, { ...options, method: 'GET' }),
|
|
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
|
request<T>(path, { ...options, method: 'POST', body }),
|
|
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
|
request<T>(path, { ...options, method: 'PUT', body }),
|
|
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
|
request<T>(path, { ...options, method: 'PATCH', body }),
|
|
delete: <T>(path: string, options?: RequestOptions) =>
|
|
request<T>(path, { ...options, method: 'DELETE' }),
|
|
};
|