/ src / api / client.ts
client.ts
  1  // Copyright (c) 2026 VPL Solutions. All rights reserved.
  2  // Licensed under the MIT License. See LICENSE for details.
  3  
  4  import { config } from '../config';
  5  import { getAuthHeaders } from '../auth/getAuthHeaders';
  6  
  7  export class ApiError extends Error {
  8    status: number;
  9    requestId?: string;
 10  
 11    constructor(message: string, status: number, requestId?: string) {
 12      super(message);
 13      this.name = 'ApiError';
 14      this.status = status;
 15      this.requestId = requestId;
 16    }
 17  }
 18  
 19  const DEFAULT_TIMEOUT_MS = 30_000;
 20  
 21  interface RequestOptions extends RequestInit {
 22    params?: Record<string, string>;
 23    baseUrl?: string;
 24    timeoutMs?: number;
 25  }
 26  
 27  async function request<T>(
 28    endpoint: string,
 29    options: RequestOptions = {}
 30  ): Promise<T> {
 31    const { params, baseUrl = config.apiBaseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, ...init } = options;
 32  
 33    let url = `${baseUrl}${endpoint}`;
 34  
 35    if (params) {
 36      const searchParams = new URLSearchParams(params);
 37      url += `?${searchParams.toString()}`;
 38    }
 39  
 40    const controller = new AbortController();
 41    const timeout = setTimeout(() => controller.abort(), timeoutMs);
 42  
 43    const authHeaders = await getAuthHeaders();
 44  
 45    let response: Response;
 46    try {
 47      response = await fetch(url, {
 48        ...init,
 49        signal: init.signal ?? controller.signal,
 50        headers: {
 51          ...(!(init.body instanceof FormData) && { 'Content-Type': 'application/json' }),
 52          ...authHeaders,
 53          ...init.headers,
 54        },
 55      });
 56    } catch (err) {
 57      if (err instanceof Error && err.name === 'AbortError') {
 58        throw new ApiError(`Request timed out after ${timeoutMs}ms`, 0);
 59      }
 60      throw err;
 61    } finally {
 62      clearTimeout(timeout);
 63    }
 64  
 65    if (!response.ok) {
 66      // 401 is handled by getAuthHeaders (acquireTokenSilent → acquireTokenRedirect).
 67      // Do not trigger re-auth here — a 401 from the backend means the token
 68      // was sent but rejected (e.g. not admin), not that the session expired.
 69      const errorData = await response.json().catch(() => ({}));
 70      throw new ApiError(
 71        errorData.detail || `Request failed: ${response.statusText}`,
 72        response.status,
 73        errorData.request_id
 74      );
 75    }
 76  
 77    return response.json();
 78  }
 79  
 80  export const api = {
 81    get: <T>(endpoint: string, options?: RequestOptions) =>
 82      request<T>(endpoint, { ...options, method: 'GET' }),
 83  
 84    post: <T>(endpoint: string, data?: unknown, options?: RequestOptions) =>
 85      request<T>(endpoint, {
 86        ...options,
 87        method: 'POST',
 88        body: data ? JSON.stringify(data) : undefined,
 89      }),
 90  
 91    postForm: <T>(endpoint: string, formData: FormData, options?: RequestOptions) =>
 92      request<T>(endpoint, { ...options, method: 'POST', body: formData }),
 93  
 94    postForBlob: async (endpoint: string, data?: unknown, options?: RequestOptions) => {
 95      const { params, baseUrl = config.apiBaseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, ...init } = options ?? {};
 96      let url = `${baseUrl}${endpoint}`;
 97      if (params) url += `?${new URLSearchParams(params).toString()}`;
 98  
 99      const controller = new AbortController();
100      const timeout = setTimeout(() => controller.abort(), timeoutMs);
101      const blobAuthHeaders = await getAuthHeaders();
102  
103      let response: Response;
104      try {
105        response = await fetch(url, {
106          ...init,
107          signal: init.signal ?? controller.signal,
108          method: 'POST',
109          headers: { 'Content-Type': 'application/json', ...blobAuthHeaders, ...init.headers },
110          body: data ? JSON.stringify(data) : undefined,
111        });
112      } catch (err) {
113        if (err instanceof Error && err.name === 'AbortError') {
114          throw new ApiError(`Request timed out after ${timeoutMs}ms`, 0);
115        }
116        throw err;
117      } finally {
118        clearTimeout(timeout);
119      }
120  
121      if (!response.ok) {
122        const errorData = await response.json().catch(() => ({}));
123        throw new ApiError(
124          errorData.detail || `Request failed: ${response.statusText}`,
125          response.status,
126          errorData.request_id,
127        );
128      }
129      return response.blob();
130    },
131  };