main
ts 179 lines 5.02 KB
Raw
1 import { readAdminAuthToken } from "@/lib/adminAuthToken";
2 import { BROWSER_API_PATHS, RELAY_API_PATHS } from "@/lib/apiPaths";
3 import type { APIEnvelope } from "@/types/api";
4
5 export class APIClientError extends Error {
6 readonly code: string;
7 readonly details: unknown;
8 readonly status: number;
9
10 constructor(message: string, status: number, code = "request_failed", details?: unknown) {
11 super(message);
12 this.name = "APIClientError";
13 this.status = status;
14 this.code = code;
15 this.details = details;
16 }
17 }
18
19 function isRecord(value: unknown): value is Record<string, unknown> {
20 return typeof value === "object" && value !== null && !Array.isArray(value);
21 }
22
23 function resolveAPIURL(path: string): string {
24 if (/^[a-z][a-z\d+\-.]*:/i.test(path)) {
25 return path;
26 }
27
28 const baseURL = import.meta.env.VITE_PORTAL_API_BASE_URL?.trim();
29 if (!baseURL) {
30 return path;
31 }
32 const normalizedPath = path.startsWith("/") ? path : `/${path}`;
33 const parsedBase = new URL(baseURL);
34 const rawBasePath = parsedBase.pathname.replace(/\/$/, "");
35 const basePath = rawBasePath.endsWith("/api")
36 ? rawBasePath.slice(0, -"/api".length)
37 : rawBasePath;
38 if (
39 basePath !== "" &&
40 (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`))
41 ) {
42 parsedBase.pathname = normalizedPath;
43 } else {
44 parsedBase.pathname = `${basePath}${normalizedPath}`;
45 }
46 parsedBase.search = "";
47 parsedBase.hash = "";
48 return parsedBase.toString();
49 }
50
51 function isPathOrChild(pathname: string, root: string): boolean {
52 return pathname === root || pathname.startsWith(`${root}/`);
53 }
54
55 function ensureJsonEnvelope<T>(raw: unknown, path: string, status: number): APIEnvelope<T> {
56 if (!isRecord(raw) || typeof raw.ok !== "boolean") {
57 throw new APIClientError(`Invalid API response for ${path}`, status, "invalid_envelope", raw);
58 }
59 if (raw.ok) {
60 return {
61 ok: true,
62 data: (raw as { data: T }).data,
63 };
64 }
65
66 if (!isRecord(raw.error)) {
67 throw new APIClientError(`Invalid error payload for ${path}`, status, "invalid_envelope", raw.error);
68 }
69 const errorValue = raw.error;
70 if (typeof errorValue.code !== "string" || typeof errorValue.message !== "string") {
71 throw new APIClientError(`Invalid error payload for ${path}`, status, "invalid_envelope", raw.error);
72 }
73
74 return {
75 ok: false,
76 data: raw.data,
77 error: {
78 code: errorValue.code,
79 message: errorValue.message,
80 },
81 };
82 }
83
84 async function decodeEnvelope<T>(path: string, response: Response): Promise<APIEnvelope<T>> {
85 const text = await response.text();
86 if (!text.trim()) {
87 throw new APIClientError(
88 `Empty API response from ${path}`,
89 response.status,
90 "invalid_envelope",
91 text
92 );
93 }
94
95 let payload: unknown;
96 try {
97 payload = JSON.parse(text);
98 } catch {
99 throw new APIClientError(
100 `API response from ${path} was not valid JSON`,
101 response.status,
102 "invalid_json",
103 text
104 );
105 }
106
107 return ensureJsonEnvelope<T>(payload, path, response.status);
108 }
109
110 async function request<T>(path: string, init: RequestInit): Promise<T> {
111 let response: Response;
112 try {
113 const requestHeaders = {
114 ...((init.headers as Record<string, string> | undefined) ?? {}),
115 };
116 const pathname = new URL(path, window.location.origin).pathname;
117 const requiresAdminAuth =
118 isPathOrChild(pathname, BROWSER_API_PATHS.policy.root) ||
119 isPathOrChild(pathname, RELAY_API_PATHS.policy.root) ||
120 isPathOrChild(pathname, RELAY_API_PATHS.admin.root);
121 if (
122 requiresAdminAuth &&
123 pathname !== BROWSER_API_PATHS.admin.authLogin
124 ) {
125 const token = readAdminAuthToken();
126 if (token) {
127 requestHeaders.Authorization = `Bearer ${token}`;
128 }
129 }
130 response = await fetch(resolveAPIURL(path), {
131 credentials: "same-origin",
132 ...init,
133 headers: {
134 Accept: "application/json",
135 ...requestHeaders,
136 },
137 });
138 } catch (error) {
139 const isAbortError =
140 error instanceof DOMException && error.name === "AbortError";
141 throw new APIClientError(
142 isAbortError ? "Request was aborted" : "Network request failed",
143 0,
144 isAbortError ? "aborted" : "network_error",
145 error
146 );
147 }
148
149 const envelope = await decodeEnvelope<T>(path, response);
150 if (envelope.ok) {
151 return envelope.data as T;
152 }
153
154 const message =
155 envelope.error?.message?.trim() || response.statusText || "Request failed";
156 const code = envelope.error?.code?.trim() || "request_failed";
157 throw new APIClientError(message, response.status, code, envelope.data);
158 }
159
160 function jsonRequestInit(body?: unknown): RequestInit {
161 if (body === undefined) {
162 return { method: "POST", headers: {} };
163 }
164
165 return {
166 method: "POST",
167 headers: { "Content-Type": "application/json" },
168 body: JSON.stringify(body),
169 };
170 }
171
172 export const apiClient = {
173 get<T>(path: string): Promise<T> {
174 return request<T>(path, { method: "GET" });
175 },
176 post<T>(path: string, body?: unknown): Promise<T> {
177 return request<T>(path, jsonRequestInit(body));
178 },
179 };