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); } export const api: KyInstance = ky.create({ prefix: resolveApiPrefixUrl(env.apiUrl), // hazard: no instance-wide Content-Type. Ky sets application/json for `json` bodies, and the // runtime must set `multipart/form-data; boundary=…` for FormData while it builds the Request. // A default here is baked into that Request; deleting it in a hook leaves the multipart body // with no Content-Type at all, so ASP.NET cannot bind [FromForm] fields. headers: { Accept: "application/json", }, hooks: { beforeRequest: [ ({ request }) => { const token = getAccessToken(); if (token) { request.headers.set("Authorization", token); } }, ], 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 };