UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

767 lines (701 loc) 28.2 kB
/** * Base HTTP Client for KRAPI SDK * * Provides common HTTP functionality that all service clients extend. * Handles authentication, request/response interceptors, and common HTTP methods. * * @module http-clients/base-http-client * @example * class MyServiceClient extends BaseHttpClient { * async getData() { * return this.get('/endpoint'); * } * } */ import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError, isAxiosError, } from "axios"; import { ApiResponse, PaginatedResponse, QueryOptions } from "../core"; import { KrapiError } from "../core/krapi-error"; import { isBackendUrl } from "../utils/endpoint-utils"; import { HttpError } from "./http-error"; /** * HTTP Client Configuration * * @interface HttpClientConfig * @property {string} baseUrl - Base URL for API requests * @property {string} [apiKey] - API key for authentication * @property {string} [sessionToken] - Session token for authentication * @property {number} [timeout] - Request timeout in milliseconds */ /** * Retry configuration for HTTP requests */ export interface RetryConfig { enabled?: boolean; maxRetries?: number; retryDelay?: number; retryableStatusCodes?: number[]; retryableErrorCodes?: string[]; } export interface HttpClientConfig { baseUrl: string; apiKey?: string; sessionToken?: string; projectId?: string; // Optional project ID - only added as header for project-scoped routes timeout?: number; retry?: RetryConfig; } /** * Base HTTP Client Class * * Base class for all HTTP client implementations. * Provides common HTTP methods (GET, POST, PUT, DELETE) and authentication handling. * * @class BaseHttpClient * @example * const client = new BaseHttpClient({ baseUrl: 'https://api.example.com' }); * await client.initializeClient(); * const response = await client.get('/endpoint'); */ export class BaseHttpClient { protected baseUrl: string; protected apiKey?: string; protected sessionToken?: string; protected projectId?: string; // Project ID - only added as header for project-scoped routes protected httpClient!: AxiosInstance; protected retryConfig?: RetryConfig; /** * Create a new BaseHttpClient instance * * @param {HttpClientConfig} config - HTTP client configuration */ constructor(config: HttpClientConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash if (config.apiKey) this.apiKey = config.apiKey; if (config.sessionToken) this.sessionToken = config.sessionToken; if (config.projectId) this.projectId = config.projectId; if (config.retry) this.retryConfig = config.retry; } /** * Initialize the HTTP client with interceptors * * Sets up axios instance with authentication interceptors and error handling. * * @returns {Promise<void>} * * @example * await client.initializeClient(); */ async initializeClient() { if (this.httpClient !== undefined) return; // Already initialized // Detect backend vs frontend URLs and use appropriate path // Backend URLs (port 3470) use /krapi/k1 (NO /api prefix) // Frontend URLs (port 3498) use /api/krapi/k1 let baseURL = this.baseUrl; const isBackend = isBackendUrl(baseURL); // Check if URL already has a path (use precise matching to avoid false positives) // Match paths that start with /api/krapi/k1 or /krapi/k1 (not just containing them) const hasApiPath = /\/api\/krapi\/k1(\/|$)/.test(baseURL); const hasKrapiPath = /\/krapi\/k1(\/|$)/.test(baseURL) && !hasApiPath; if (hasApiPath) { // URL already has /api/krapi/k1 - keep as is (already correct for frontend) // No modification needed } else if (hasKrapiPath) { // URL has /krapi/k1 but not /api/krapi/k1 if (isBackend) { // Backend URL with explicit /krapi/k1 path - keep as is (CRITICAL: do not modify) // baseURL stays as is - no modification } else { // Frontend URL with /krapi/k1 - convert to /api/krapi/k1 baseURL = baseURL.replace("/krapi/k1", "/api/krapi/k1"); } } else { // No path specified - add appropriate path based on URL type if (isBackend) { baseURL = `${baseURL}/krapi/k1`; // Backend: NO /api prefix } else { baseURL = `${baseURL}/api/krapi/k1`; // Frontend: WITH /api prefix } } this.httpClient = axios.create({ baseURL, timeout: 30000, headers: { "Content-Type": "application/json", }, }); // Add request interceptor for authentication and conditional project ID header // This interceptor: // 1. Sets Authorization header from current token (session token or API key) // 2. Conditionally adds X-Project-ID header ONLY for project-scoped routes // (NOT for list/create operations like GET/POST /projects) this.httpClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Handle FormData: Remove Content-Type header so axios can set it automatically // with the proper multipart/form-data boundary if (config.data instanceof FormData) { // Delete Content-Type header to let axios/browser set it with boundary delete config.headers["Content-Type"]; delete config.headers["content-type"]; } // Set authorization token const token = this.getCurrentToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; // Remove X-API-Key header if it exists (we use Bearer token format) delete config.headers["X-API-Key"]; } // DEFENSIVE: Explicitly handle X-Project-ID header for all requests // This ensures header is NOT sent for list/create operations, even if it was set elsewhere if (config.url) { // Normalize the path by removing any base path prefixes // Handles: /projects, /krapi/k1/projects, /api/krapi/k1/projects let normalizedPath = config.url; normalizedPath = normalizedPath.replace(/^(\/api)?\/krapi\/k1/, ""); // Check if this is a list/create operation (e.g., GET /projects, POST /projects) // Match /projects, /projects/, or /projects? but NOT /projects/{id} const isProjectListOrCreate = /^\/projects\/?(\?|$)/.test( normalizedPath ); // Check if this is a project-scoped route (has /projects/{id} pattern) // Project-scoped routes: /projects/{id}, /projects/{id}/settings, etc. const isProjectScoped = /^\/projects\/[^/]+/.test(normalizedPath); if (isProjectListOrCreate) { // DEFENSIVE: Explicitly remove header for list/create operations // This ensures header is NOT sent even if it was set elsewhere delete config.headers["X-Project-ID"]; delete config.headers["x-project-id"]; } else if (isProjectScoped && this.projectId) { // Only add header for project-scoped routes when projectId is set config.headers["X-Project-ID"] = this.projectId; } } // Comprehensive debug logging to verify interceptor behavior if ( process.env.NODE_ENV === "development" || process.env.DEBUG_SDK_HEADERS ) { const normalizedPath = config.url?.replace(/^(\/api)?\/krapi\/k1/, "") || ""; const isProjectListOrCreate = /^\/projects(\/|\?|$)/.test( normalizedPath ); const isProjectScoped = /^\/projects\/[^/]+/.test(normalizedPath); const hasProjectIdHeader = !!( config.headers["X-Project-ID"] || config.headers["x-project-id"] ); const relevantHeaders = Object.keys(config.headers) .filter( (k) => k.toLowerCase().includes("project") || k.toLowerCase() === "authorization" ) .reduce((acc, k) => { acc[k] = config.headers[k] ? "present" : "missing"; return acc; }, {} as Record<string, string>); // eslint-disable-next-line no-console console.log("[SDK Interceptor]", { method: config.method?.toUpperCase(), url: config.url, normalizedPath, hasProjectId: !!this.projectId, isProjectListOrCreate, isProjectScoped, willAddHeader: isProjectScoped && !!this.projectId, willRemoveHeader: isProjectListOrCreate, actualHeaderPresent: hasProjectIdHeader, relevantHeaders, }); } return config; } ); // Add response interceptor for error handling this.httpClient.interceptors.response.use( (response: AxiosResponse) => response.data, // Return just the data (error: unknown) => { // Handle Axios errors if (isAxiosError(error)) { const axiosError = error as AxiosError<{ error?: string; message?: string; success?: boolean; }>; // Extract error information const status = axiosError.response?.status; const responseData = axiosError.response?.data; const method = axiosError.config?.method?.toUpperCase(); const relativeUrl = axiosError.config?.url; const baseUrl = axiosError.config?.baseURL || ""; // Construct full URL for error messages // baseURL already includes /api/krapi/k1, so we just need to append the relative path const fullUrl = relativeUrl ? `${baseUrl}${relativeUrl}` : baseUrl; // Extract request body/data const requestBody = axiosError.config?.data; // Extract query parameters const requestQuery = axiosError.config?.params || {}; // Extract response headers const responseHeaders: Record<string, string> = {}; if (axiosError.response?.headers) { Object.keys(axiosError.response.headers).forEach((key) => { const value = axiosError.response?.headers[key]; if (typeof value === "string") { responseHeaders[key] = value; } else if (Array.isArray(value) && value.length > 0) { responseHeaders[key] = value.join(", "); } }); } // Sanitize headers (remove sensitive data) const requestHeaders: Record<string, string> = {}; if (axiosError.config && axiosError.config.headers) { const headers = axiosError.config.headers; Object.keys(headers).forEach((key) => { const value = headers[key]; if (typeof value === "string") { // Mask authorization tokens if (key.toLowerCase() === "authorization") { requestHeaders[key] = `${value.substring(0, 20)}...`; } else { requestHeaders[key] = value; } } }); } // Build error message let errorMessage = "HTTP request failed"; let errorCode: string | undefined; if (status) { // HTTP error (4xx, 5xx) if (responseData) { // Try to extract error message from response if (typeof responseData === "object" && responseData !== null) { const data = responseData as Record<string, unknown>; errorMessage = (data.error as string) || (data.message as string) || `HTTP ${status} ${ axiosError.response?.statusText || "Error" }`; // Extract error code if available if (data.code && typeof data.code === "string") { errorCode = data.code; } } else if (typeof responseData === "string") { errorMessage = responseData; } } else { errorMessage = `HTTP ${status} ${ axiosError.response?.statusText || "Error" }`; } // Add specific guidance based on status code if (status === 404) { const isBackend = isBackendUrl(baseUrl); if (isBackend) { errorMessage += `\n- Backend URL detected (port 3470) - SDK should use /krapi/k1 path\n` + `- Verify the endpoint path is correct\n` + `- Check that backend routes are accessible at /krapi/k1/...`; } else { errorMessage += `\n- Frontend URL detected (port 3498) - SDK should use /api/krapi/k1 path\n` + `- The SDK should automatically append /api/krapi/k1/ to your endpoint\n` + `- Verify the endpoint path is correct`; } } else if (status === 401) { // Context-aware error message based on authentication method const currentToken = this.getCurrentToken(); const isSessionToken = !!this.sessionToken; if (isSessionToken) { errorMessage += `\n- Invalid or expired session token\n` + `- Verify the session token is correct\n` + `- Check if the session has expired\n` + `- Ensure you're logged in and the session is active\n` + `- Try logging in again to get a new session token`; } else if (currentToken) { errorMessage += `\n- Invalid or expired API key\n` + `- Check that your API key is correct\n` + `- Verify the API key has the required scopes\n` + `- Ensure the API key hasn't been revoked`; } else { errorMessage += `\n- Authentication required\n` + `- No session token or API key provided\n` + `- Set a session token using setSessionToken() or provide an API key`; } } else if (status === 403) { // Context-aware error message based on authentication method const isSessionToken = !!this.sessionToken; if (isSessionToken) { errorMessage += `\n- Your session token may not have permission for this operation\n` + `- Check the user's role and permissions\n` + `- Verify the session token belongs to a user with sufficient access\n` + `- Ensure you're using the correct authentication method`; } else { errorMessage += `\n- Your API key may not have permission for this operation\n` + `- Check the API key scopes and permissions\n` + `- Verify you're using the correct authentication method`; } } // Create HttpError with detailed information // Note: Route information (method and URL) is available in HttpError object properties // for programmatic access, not included in the error message string const httpErrorOptions: { status?: number; method?: string; url?: string; requestHeaders?: Record<string, string>; requestBody?: unknown; requestQuery?: Record<string, unknown>; responseData?: unknown; responseHeaders?: Record<string, string>; code?: string; originalError?: unknown; } = {}; if (status !== undefined) httpErrorOptions.status = status; if (method !== undefined) httpErrorOptions.method = method; // Store full URL for error details if (fullUrl !== undefined) httpErrorOptions.url = fullUrl; if (Object.keys(requestHeaders).length > 0) httpErrorOptions.requestHeaders = requestHeaders; if (requestBody !== undefined) httpErrorOptions.requestBody = requestBody; if (Object.keys(requestQuery).length > 0) httpErrorOptions.requestQuery = requestQuery; if (responseData !== undefined) httpErrorOptions.responseData = responseData; if (Object.keys(responseHeaders).length > 0) httpErrorOptions.responseHeaders = responseHeaders; if (errorCode !== undefined) httpErrorOptions.code = errorCode; if (error !== undefined) httpErrorOptions.originalError = error; const httpError = new HttpError(errorMessage, httpErrorOptions); return Promise.reject(httpError); } else { // Network error (no response) const networkDisplayUrl = relativeUrl ? `${baseUrl.replace(/^https?:\/\/[^/]+/, "")}${relativeUrl}` : fullUrl || "endpoint"; if ( axiosError.code === "ECONNABORTED" || axiosError.message.includes("timeout") ) { errorMessage = `Request timeout: ${ method || "Request" } to ${networkDisplayUrl} exceeded ${ axiosError.config?.timeout || 30000 }ms`; errorCode = "TIMEOUT"; } else if ( axiosError.code === "ENOTFOUND" || axiosError.code === "ECONNREFUSED" ) { const isBackend = isBackendUrl(baseUrl); const connectionType = isBackend ? "backend URL (port 3470)" : "frontend URL (port 3498)"; errorMessage = `Cannot connect to Krapi Server at ${networkDisplayUrl}.\n` + `- Is the server running?\n` + `- Are you using the ${connectionType}?\n` + `- Check firewall settings if accessing remotely.`; errorCode = "NETWORK_ERROR"; } else if ( axiosError.code === "ECONNRESET" || axiosError.message.includes("socket hang up") ) { errorMessage = `Connection reset by server at ${networkDisplayUrl}.\n` + `- The server may have closed the connection unexpectedly\n` + `- This can happen with long-running queries or large result sets\n` + `- Try reducing query scope or increasing timeout if applicable\n` + `- Check server logs for errors`; errorCode = "CONNECTION_RESET"; } else { errorMessage = `Network error: ${ axiosError.message || "Failed to connect to server" }\n` + `- Verify the endpoint URL is correct\n` + `- Check network connectivity\n` + `- Ensure the server is accessible`; errorCode = "NETWORK_ERROR"; } // Extract request body/data for network errors too const requestBody = axiosError.config?.data; const requestQuery = axiosError.config?.params || {}; const httpErrorOptions: { method?: string; url?: string; requestHeaders?: Record<string, string>; requestBody?: unknown; requestQuery?: Record<string, unknown>; code?: string; originalError?: unknown; } = {}; if (method !== undefined) httpErrorOptions.method = method; if (fullUrl !== undefined) httpErrorOptions.url = fullUrl; if (Object.keys(requestHeaders).length > 0) httpErrorOptions.requestHeaders = requestHeaders; if (requestBody !== undefined) httpErrorOptions.requestBody = requestBody; if (Object.keys(requestQuery).length > 0) httpErrorOptions.requestQuery = requestQuery; if (errorCode !== undefined) httpErrorOptions.code = errorCode; if (error !== undefined) httpErrorOptions.originalError = error; const httpError = new HttpError(errorMessage, httpErrorOptions); return Promise.reject(httpError); } } // Handle non-Axios errors if (error instanceof Error) { const httpError = new HttpError(error.message, { originalError: error, code: "UNKNOWN_ERROR", }); return Promise.reject(httpError); } // Handle unknown error types const httpError = new HttpError("Unknown error occurred", { originalError: error, code: "UNKNOWN_ERROR", }); return Promise.reject(httpError); } ); } /** * Get current authentication token (session token or API key) * * This method is used by the request interceptor to get the current token * at request time, ensuring we always use the latest token value. * * @returns {string | undefined} Current token or undefined if none set * @private */ private getCurrentToken(): string | undefined { return this.sessionToken || this.apiKey; } /** * Set project ID for project-scoped operations * * The project ID will be automatically added as X-Project-ID header * only for project-scoped routes (e.g., /projects/{id}/...). * It will NOT be added for list/create operations (e.g., /projects). * * @param {string} projectId - Project ID * @returns {void} * * @example * client.setProjectId('project-id-here'); */ setProjectId(projectId: string): void { this.projectId = projectId; } /** * Set session token for authentication * * @param {string} token - Session token * @returns {void} * @throws {Error} If HTTP client is not initialized * * @example * client.setSessionToken('session-token-here'); */ // Authentication methods setSessionToken(token: string) { // Ensure HTTP client is initialized if (!this.httpClient) { throw KrapiError.serviceUnavailable( "HTTP client not initialized. Call initializeClient() first or ensure connect() was called." ); } this.sessionToken = token; delete this.apiKey; // Update axios instance defaults for consistency // Note: The interceptor uses getCurrentToken() which reads from this.sessionToken, // so it will automatically use the latest token value this.httpClient.defaults.headers.common[ "Authorization" ] = `Bearer ${token}`; delete this.httpClient.defaults.headers.common["X-API-Key"]; } /** * Set API key for authentication * * @param {string} key - API key * @returns {void} * * @example * client.setApiKey('api-key-here'); */ setApiKey(key: string) { // Ensure HTTP client is initialized if (!this.httpClient) { throw KrapiError.serviceUnavailable( "HTTP client not initialized. Call initializeClient() first or ensure connect() was called." ); } this.apiKey = key; delete this.sessionToken; // Update axios instance defaults for consistency // Note: The interceptor uses getCurrentToken() which reads from this.apiKey, // so it will automatically use the latest token value // Use Bearer token format for API keys (backend expects Authorization: Bearer {token}) this.httpClient.defaults.headers.common["Authorization"] = `Bearer ${key}`; delete this.httpClient.defaults.headers.common["X-API-Key"]; } /** * Clear authentication credentials * * Removes both session token and API key. * * @returns {void} * * @example * client.clearAuth(); */ clearAuth() { delete this.sessionToken; delete this.apiKey; } /** * Send GET request * * @template T * @param {string} endpoint - API endpoint * @param {QueryOptions} [params] - Query parameters * @returns {Promise<ApiResponse<T>>} API response * * @example * const response = await client.get('/projects', { limit: 10 }); */ /** * Execute request with retry logic if configured * * @template T * @param {() => Promise<T>} requestFn - Request function to execute * @returns {Promise<T>} Request result */ private async executeWithRetry<T>(requestFn: () => Promise<T>): Promise<T> { if (!this.retryConfig?.enabled) { return requestFn(); } const maxRetries = this.retryConfig.maxRetries ?? 3; const retryDelay = this.retryConfig.retryDelay ?? 1000; const retryableStatusCodes = this.retryConfig.retryableStatusCodes ?? [ 408, 429, 500, 502, 503, 504, ]; const retryableErrorCodes = this.retryConfig.retryableErrorCodes ?? [ "TIMEOUT", "NETWORK_ERROR", ]; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await requestFn(); } catch (error) { lastError = error; // Don't retry on last attempt if (attempt >= maxRetries) { break; } // Check if error is retryable let shouldRetry = false; if (error instanceof HttpError) { // Check status code if (error.status && retryableStatusCodes.includes(error.status)) { shouldRetry = true; } // Check error code if (error.code && retryableErrorCodes.includes(error.code)) { shouldRetry = true; } } else if (error instanceof Error) { // Check for network errors if ( error.message.includes("timeout") || error.message.includes("ECONNREFUSED") || error.message.includes("ENOTFOUND") ) { shouldRetry = true; } } if (!shouldRetry) { break; } // Wait before retrying (exponential backoff) const delay = retryDelay * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } // If we get here, all retries failed throw lastError; } // Common HTTP methods protected async get<T>( endpoint: string, params?: QueryOptions ): Promise<ApiResponse<T>> { await this.initializeClient(); return this.executeWithRetry(() => this.httpClient.get(endpoint, { params }) ); } protected async post<T>( endpoint: string, data?: unknown ): Promise<ApiResponse<T>> { await this.initializeClient(); return this.executeWithRetry(() => this.httpClient.post(endpoint, data)); } protected async put<T>( endpoint: string, data?: unknown ): Promise<ApiResponse<T>> { await this.initializeClient(); return this.executeWithRetry(() => this.httpClient.put(endpoint, data)); } protected async patch<T>( endpoint: string, data?: unknown ): Promise<ApiResponse<T>> { await this.initializeClient(); return this.executeWithRetry(() => this.httpClient.patch(endpoint, data)); } protected async delete<T>( endpoint: string, data?: unknown ): Promise<ApiResponse<T>> { await this.initializeClient(); return this.executeWithRetry(() => this.httpClient.delete(endpoint, { data }) ); } // Utility method to build query strings protected buildQueryString(params: Record<string, unknown>): string { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { query.append(key, String(value)); } }); return query.toString(); } // Handle paginated responses protected async getPaginated<T>( endpoint: string, params?: QueryOptions & { page?: number } ): Promise<PaginatedResponse<T>> { await this.initializeClient(); return this.httpClient.get(endpoint, { params }); } }