magically-sdk
Version:
Official SDK for Magically - Build mobile apps with AI
236 lines (202 loc) • 7.61 kB
text/typescript
import { Logger } from './Logger';
import { SDKConfig } from './types';
export interface RequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: any;
operation?: string; // For logging context
}
export class APIClient {
private config: SDKConfig;
private logger: Logger;
private baseUrl: string;
private apiKey: string | null = null;
// Infinite loop detection
private callHistory: Map<string, number[]> = new Map();
private readonly LOOP_THRESHOLD = 10; // Max calls per method in time window
private readonly TIME_WINDOW = 5000; // 5 seconds
private lastCleanup = 0; // Track last garbage collection
private readonly CLEANUP_INTERVAL = 30000; // Clean up every 30 seconds
constructor(config: SDKConfig, loggerPrefix: string = 'MagicallyAPI') {
this.config = config;
this.logger = new Logger(config.debug || false, loggerPrefix);
this.baseUrl = config.apiUrl || 'https://trymagically.com';
// Use API key from config if provided (for edge functions)
if (config.apiKey) {
this.apiKey = config.apiKey;
this.logger.info('API key provided in config - using API key authentication');
} else if (typeof globalThis !== 'undefined' && 'MAGICALLY_API_KEY' in globalThis) {
// Fallback to global environment
this.apiKey = (globalThis as any).MAGICALLY_API_KEY;
this.logger.info('API key detected in environment - using API key authentication');
}
}
/**
* Make an authenticated API request with automatic logging
*/
async request<T = any>(
endpoint: string,
options: RequestOptions,
token?: string | null
): Promise<T> {
// Check for infinite loops before making request
this.checkForInfiniteLoop(options.operation || endpoint);
const startTime = Date.now();
let requestId: string = '';
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
// Use API key if available (edge environment), otherwise use provided token
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
this.logger.debug('Using API key authentication for request');
} else if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const requestConfig: RequestInit = {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
};
// Log the request
requestId = this.logger.networkRequest(options.method, url, {
headers,
body: options.body,
operation: options.operation
});
try {
const response = await fetch(url, requestConfig);
const responseData = await response.json();
const duration = Date.now() - startTime;
if (!response.ok) {
// Log error response
this.logger.networkError(requestId, responseData, {
duration,
operation: options.operation
});
// Throw structured error
this.handleAPIError(responseData, `${options.operation || 'Request'} failed`);
}
// Log successful response
this.logger.networkResponse(requestId, {
status: response.status,
statusText: response.statusText,
duration,
data: this.sanitizeResponseData(responseData, options.operation),
operation: options.operation
});
return responseData;
} catch (error) {
// Log network errors
if (requestId) {
this.logger.networkError(requestId, error, {
duration: Date.now() - startTime,
operation: options.operation
});
}
throw error;
}
}
/**
* Check if running in edge environment (has API key)
*/
isEdgeEnvironment(): boolean {
return this.apiKey !== null;
}
/**
* Sanitize response data for logging (avoid logging large arrays)
*/
private sanitizeResponseData(data: any, operation?: string): any {
if (operation === 'query' && data.data && Array.isArray(data.data)) {
return {
...data,
dataCount: data.data.length,
data: '[Array of items]'
};
}
return data;
}
/**
* Check for infinite loops by tracking method call frequency
*/
private checkForInfiniteLoop(methodName: string): void {
const now = Date.now();
const cleanMethodName = methodName.replace(/[^a-zA-Z0-9:_-]/g, '_');
// Get or create call history for this method
if (!this.callHistory.has(cleanMethodName)) {
this.callHistory.set(cleanMethodName, []);
}
const calls = this.callHistory.get(cleanMethodName)!;
// Auto garbage collection - clean up stale method entries periodically
if (now - this.lastCleanup > this.CLEANUP_INTERVAL) {
this.garbageCollectCallHistory(now);
this.lastCleanup = now;
}
// Remove calls outside the time window
const cutoffTime = now - this.TIME_WINDOW;
const recentCalls = calls.filter(timestamp => timestamp > cutoffTime);
// Add current call
recentCalls.push(now);
// If no recent calls, remove the method entry entirely
if (recentCalls.length === 1) {
this.callHistory.set(cleanMethodName, recentCalls);
} else {
this.callHistory.set(cleanMethodName, recentCalls);
}
// Check if we've exceeded the threshold
if (recentCalls.length > this.LOOP_THRESHOLD) {
// Use logger for structured warning
this.logger.error('🚨 INFINITE LOOP DETECTED', {
method: methodName,
callCount: recentCalls.length,
timeWindow: `${this.TIME_WINDOW/1000}s`,
threshold: this.LOOP_THRESHOLD,
message: `Method '${methodName}' called ${recentCalls.length} times in ${this.TIME_WINDOW/1000}s`
});
// Clear the history to prevent further spam
this.callHistory.set(cleanMethodName, []);
// Throw error to stop the loop
throw new Error(`Infinite loop detected in method: ${methodName}. Too many rapid calls (${recentCalls.length} in ${this.TIME_WINDOW/1000}s)`);
}
}
/**
* Garbage collect stale call history entries to prevent memory leaks
*/
private garbageCollectCallHistory(now: number): void {
const cutoffTime = now - this.TIME_WINDOW;
const methodsToDelete: string[] = [];
// Check each method's call history
for (const [methodName, timestamps] of this.callHistory.entries()) {
// Filter out old calls
const recentCalls = timestamps.filter(timestamp => timestamp > cutoffTime);
if (recentCalls.length === 0) {
// No recent calls - mark for deletion
methodsToDelete.push(methodName);
} else {
// Update with only recent calls
this.callHistory.set(methodName, recentCalls);
}
}
// Remove stale method entries
methodsToDelete.forEach(methodName => {
this.callHistory.delete(methodName);
});
if (methodsToDelete.length > 0) {
this.logger.debug('Garbage collected call history', {
removedMethods: methodsToDelete.length,
totalMethods: this.callHistory.size
});
}
}
/**
* Handle API errors with structured error information
*/
private handleAPIError(errorData: any, fallbackMessage: string): never {
// Create an error with the response data attached for the Logger
const error = new Error(errorData.error || errorData.message || fallbackMessage) as any;
error.responseData = errorData; // Preserve the original error response for Logger
throw error;
}
}