feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
123
src/lib/api/client.ts
Normal file
123
src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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' }),
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue