File size: 2,989 Bytes
49e7bf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/**
 * Production-Grade API Client
 * - Unified Fetch Wrapper with JWT injection.
 * - Centralized 401 Interception (Auto-Logout).
 * - Support for JSON and Multipart/Form-Data.
 */

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";

export type ApiError = {
  message: string;
  status?: number;
  detail?: any;
};

async function request<T>(
  endpoint: string,
  options: RequestInit & { params?: Record<string, string> } = {}
): Promise<T> {
  const { params, headers, ...config } = options;

  // 1. Construct URL with Search Params
  const url = new URL(`${BASE_URL}${endpoint}`);
  if (params) {
    Object.entries(params).forEach(([key, val]) => url.searchParams.append(key, val));
  }

  // 2. Token Retrieval (Direct localStorage for non-React context utility)
  const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;

  // 3. Header Synthesis
  const authHeader = token ? { Authorization: `Bearer ${token}` } : {};
  
  // Don't set Content-Type if sending FormData (browser handles it)
  const isFormData = config.body instanceof FormData;
  const contentTypeHeader = isFormData ? {} : { "Content-Type": "application/json" };

  const finalConfig: RequestInit = {
    ...config,
    headers: {
      ...contentTypeHeader,
      ...authHeader,
      ...headers,
    },
  };

  try {
    const response = await fetch(url.toString(), finalConfig);

    // 4. Global Interceptor: Handle Unauthorized
    if (response.status === 401) {
      if (typeof window !== "undefined") {
        localStorage.removeItem("token");
        // Force hard-redirect to clear state if token is dead
        window.location.href = "/login?error=session_expired";
      }
      throw new Error("Unauthorized access. Please log in again.");
    }

    // 5. Success Handlers
    if (response.status === 204) return {} as T;
    
    const data = await response.json();

    if (!response.ok) {
      // Return the specific backend detail if available (FastAPI style)
      const errorMsg = data.detail || "The research server encountered an issue.";
      return Promise.reject({ message: errorMsg, status: response.status, detail: data });
    }

    return data as T;
  } catch (err: any) {
    // Handle Network Failures
    return Promise.reject({
      message: err.message || "Unable to connect to the research server. Check your connection.",
      status: err.status || 500
    });
  }
}

export const api = {
  get: <T>(url: string, p?: Record<string, string>) => request<T>(url, { method: "GET", params: p }),
  post: <T>(url: string, body: any) => request<T>(url, { method: "POST", body: JSON.stringify(body) }),
  put: <T>(url: string, body: any) => request<T>(url, { method: "PUT", body: JSON.stringify(body) }),
  delete: <T>(url: string) => request<T>(url, { method: "DELETE" }),
  // For PICO/Avatar uploads later:
  upload: <T>(url: string, formData: FormData) => request<T>(url, { method: "POST", body: formData }),
};