You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

104 lines
3.0 KiB
TypeScript

import type { ApiError } from '~/types';
const API_BASE = '/api/v1';
/**
* Centralized HTTP client for all API communication.
*
* Every request includes the `X-Requested-With: XMLHttpRequest` header as a
* lightweight CSRF mitigation: the backend rejects requests that lack this
* header, which browsers won't add on cross-origin form submissions.
*
* Credentials are sent with `same-origin` policy so the session cookie is
* attached automatically without exposing it to third-party origins.
*
* On a 401 response the client redirects the browser to `/login`, ensuring
* expired sessions are handled uniformly across the application.
*/
class ApiClient {
/**
* Execute an HTTP request and return the parsed JSON response.
*
* Handles JSON serialization, FormData pass-through, 401 redirects,
* and 204 (no-content) responses.
*/
private async request<T>(
method: string,
path: string,
options?: {
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
},
): Promise<T> {
const url = `${API_BASE}${path}`;
const headers: Record<string, string> = {
'X-Requested-With': 'XMLHttpRequest',
...options?.headers,
};
if (options?.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, {
method,
headers,
body:
options?.body instanceof FormData
? options.body
: options?.body
? JSON.stringify(options.body)
: undefined,
credentials: 'same-origin',
signal: options?.signal,
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/login';
throw { status: 401, message: 'Session expired' } satisfies ApiError;
}
const errorBody = await response
.json()
.catch(() => ({ error: 'Unknown error' }));
const apiError: ApiError = {
status: response.status,
message: errorBody.message || errorBody.error || `HTTP ${response.status}`,
field_errors: errorBody.field_errors,
};
throw apiError;
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
/** Send a GET request. Accepts an optional `AbortSignal` for cancellation. */
get<T>(path: string, signal?: AbortSignal): Promise<T> {
return this.request<T>('GET', path, { signal });
}
/** Send a POST request with an optional JSON or FormData body. */
post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('POST', path, { body });
}
/** Send a PUT request with an optional JSON body. */
put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PUT', path, { body });
}
/** Send a DELETE request. */
delete<T>(path: string): Promise<T> {
return this.request<T>('DELETE', path);
}
}
/** Singleton API client instance used by all API modules. */
export const api = new ApiClient();