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
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();
|