@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
text/typescript
/**
* 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 });
}
}