doe/fe/src/lib/apiClient.ts
2025-03-30 19:42:32 +02:00

278 lines
12 KiB
TypeScript

// 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<RequestInit, 'body'> {
// Can add custom options here later, e.g.:
// skipAuth?: boolean; // To bypass adding the Authorization header
}
// --- Core Request Function ---
// Uses generics <T> to allow specifying the expected successful response data type
async function request<T = unknown>(
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<T> {
// 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: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
return request<T>('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: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
return request<T>('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: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
return request<T>('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: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
// DELETE requests might or might not have a body depending on API design
return request<T>('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: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
return request<T>('PATCH', path, data, options);
}
};
// Optional: Export the error class as well if needed externally
// export { ApiClientError };