Skip to content

Commit d97a7d9

Browse files
Merge pull request #147 from KAMALDEEN333/Api-Client
Api client
2 parents daaf92d + 88ae0e3 commit d97a7d9

3 files changed

Lines changed: 373 additions & 47 deletions

File tree

src/lib/ApiProvider.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client';
2+
3+
import React, { useEffect } from 'react';
4+
import { setupApiInterceptors } from '@/lib/apiInterceptors';
5+
6+
/**
7+
* ApiProvider - Sets up API interceptors on client-side initialization
8+
* This component should wrap your app to ensure interceptors are configured
9+
*/
10+
export const ApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
11+
useEffect(() => {
12+
// Setup interceptors once on client mount
13+
setupApiInterceptors();
14+
}, []);
15+
16+
return <>{children}</>;
17+
};

src/lib/api.ts

Lines changed: 248 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,42 @@ import { ErrorType, ErrorInfo } from '@/utils/errorUtils';
44
export type { ErrorInfo };
55

66
const DEFAULT_TIMEOUT_MS = 10_000;
7+
const MAX_RETRIES = 3;
8+
const RETRY_DELAY_MS = 1000;
9+
10+
/**
11+
* Request interceptor function type
12+
*/
13+
export type RequestInterceptor = (config: RequestConfig) => Promise<RequestConfig> | RequestConfig;
14+
15+
/**
16+
* Response interceptor function type
17+
*/
18+
export type ResponseInterceptor<T = unknown> = (response: T) => Promise<T> | T;
19+
20+
/**
21+
* Error interceptor function type
22+
*/
23+
export type ErrorInterceptor = (error: Error) => Promise<void> | void;
24+
25+
/**
26+
* Request configuration for the API client
27+
*/
28+
export interface RequestConfig extends RequestInit {
29+
url: string;
30+
retries?: number;
31+
timeout?: number;
32+
}
33+
34+
/**
35+
* API Client configuration options
36+
*/
37+
export interface ApiClientConfig {
38+
baseURL?: string;
39+
timeout?: number;
40+
maxRetries?: number;
41+
retryDelay?: number;
42+
}
743

844
function statusToErrorType(status: number): ErrorType {
945
if (status === 401) return ErrorType.AUTHENTICATION;
@@ -21,68 +57,233 @@ function statusToUserMessage(status: number): string {
2157
return 'There was a problem with your request.';
2258
}
2359

24-
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
25-
const controller = new AbortController();
26-
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
60+
/**
61+
* Should retry on specific status codes
62+
*/
63+
function shouldRetry(status: number, attempt: number, maxRetries: number): boolean {
64+
if (attempt >= maxRetries) return false;
65+
// Retry on 408, 429, 500, 502, 503, 504
66+
return [408, 429, 500, 502, 503, 504].includes(status);
67+
}
2768

28-
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
69+
/**
70+
* Calculate exponential backoff delay
71+
*/
72+
function getRetryDelay(attempt: number, baseDelay: number): number {
73+
return baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000;
74+
}
2975

30-
const headers: HeadersInit = {
31-
'Content-Type': 'application/json',
32-
...(token ? { Authorization: `Bearer ${token}` } : {}),
33-
...(options.headers as Record<string, string>),
34-
};
76+
/**
77+
* Unified API Client with interceptors and retry logic
78+
*/
79+
class ApiClientImpl {
80+
private config: Required<ApiClientConfig>;
81+
private requestInterceptors: RequestInterceptor[] = [];
82+
private responseInterceptors: ResponseInterceptor[] = [];
83+
private errorInterceptors: ErrorInterceptor[] = [];
3584

36-
try {
37-
const response = await fetch(url, {
38-
...options,
39-
headers,
40-
signal: controller.signal,
41-
});
85+
constructor(config: ApiClientConfig = {}) {
86+
this.config = {
87+
baseURL: config.baseURL || '',
88+
timeout: config.timeout || DEFAULT_TIMEOUT_MS,
89+
maxRetries: config.maxRetries || MAX_RETRIES,
90+
retryDelay: config.retryDelay || RETRY_DELAY_MS,
91+
};
92+
}
4293

43-
clearTimeout(timer);
94+
/**
95+
* Add a request interceptor
96+
*/
97+
addRequestInterceptor(interceptor: RequestInterceptor): void {
98+
this.requestInterceptors.push(interceptor);
99+
}
44100

45-
if (!response.ok) {
46-
let message = response.statusText;
47-
try {
48-
const body = await response.json();
49-
message = body?.message ?? message;
50-
} catch {
51-
// ignore parse errors
52-
}
53-
throw new ApiError(
54-
statusToErrorType(response.status),
55-
message,
56-
statusToUserMessage(response.status),
57-
response.status,
58-
);
101+
/**
102+
* Add a response interceptor
103+
*/
104+
addResponseInterceptor(interceptor: ResponseInterceptor): void {
105+
this.responseInterceptors.push(interceptor);
106+
}
107+
108+
/**
109+
* Add an error interceptor
110+
*/
111+
addErrorInterceptor(interceptor: ErrorInterceptor): void {
112+
this.errorInterceptors.push(interceptor);
113+
}
114+
115+
/**
116+
* Apply all request interceptors
117+
*/
118+
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
119+
let processedConfig = config;
120+
for (const interceptor of this.requestInterceptors) {
121+
processedConfig = await interceptor(processedConfig);
59122
}
123+
return processedConfig;
124+
}
60125

61-
return response.json() as Promise<T>;
62-
} catch (err) {
63-
clearTimeout(timer);
64-
if (err instanceof ApiError) throw err;
65-
throw parseApiError(err);
126+
/**
127+
* Apply all response interceptors
128+
*/
129+
private async applyResponseInterceptors<T>(response: T): Promise<T> {
130+
let processedResponse = response;
131+
for (const interceptor of this.responseInterceptors) {
132+
processedResponse = await interceptor(processedResponse);
133+
}
134+
return processedResponse;
66135
}
67-
}
68136

69-
export const apiClient = {
70-
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
137+
/**
138+
* Apply all error interceptors
139+
*/
140+
private async applyErrorInterceptors(error: Error): Promise<void> {
141+
for (const interceptor of this.errorInterceptors) {
142+
await interceptor(error);
143+
}
144+
}
145+
146+
/**
147+
* Get authentication token
148+
*/
149+
private getToken(): string | null {
150+
if (typeof window === 'undefined') return null;
151+
return localStorage.getItem('token');
152+
}
153+
154+
/**
155+
* Make HTTP request with retry logic
156+
*/
157+
private async requestWithRetry<T>(config: RequestConfig, attempt = 1): Promise<T> {
158+
const controller = new AbortController();
159+
const timeout = config.timeout || this.config.timeout;
160+
const timer = setTimeout(() => controller.abort(), timeout);
161+
const maxRetries = config.retries ?? this.config.maxRetries;
162+
163+
const token = this.getToken();
164+
const headers: HeadersInit = {
165+
'Content-Type': 'application/json',
166+
...(token ? { Authorization: `Bearer ${token}` } : {}),
167+
...(config.headers as Record<string, string>),
168+
};
169+
170+
try {
171+
// Apply request interceptors
172+
const processedConfig = await this.applyRequestInterceptors({
173+
...config,
174+
headers,
175+
signal: controller.signal,
176+
});
177+
178+
const url = this.config.baseURL
179+
? `${this.config.baseURL}${config.url}`
180+
: config.url;
71181

72-
post: <T>(url: string, body: unknown, options?: RequestInit) =>
73-
request<T>(url, {
182+
const response = await fetch(url, processedConfig);
183+
clearTimeout(timer);
184+
185+
if (!response.ok) {
186+
// Check if we should retry
187+
if (shouldRetry(response.status, attempt, maxRetries)) {
188+
const delay = getRetryDelay(attempt, this.config.retryDelay);
189+
await new Promise((resolve) => setTimeout(resolve, delay));
190+
return this.requestWithRetry<T>(config, attempt + 1);
191+
}
192+
193+
let message = response.statusText;
194+
try {
195+
const body = await response.json();
196+
message = body?.message ?? message;
197+
} catch {
198+
// ignore parse errors
199+
}
200+
throw new ApiError(
201+
statusToErrorType(response.status),
202+
message,
203+
statusToUserMessage(response.status),
204+
response.status,
205+
);
206+
}
207+
208+
const data = (await response.json()) as T;
209+
210+
// Apply response interceptors
211+
const processedResponse = await this.applyResponseInterceptors(data);
212+
return processedResponse;
213+
} catch (err) {
214+
clearTimeout(timer);
215+
216+
const error = err instanceof Error ? err : new Error('Unknown error occurred');
217+
218+
// Apply error interceptors
219+
await this.applyErrorInterceptors(error);
220+
221+
if (err instanceof ApiError) throw err;
222+
throw parseApiError(err);
223+
}
224+
}
225+
226+
/**
227+
* GET request
228+
*/
229+
async get<T>(url: string, options?: RequestInit): Promise<T> {
230+
return this.requestWithRetry<T>({
74231
...options,
232+
url,
233+
method: 'GET',
234+
});
235+
}
236+
237+
/**
238+
* POST request
239+
*/
240+
async post<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
241+
return this.requestWithRetry<T>({
242+
...options,
243+
url,
75244
method: 'POST',
76245
body: JSON.stringify(body),
77-
}),
246+
});
247+
}
78248

79-
patch: <T>(url: string, body: unknown, options?: RequestInit) =>
80-
request<T>(url, {
249+
/**
250+
* PATCH request
251+
*/
252+
async patch<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
253+
return this.requestWithRetry<T>({
81254
...options,
255+
url,
82256
method: 'PATCH',
83257
body: JSON.stringify(body),
84-
}),
258+
});
259+
}
260+
261+
/**
262+
* PUT request
263+
*/
264+
async put<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
265+
return this.requestWithRetry<T>({
266+
...options,
267+
url,
268+
method: 'PUT',
269+
body: JSON.stringify(body),
270+
});
271+
}
272+
273+
/**
274+
* DELETE request
275+
*/
276+
async delete<T>(url: string, options?: RequestInit): Promise<T> {
277+
return this.requestWithRetry<T>({
278+
...options,
279+
url,
280+
method: 'DELETE',
281+
});
282+
}
283+
}
284+
285+
// Create singleton instance
286+
export const apiClient = new ApiClientImpl();
85287

86-
delete: <T>(url: string, options?: RequestInit) =>
87-
request<T>(url, { ...options, method: 'DELETE' }),
88-
};
288+
// Export types for external use
289+
export type { ApiClientImpl };

0 commit comments

Comments
 (0)