| | | 1 | | import { getValidToken } from "~/lib/oidc.server"; |
| | | 2 | | import { apiPathVersionPrefix, getApiBase } from "~/constants/api"; |
| | | 3 | | |
| | | 4 | | export type ApiLogEvent = |
| | | 5 | | | { kind: "request"; method: string; url: string } |
| | | 6 | | | { kind: "response"; method: string; url: string; status: number; durationMs: number } |
| | | 7 | | | { kind: "error"; method: string; url: string; error: unknown }; |
| | | 8 | | |
| | | 9 | | let logListener: ((e: ApiLogEvent) => void) | undefined; |
| | | 10 | | |
| | 23 | 11 | | export function setApiLogListener( |
| | | 12 | | listener: ((e: ApiLogEvent) => void) | undefined, |
| | | 13 | | ): void { |
| | 23 | 14 | | logListener = listener; |
| | | 15 | | } |
| | | 16 | | |
| | 20 | 17 | | function resolveUrl(path: string): string { |
| | 20 | 18 | | const base = getApiBase().replace(/\/$/, ""); |
| | | 19 | | // Paths beginning with "/api/" are treated as already-prefixed (the typed |
| | | 20 | | // wrapper supplies them straight from the openapi `paths` keys). Other |
| | | 21 | | // paths are prefixed with the version segment for legacy callers. |
| | 20 | 22 | | const versionedPath = path.startsWith("/api/") |
| | | 23 | | ? path.replace(/^\//, "") |
| | | 24 | | : `${apiPathVersionPrefix}/${path.replace(/^\//, "")}`; |
| | 20 | 25 | | return base ? `${base}/${versionedPath}` : `/${versionedPath}`; |
| | | 26 | | } |
| | | 27 | | |
| | | 28 | | /** |
| | | 29 | | * All API traffic goes through the BFF (React Router SSR server). |
| | | 30 | | * Pass `ssrRequest` from every loader/action so the session cookie can be |
| | | 31 | | * resolved to a Bearer token server-side. Browser-direct .NET calls are gone. |
| | | 32 | | */ |
| | 20 | 33 | | export async function apiFetch( |
| | | 34 | | path: string, |
| | | 35 | | init: RequestInit = {}, |
| | | 36 | | ssrRequest?: Request, |
| | | 37 | | ): Promise<Response> { |
| | 20 | 38 | | const url = resolveUrl(path); |
| | 20 | 39 | | const method = (init.method ?? "GET").toUpperCase(); |
| | | 40 | | |
| | 20 | 41 | | const headers = new Headers(init.headers); |
| | | 42 | | |
| | 20 | 43 | | if (ssrRequest) { |
| | 2 | 44 | | const token = await getValidToken(ssrRequest); |
| | 2 | 45 | | if (token) headers.set("Authorization", `Bearer ${token}`); |
| | | 46 | | } |
| | | 47 | | |
| | 20 | 48 | | logListener?.({ kind: "request", method, url }); |
| | 20 | 49 | | if (import.meta.env.DEV) console.debug(`[api] ${method} ${url}`); |
| | | 50 | | |
| | 20 | 51 | | const t0 = Date.now(); |
| | | 52 | | |
| | 20 | 53 | | try { |
| | 20 | 54 | | const response = await fetch(url, { ...init, headers }); |
| | 19 | 55 | | const durationMs = Date.now() - t0; |
| | 19 | 56 | | logListener?.({ kind: "response", method, url, status: response.status, durationMs }); |
| | 20 | 57 | | if (import.meta.env.DEV) |
| | 19 | 58 | | console.debug(`[api] ${method} ${url} → ${response.status} (${durationMs}ms)`); |
| | | 59 | | |
| | 19 | 60 | | if (response.status === 401) { |
| | | 61 | | // Session expired and refresh failed — redirect to sign-in from server |
| | 0 | 62 | | throw new Response(null, { status: 302, headers: { Location: "/auth/signin" } }); |
| | | 63 | | } |
| | | 64 | | |
| | 19 | 65 | | return response; |
| | | 66 | | } catch (error) { |
| | 1 | 67 | | if (error instanceof Response) throw error; // propagate redirects |
| | 1 | 68 | | logListener?.({ kind: "error", method, url, error }); |
| | 1 | 69 | | console.error(`[api] ${method} ${url} failed`, error); |
| | 1 | 70 | | throw error; |
| | | 71 | | } |
| | | 72 | | } |