Advanced Usage
Advanced Usage
This guide covers advanced patterns and techniques for power users of @shkumbinhsn/fetcher.
GraphQL Integration
Use the fetcher with GraphQL APIs:
import { fetcher, defineError } from '@shkumbinhsn/fetcher';import { z } from 'zod';
// GraphQL error schemaconst GraphQLError = defineError(400, z.object({ errors: z.array(z.object({ message: z.string(), locations: z.array(z.object({ line: z.number(), column: z.number() })).optional(), path: z.array(z.union([z.string(), z.number()])).optional(), extensions: z.object({ code: z.string(), exception: z.record(z.any()).optional() }).optional() })), data: z.null().optional()}));
// GraphQL response wrapperconst GraphQLResponseSchema = <T extends z.ZodType>(dataSchema: T) => z.object({ data: dataSchema.nullable(), errors: z.array(z.object({ message: z.string(), locations: z.array(z.object({ line: z.number(), column: z.number() })).optional(), path: z.array(z.union([z.string(), z.number()])).optional() })).optional() });
class GraphQLClient { constructor( private endpoint: string, private headers: Record<string, string> = {} ) {}
async query<T>( query: string, variables: Record<string, any> = {}, schema: z.ZodType<T> ): Promise<T> { const response = await fetcher(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.headers }, body: JSON.stringify({ query, variables }), schema: GraphQLResponseSchema(schema), errors: [GraphQLError] });
if (response.errors && response.errors.length > 0) { throw new Error(`GraphQL errors: ${response.errors.map(e => e.message).join(', ')}`); }
if (response.data === null) { throw new Error('GraphQL query returned null data'); }
return response.data; }
async mutation<T>( mutation: string, variables: Record<string, any> = {}, schema: z.ZodType<T> ): Promise<T> { return this.query(mutation, variables, schema); }}
// Usageconst UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string()});
const client = new GraphQLClient('https://api.example.com/graphql', { 'Authorization': 'Bearer token'});
const user = await client.query( `query GetUser($id: ID!) { user(id: $id) { id name email } }`, { id: '123' }, z.object({ user: UserSchema }));WebSocket Integration
Combine HTTP fetching with WebSocket subscriptions:
interface SubscriptionOptions { onMessage: (data: any) => void; onError?: (error: Error) => void; onClose?: () => void;}
class RealTimeClient { private ws: WebSocket | null = null; private subscriptions = new Map<string, SubscriptionOptions>();
constructor( private httpClient: ApiClient, private wsUrl: string ) {}
// HTTP methods from the base client async get<T>(endpoint: string, init?: FetcherRequestInit<T>) { return this.httpClient.get(endpoint, init); }
async post<T>(endpoint: string, data?: any, init?: Omit<FetcherRequestInit<T>, 'method' | 'body'>) { return this.httpClient.post(endpoint, data, init); }
// WebSocket subscription subscribe(channel: string, options: SubscriptionOptions) { if (!this.ws) { this.connect(); }
this.subscriptions.set(channel, options);
if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'subscribe', channel })); } }
unsubscribe(channel: string) { this.subscriptions.delete(channel);
if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'unsubscribe', channel })); } }
private connect() { this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => { console.log('WebSocket connected'); // Subscribe to all pending channels for (const channel of this.subscriptions.keys()) { this.ws!.send(JSON.stringify({ type: 'subscribe', channel })); } };
this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); const subscription = this.subscriptions.get(message.channel);
if (subscription) { subscription.onMessage(message.data); } } catch (error) { console.error('Failed to parse WebSocket message:', error); } };
this.ws.onerror = (error) => { for (const subscription of this.subscriptions.values()) { subscription.onError?.(new Error('WebSocket error')); } };
this.ws.onclose = () => { console.log('WebSocket disconnected'); for (const subscription of this.subscriptions.values()) { subscription.onClose?.(); }
// Attempt to reconnect after 5 seconds setTimeout(() => this.connect(), 5000); }; }
disconnect() { this.subscriptions.clear(); this.ws?.close(); this.ws = null; }}
// Usageconst httpClient = new ApiClient('https://api.example.com');const realTimeClient = new RealTimeClient(httpClient, 'wss://api.example.com/ws');
// Use HTTP methodsconst user = await realTimeClient.get('/users/123', { schema: UserSchema });
// Subscribe to real-time updatesrealTimeClient.subscribe('user:123', { onMessage: (updatedUser) => { console.log('User updated:', updatedUser); }, onError: (error) => { console.error('Subscription error:', error); }});Multi-Tenant API Client
Handle multi-tenant applications:
interface TenantConfig { id: string; apiUrl: string; apiKey: string; rateLimit?: { requests: number; windowMs: number; };}
class MultiTenantClient { private clients = new Map<string, ApiClient>(); private rateLimiters = new Map<string, RateLimitedClient>();
constructor(private tenants: TenantConfig[]) { this.initializeClients(); }
private initializeClients() { for (const tenant of this.tenants) { const client = new ApiClient(tenant.apiUrl, { apiKey: tenant.apiKey }); this.clients.set(tenant.id, client);
if (tenant.rateLimit) { const rateLimiter = new RateLimitedClient( client, tenant.rateLimit.requests, tenant.rateLimit.windowMs ); this.rateLimiters.set(tenant.id, rateLimiter); } } }
getClient(tenantId: string): ApiClient { const client = this.clients.get(tenantId); if (!client) { throw new Error(`No client configured for tenant: ${tenantId}`); } return client; }
getRateLimitedClient(tenantId: string): RateLimitedClient { const client = this.rateLimiters.get(tenantId); if (!client) { throw new Error(`No rate-limited client configured for tenant: ${tenantId}`); } return client; }
async request<T>( tenantId: string, endpoint: string, init?: FetcherRequestInit<T> ): Promise<T> { const rateLimiter = this.rateLimiters.get(tenantId);
if (rateLimiter) { return rateLimiter.request(endpoint, init); } else { const client = this.getClient(tenantId); return client.get(endpoint, init); } }
// Batch requests across multiple tenants async batchRequest<T>( requests: Array<{ tenantId: string; endpoint: string; init?: FetcherRequestInit<T>; }> ): Promise<Array<{ tenantId: string; result: T | Error }>> { const promises = requests.map(async ({ tenantId, endpoint, init }) => { try { const result = await this.request(tenantId, endpoint, init); return { tenantId, result }; } catch (error) { return { tenantId, result: error as Error }; } });
return Promise.all(promises); }}
// Configurationconst tenants: TenantConfig[] = [ { id: 'tenant-1', apiUrl: 'https://tenant1.api.example.com', apiKey: 'key1', rateLimit: { requests: 100, windowMs: 60000 } }, { id: 'tenant-2', apiUrl: 'https://tenant2.api.example.com', apiKey: 'key2', rateLimit: { requests: 50, windowMs: 60000 } }];
const multiClient = new MultiTenantClient(tenants);
// Single tenant requestconst user = await multiClient.request('tenant-1', '/users/123', { schema: UserSchema});
// Batch requests across tenantsconst results = await multiClient.batchRequest([ { tenantId: 'tenant-1', endpoint: '/stats', init: { schema: StatsSchema } }, { tenantId: 'tenant-2', endpoint: '/stats', init: { schema: StatsSchema } }]);Request Mocking and Testing
Advanced mocking for testing:
interface MockResponse<T = any> { status: number; data?: T; headers?: Record<string, string>; delay?: number;}
class MockClient { private mocks = new Map<string, MockResponse>(); private callLog: Array<{ url: string; init?: FetcherRequestInit<any>; timestamp: number }> = [];
constructor(private realClient?: ApiClient) {}
mock(pattern: string | RegExp, response: MockResponse) { const key = pattern instanceof RegExp ? pattern.source : pattern; this.mocks.set(key, response); }
clearMock(pattern: string | RegExp) { const key = pattern instanceof RegExp ? pattern.source : pattern; this.mocks.delete(key); }
clearAllMocks() { this.mocks.clear(); this.callLog = []; }
getCallLog() { return [...this.callLog]; }
private findMock(url: string): MockResponse | null { for (const [pattern, response] of this.mocks) { if (pattern.startsWith('/') && pattern.endsWith('/')) { // Regex pattern const regex = new RegExp(pattern.slice(1, -1)); if (regex.test(url)) return response; } else { // String pattern if (url.includes(pattern)) return response; } } return null; }
async request<T>(url: string, init?: FetcherRequestInit<T>): Promise<T> { // Log the call this.callLog.push({ url, init, timestamp: Date.now() });
const mock = this.findMock(url);
if (mock) { // Simulate network delay if (mock.delay) { await new Promise(resolve => setTimeout(resolve, mock.delay)); }
if (mock.status >= 400) { throw new Error(`Mock error: ${mock.status}`); }
return mock.data as T; }
if (this.realClient) { return this.realClient.get(url, init); }
throw new Error(`No mock found for ${url} and no real client provided`); }}
// Test utilitiesclass TestScenario { constructor(private mockClient: MockClient) {}
// Simulate network errors simulateNetworkError(pattern: string | RegExp) { this.mockClient.mock(pattern, { status: 500 }); }
// Simulate slow responses simulateSlowResponse(pattern: string | RegExp, delay: number) { this.mockClient.mock(pattern, { status: 200, data: { message: 'slow response' }, delay }); }
// Simulate rate limiting simulateRateLimit(pattern: string | RegExp) { this.mockClient.mock(pattern, { status: 429 }); }
// Create realistic user data createMockUser(overrides: Partial<z.infer<typeof UserSchema>> = {}) { return { id: '123', name: 'John Doe', email: 'john@example.com', ...overrides }; }}
// Usage in testsdescribe('API Client', () => { let mockClient: MockClient; let scenario: TestScenario;
beforeEach(() => { mockClient = new MockClient(); scenario = new TestScenario(mockClient); });
it('should handle successful user fetch', async () => { const mockUser = scenario.createMockUser({ name: 'Test User' }); mockClient.mock('/users/123', { status: 200, data: mockUser });
const user = await mockClient.request('/users/123', { schema: UserSchema });
expect(user.name).toBe('Test User'); expect(mockClient.getCallLog()).toHaveLength(1); });
it('should handle network errors', async () => { scenario.simulateNetworkError('/users/123');
await expect( mockClient.request('/users/123', { schema: UserSchema }) ).rejects.toThrow('Mock error: 500'); });
it('should handle slow responses', async () => { scenario.simulateSlowResponse('/users', 1000);
const start = Date.now(); await mockClient.request('/users'); const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThan(900); });});Performance Monitoring
Add performance monitoring and metrics:
interface RequestMetrics { url: string; method: string; duration: number; status: number; size: number; timestamp: number; success: boolean;}
class PerformanceMonitor { private metrics: RequestMetrics[] = []; private maxMetrics = 1000;
recordRequest(metrics: RequestMetrics) { this.metrics.push(metrics);
// Keep only recent metrics if (this.metrics.length > this.maxMetrics) { this.metrics = this.metrics.slice(-this.maxMetrics); } }
getAverageResponseTime(pattern?: string): number { const filteredMetrics = pattern ? this.metrics.filter(m => m.url.includes(pattern)) : this.metrics;
if (filteredMetrics.length === 0) return 0;
const total = filteredMetrics.reduce((sum, m) => sum + m.duration, 0); return total / filteredMetrics.length; }
getSuccessRate(pattern?: string): number { const filteredMetrics = pattern ? this.metrics.filter(m => m.url.includes(pattern)) : this.metrics;
if (filteredMetrics.length === 0) return 0;
const successful = filteredMetrics.filter(m => m.success).length; return (successful / filteredMetrics.length) * 100; }
getMetricsSummary() { return { totalRequests: this.metrics.length, averageResponseTime: this.getAverageResponseTime(), successRate: this.getSuccessRate(), slowestRequest: Math.max(...this.metrics.map(m => m.duration)), fastestRequest: Math.min(...this.metrics.map(m => m.duration)) }; }
exportMetrics(): RequestMetrics[] { return [...this.metrics]; }}
class MonitoredClient { private monitor = new PerformanceMonitor();
constructor(private client: ApiClient) {}
async request<T>(url: string, init?: FetcherRequestInit<T>): Promise<T> { const start = performance.now(); const method = init?.method || 'GET'; let status = 0; let success = false;
try { const response = await this.client.get(url, init); status = 200; // Assuming success success = true;
// Record metrics const duration = performance.now() - start; const size = JSON.stringify(response).length;
this.monitor.recordRequest({ url, method, duration, status, size, timestamp: Date.now(), success });
return response; } catch (error) { if (error && typeof error === 'object' && 'statusCode' in error) { status = error.statusCode as number; } else { status = 0; // Network error }
const duration = performance.now() - start; this.monitor.recordRequest({ url, method, duration, status, size: 0, timestamp: Date.now(), success: false });
throw error; } }
getPerformanceMetrics() { return this.monitor.getMetricsSummary(); }
exportMetrics() { return this.monitor.exportMetrics(); }}
// Usageconst client = new ApiClient('https://api.example.com');const monitoredClient = new MonitoredClient(client);
// Make some requestsawait monitoredClient.request('/users', { schema: z.array(UserSchema) });await monitoredClient.request('/posts', { schema: z.array(PostSchema) });
// Get performance insightsconst metrics = monitoredClient.getPerformanceMetrics();console.log(`Average response time: ${metrics.averageResponseTime}ms`);console.log(`Success rate: ${metrics.successRate}%`);Circuit Breaker Pattern
Implement circuit breaker for fault tolerance:
enum CircuitState { CLOSED = 'CLOSED', OPEN = 'OPEN', HALF_OPEN = 'HALF_OPEN'}
interface CircuitBreakerOptions { failureThreshold: number; recoveryTimeout: number; monitoringWindow: number;}
class CircuitBreaker { private state = CircuitState.CLOSED; private failures = 0; private lastFailureTime = 0; private successCount = 0;
constructor(private options: CircuitBreakerOptions) {}
async execute<T>(operation: () => Promise<T>): Promise<T> { if (this.state === CircuitState.OPEN) { if (Date.now() - this.lastFailureTime > this.options.recoveryTimeout) { this.state = CircuitState.HALF_OPEN; this.successCount = 0; } else { throw new Error('Circuit breaker is OPEN'); } }
try { const result = await operation(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } }
private onSuccess() { this.failures = 0;
if (this.state === CircuitState.HALF_OPEN) { this.successCount++; if (this.successCount >= 3) { // Require 3 successes to close this.state = CircuitState.CLOSED; } } }
private onFailure() { this.failures++; this.lastFailureTime = Date.now();
if (this.failures >= this.options.failureThreshold) { this.state = CircuitState.OPEN; } }
getState() { return this.state; }}
class ResilientClient { private circuitBreakers = new Map<string, CircuitBreaker>();
constructor(private client: ApiClient) {}
private getCircuitBreaker(endpoint: string): CircuitBreaker { if (!this.circuitBreakers.has(endpoint)) { this.circuitBreakers.set(endpoint, new CircuitBreaker({ failureThreshold: 5, recoveryTimeout: 30000, // 30 seconds monitoringWindow: 60000 // 1 minute })); } return this.circuitBreakers.get(endpoint)!; }
async request<T>(url: string, init?: FetcherRequestInit<T>): Promise<T> { const circuitBreaker = this.getCircuitBreaker(url);
return circuitBreaker.execute(async () => { return this.client.get(url, init); }); }
getCircuitBreakerStatus(endpoint: string) { const cb = this.circuitBreakers.get(endpoint); return cb ? cb.getState() : null; }}
// Usageconst client = new ApiClient('https://api.example.com');const resilientClient = new ResilientClient(client);
try { const user = await resilientClient.request('/users/123', { schema: UserSchema });} catch (error) { if (error.message === 'Circuit breaker is OPEN') { console.log('Service is temporarily unavailable'); }}
console.log('Circuit breaker state:', resilientClient.getCircuitBreakerStatus('/users/123'));These advanced patterns demonstrate the flexibility and power of @shkumbinhsn/fetcher for building robust, production-ready applications.
Next Steps
- Common Patterns - Essential patterns for daily use
- API Reference - Complete API documentation
- TypeScript Integration - Advanced TypeScript usage