// src/lib/apiClient.ts // Import necessary modules/types import { browser } from '$app/environment'; // For checks if needed import { error } from '@sveltejs/kit'; // Can be used for throwing errors in load functions import { authStore, logout, getCurrentToken } from './stores/authStore'; // Import store and helpers // --- Configuration --- // Read base URL from Vite environment variables // Ensure VITE_API_BASE_URL is set in your fe/.env file (e.g., VITE_API_BASE_URL=http://localhost:8000/api) const BASE_URL = import.meta.env.VITE_API_BASE_URL; // Initial check for configuration during module load (optional but good practice) if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build if possible console.error( 'VITE_API_BASE_URL is not defined. Please set it in your .env file. API calls may fail.' ); } // --- Custom Error Class for API Client --- export class ApiClientError extends Error { status: number; // HTTP status code errorData: unknown; // Parsed error data from response body (if any) constructor(message: string, status: number, errorData: unknown = null) { super(message); // Pass message to the base Error class this.name = 'ApiClientError'; // Custom error name this.status = status; this.errorData = errorData; // Attempt to capture a cleaner stack trace in V8 environments (Node, Chrome) // Conditionally check if the non-standard captureStackTrace exists if (typeof (Error as any).captureStackTrace === 'function') { // Call it if it exists, casting Error to 'any' to bypass static type check (Error as any).captureStackTrace(this, ApiClientError); // Pass 'this' and the constructor } } } // --- Request Options Interface --- // Extends standard RequestInit but omits 'body' as we handle it separately interface RequestOptions extends Omit { // Can add custom options here later, e.g.: // skipAuth?: boolean; // To bypass adding the Authorization header } // --- Core Request Function --- // Uses generics to allow specifying the expected successful response data type async function request( method: string, path: string, // Relative path to the API endpoint (e.g., /v1/users/me) bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.) options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.) ): Promise { // Runtime check for BASE_URL, in case it wasn't set or available during initial load if (!BASE_URL) { // Depending on context (load function vs. component event), choose how to handle // error(500, 'API Base URL is not configured.'); // Use in load functions throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); // Throw for component events } // Construct the full URL safely const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path const url = `${cleanBase}/${cleanPath}`; // Initialize headers, setting Accept to JSON by default const headers = new Headers({ Accept: 'application/json', ...options.headers // Spread custom headers provided in options early }); // --- Prepare Request Body and Set Content-Type --- let processedBody: BodyInit | null = null; if (bodyData !== undefined && bodyData !== null) { if (bodyData instanceof URLSearchParams) { // Handle URL-encoded form data headers.set('Content-Type', 'application/x-www-form-urlencoded'); processedBody = bodyData; } else if (bodyData instanceof FormData) { // Handle FormData (multipart/form-data) // Let the browser set the Content-Type with the correct boundary // Important: DO NOT set 'Content-Type' manually for FormData // headers.delete('Content-Type'); // Ensure no manual Content-Type is set processedBody = bodyData; } else if (typeof bodyData === 'object') { // Handle plain JavaScript objects as JSON headers.set('Content-Type', 'application/json'); try { processedBody = JSON.stringify(bodyData); } catch (e) { console.error("Failed to stringify JSON body data:", bodyData, e); throw new Error("Invalid JSON body data provided."); } } else { // Handle other primitives (string, number, boolean) - default to sending as JSON stringified // Adjust this logic if you need to send plain text or other formats headers.set('Content-Type', 'application/json'); try { processedBody = JSON.stringify(bodyData) } catch (e) { console.error("Failed to stringify primitive body data:", bodyData, e); throw new Error("Invalid body data provided."); } } } // --- End Body Preparation --- // --- Add Authorization Header --- const currentToken = getCurrentToken(); // Get token synchronously from auth store // Add header if token exists and Authorization wasn't manually set in options.headers if (currentToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${currentToken}`); } // --- End Authorization Header --- // Assemble final fetch options const fetchOptions: RequestInit = { method: method.toUpperCase(), headers, body: processedBody, // Use the potentially processed body credentials: options.credentials ?? 'same-origin', // Default credentials policy mode: options.mode ?? 'cors', // Default mode cache: options.cache ?? 'default', // Default cache policy ...options // Spread remaining options, potentially overriding defaults if needed }; // --- Execute Fetch and Handle Response --- try { // Optional: Log request details for debugging // console.debug(`API Request: ${fetchOptions.method} ${url}`, { headers: Object.fromEntries(headers.entries()), body: bodyData }); const response = await fetch(url, fetchOptions); // Optional: Log response status // console.debug(`API Response Status: ${response.status} for ${fetchOptions.method} ${url}`); // Check if the response status code indicates failure (not 2xx) if (!response.ok) { let errorJson: unknown = null; // Attempt to parse error details from the response body try { errorJson = await response.json(); // console.debug(`API Error Response Body:`, errorJson); } catch (e) { // Ignore if response body isn't valid JSON or empty console.warn(`API Error response for ${response.status} was not valid JSON or empty.`); } // Create the custom error object const errorToThrow = new ApiClientError( `API request failed: ${response.status} ${response.statusText}`, response.status, errorJson ); // --- Global 401 (Unauthorized) Handling --- // If the server returns 401, assume the token is invalid/expired // and automatically log the user out by clearing the auth store. if (response.status === 401) { console.warn(`API Client: Received 401 Unauthorized for ${method} ${path}. Logging out.`); // Calling logout clears the token from store & localStorage logout(); // Optional: Trigger a redirect to login page. Often better handled // by calling code or root layout based on application structure. // import { goto } from '$app/navigation'; // if (browser) await goto('/login?sessionExpired=true'); } // --- End Global 401 Handling --- // Throw the error regardless, so the calling code knows the request failed throw errorToThrow; } // Handle successful responses with no content (e.g., 204 No Content for DELETE) if (response.status === 204) { // Assert type as T, assuming T can accommodate null or void if needed return null as T; } // Parse successful JSON response body const responseData = await response.json(); // Assert the response data matches the expected generic type T return responseData as T; } catch (err) { // Handle network errors (fetch throws TypeError) or errors thrown above console.error(`API Client request error during ${method} ${path}:`, err); // Ensure logout is called even if the caught error is a 401 ApiClientError // This handles cases where parsing a non-ok response might fail but status was 401 if (err instanceof ApiClientError && err.status === 401) { console.warn(`API Client: Caught ApiClientError 401 for ${method} ${path}. Ensuring logout.`); // Ensure logout state is cleared even if error originated elsewhere logout(); } // Re-throw the error so the calling code can handle it appropriately // If it's already our custom error, re-throw it directly if (err instanceof ApiClientError) { throw err; } // Otherwise, wrap network or other unexpected errors in our custom error type throw new ApiClientError( `Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`, 0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors err // Include the original error object as data ); } } // --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) --- // Provide simple wrappers around the core 'request' function export const apiClient = { /** * Performs a GET request. * @template T The expected type of the response data. * @param path API endpoint path (e.g., '/v1/users/me'). * @param options Optional fetch request options. * @returns Promise resolving to the parsed JSON response body of type T. */ get: (path: string, options: RequestOptions = {}): Promise => { return request('GET', path, undefined, options); }, /** * Performs a POST request. * @template T The expected type of the response data. * @param path API endpoint path (e.g., '/v1/auth/signup'). * @param data Request body data (object, FormData, URLSearchParams). * @param options Optional fetch request options. * @returns Promise resolving to the parsed JSON response body of type T. */ post: (path: string, data: unknown, options: RequestOptions = {}): Promise => { return request('POST', path, data, options); }, /** * Performs a PUT request. * @template T The expected type of the response data. * @param path API endpoint path. * @param data Request body data. * @param options Optional fetch request options. * @returns Promise resolving to the parsed JSON response body of type T. */ put: (path: string, data: unknown, options: RequestOptions = {}): Promise => { return request('PUT', path, data, options); }, /** * Performs a DELETE request. * @template T The expected type of the response data (often null or void). * @param path API endpoint path. * @param options Optional fetch request options. * @returns Promise resolving to the parsed JSON response body (often null for 204). */ delete: (path: string, options: RequestOptions = {}): Promise => { // DELETE requests might or might not have a body depending on API design return request('DELETE', path, undefined, options); }, /** * Performs a PATCH request. * @template T The expected type of the response data. * @param path API endpoint path. * @param data Request body data (usually partial updates). * @param options Optional fetch request options. * @returns Promise resolving to the parsed JSON response body of type T. */ patch: (path: string, data: unknown, options: RequestOptions = {}): Promise => { return request('PATCH', path, data, options); } }; // Optional: Export the error class as well if needed externally // export { ApiClientError };