This commit is contained in:
Justin Visser 2026-02-17 20:24:12 +01:00
parent efd21a7297
commit 441676be27
97 changed files with 10764 additions and 223 deletions

View file

@ -0,0 +1,102 @@
import {
isErrorEnvelope,
isSuccessEnvelope,
readRequestId,
type ApiSuccessEnvelope,
} from "@/lib/api/envelope";
import { ApiRequestError } from "@/lib/api/errors";
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export interface ApiRequestOptions {
method?: HttpMethod;
body?: unknown | FormData;
headers?: Record<string, string>;
}
const UNSAFE_METHODS = new Set<HttpMethod>(["POST", "PUT", "PATCH", "DELETE"]);
const API_BASE = "/api/v1";
let csrfTokenProvider: (() => string | null) | null = null;
export function setCsrfTokenProvider(provider: () => string | null): void {
csrfTokenProvider = provider;
}
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiSuccessEnvelope<T>> {
const method = options.method ?? "GET";
const headers = new Headers(options.headers ?? {});
headers.set("Accept", "application/json");
const hasBody = options.body !== undefined;
const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;
let requestBody: BodyInit | undefined;
if (hasBody && !isFormData && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (hasBody) {
requestBody = isFormData ? (options.body as FormData) : JSON.stringify(options.body);
}
if (UNSAFE_METHODS.has(method) && csrfTokenProvider) {
const csrfToken = csrfTokenProvider();
if (csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
}
const response = await fetch(`${API_BASE}${path}`, {
method,
headers,
credentials: "include",
body: requestBody,
});
const raw = await parseResponseBody(response);
const requestId = readRequestId(raw) ?? response.headers.get("X-Request-ID");
if (!response.ok) {
if (isErrorEnvelope(raw)) {
throw new ApiRequestError({
status: response.status,
code: raw.error.code || "error",
message: raw.error.message || "Request failed.",
details: raw.error.details,
requestId,
});
}
throw new ApiRequestError({
status: response.status,
code: "http_error",
message: `Request failed with status ${response.status}.`,
requestId,
});
}
if (!isSuccessEnvelope<T>(raw)) {
throw new ApiRequestError({
status: response.status,
code: "invalid_envelope",
message: "Server returned an unexpected response format.",
requestId,
details: raw,
});
}
return raw;
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("Content-Type") || "";
if (!contentType.includes("application/json")) {
return null;
}
try {
return await response.json();
} catch (_err) {
return null;
}
}