leaflet_svelte/src/lib/api/client.ts
2026-04-22 01:27:38 +09:00

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' }),
};