278 lines
12 KiB
TypeScript
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 };
|