import ky, { type KyInstance, type Options } from "ky"; import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; import { getAccessToken } from "@/lib/auth/auth-storage"; import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; import { traceHttpOperation } from "@/observability/http-transaction"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); } /** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */ export function shouldStripJsonContentType(body: unknown): boolean { return body instanceof FormData; } export const api: KyInstance = ky.create({ prefix: resolveApiPrefixUrl(env.apiUrl), headers: { Accept: "application/json", "Content-Type": "application/json", }, hooks: { beforeRequest: [ ({ request, options }) => { const token = getAccessToken(); if (token) { request.headers.set("Authorization", token); } // Let the runtime set multipart boundary; default JSON Content-Type breaks FormData. if (shouldStripJsonContentType(options.body)) { request.headers.delete("Content-Type"); } }, ], beforeError: [({ error }) => normalizeApiRequestError(error)], afterResponse: [ ({ request, response }) => { if (response.status !== 401 || isAuthLoginRequest(request)) { return response; } expireSessionAndRedirectToLogin(); return response; }, ], }, }); export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete"; export async function apiRequestRaw( method: ApiRawMethod, url: string, options?: Options, entryPoint?: string, ): Promise { return traceHttpOperation( { method: method.toUpperCase(), url, entryPoint: entryPoint ?? `api.${method}`, }, async ({ reportStatus }) => { const response = await api[method](url, options); reportStatus(response.status); return response; }, ); } export async function apiGet(url: string, options?: Options): Promise { return traceHttpOperation( { method: "GET", url, entryPoint: "api.apiGet" }, async ({ reportStatus }) => { const response = await api.get(url, options); reportStatus(response.status); return response.json(); }, ); } export async function apiPost(url: string, body?: unknown, options?: Options): Promise { return traceHttpOperation( { method: "POST", url, entryPoint: "api.apiPost" }, async ({ reportStatus }) => { const response = await api.post(url, { ...options, json: body }); reportStatus(response.status); return response.json(); }, ); } export async function apiPostForm(url: string, body: FormData, options?: Options): Promise { return traceHttpOperation( { method: "POST", url, entryPoint: "api.apiPostForm" }, async ({ reportStatus }) => { const response = await api.post(url, { ...options, body }); reportStatus(response.status); return response.json(); }, ); } export async function apiPut(url: string, body?: unknown, options?: Options): Promise { return traceHttpOperation( { method: "PUT", url, entryPoint: "api.apiPut" }, async ({ reportStatus }) => { const response = await api.put(url, { ...options, json: body }); reportStatus(response.status); return response.json(); }, ); } export async function apiPatch(url: string, body?: unknown, options?: Options): Promise { return traceHttpOperation( { method: "PATCH", url, entryPoint: "api.apiPatch" }, async ({ reportStatus }) => { const response = await api.patch(url, { ...options, json: body }); reportStatus(response.status); return response.json(); }, ); } export async function apiDelete(url: string, options?: Options): Promise { return traceHttpOperation( { method: "DELETE", url, entryPoint: "api.apiDelete" }, async ({ reportStatus }) => { const response = await api.delete(url, options); reportStatus(response.status); return response.json(); }, ); } export async function apiPostNoContent( url: string, body?: unknown, options?: Options, ): Promise { return traceHttpOperation( { method: "POST", url, entryPoint: "api.apiPostNoContent" }, async ({ reportStatus }) => { const response = await api.post(url, { ...options, json: body }); reportStatus(response.status); if (response.status === 204) { return; } const text = await response.text(); if (!text.trim()) { return; } JSON.parse(text); }, ); } export async function apiDeleteNoContent(url: string, options?: Options): Promise { return traceHttpOperation( { method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" }, async ({ reportStatus }) => { const response = await api.delete(url, options); reportStatus(response.status); if (response.status === 204) { return; } const text = await response.text(); if (!text.trim()) { return; } JSON.parse(text); }, ); } export { mapHttpStatusToMessage };