@rendomnet/apiservice 1.0.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,29 @@ A robust TypeScript API service framework for making authenticated API calls wit
8
8
  - ✅ Status code-specific hooks for handling errors
9
9
  - ✅ Account state tracking
10
10
  - ✅ File upload support
11
+ - ✅ Support for multiple accounts or a single default account
12
+ - ✅ Automatic token refresh for 401 errors
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @rendomnet/apiservice
18
+ ```
19
+
20
+ ## Testing
21
+
22
+ ApiService includes a comprehensive test suite using Jest. To run the tests:
23
+
24
+ ```bash
25
+ # Run tests
26
+ npm test
27
+
28
+ # Run tests with coverage report
29
+ npm run test:coverage
30
+
31
+ # Run tests in watch mode during development
32
+ npm run test:watch
33
+ ```
11
34
 
12
35
  ## Usage
13
36
 
@@ -17,31 +40,355 @@ import ApiService from 'apiservice';
17
40
  // Create and setup the API service
18
41
  const api = new ApiService();
19
42
  api.setup({
20
- provider: 'my-service',
43
+ provider: 'my-service', // 'google' | 'microsoft' and etc.
21
44
  tokenService: myTokenService,
22
45
  hooks: {
23
- 401: {
24
- retryCall: true,
25
- retryDelay: true,
26
- callback: async (accountId, response) => {
27
- // Handle token refresh logic
28
- return { /* updated parameters */ };
29
- }
30
- }
46
+ // You can define custom hooks here,
47
+ // or use the default token refresh handler for 401 errors
31
48
  },
32
- cacheTime: 30000 // 30 seconds
49
+ cacheTime: 30000, // 30 seconds
50
+ baseUrl: 'https://api.example.com' // Set default base URL
33
51
  });
34
52
 
35
- // Make API calls
36
- const result = await api.makeApiCall({
53
+ // Make API calls with specific account ID and use default baseUrl
54
+ const result = await api.call({
37
55
  accountId: 'user123',
38
56
  method: 'GET',
39
- base: 'https://api.example.com',
40
57
  route: '/users',
41
- requireAuth: true
58
+ useAuth: true
59
+ });
60
+
61
+ // Override default baseUrl for specific calls
62
+ const customResult = await api.call({
63
+ method: 'GET',
64
+ base: 'https://api2.example.com', // Override default baseUrl
65
+ route: '/users',
66
+ useAuth: true
67
+ });
68
+
69
+ // Or omit accountId to use the default account ('default')
70
+ const defaultResult = await api.call({
71
+ method: 'GET',
72
+ route: '/users',
73
+ useAuth: true
74
+ });
75
+ ```
76
+
77
+ ## Automatic Token Refresh
78
+
79
+ ApiService includes a built-in handler for 401 (Unauthorized) errors that automatically refreshes OAuth tokens. This feature:
80
+
81
+ 1. Detects 401 errors from the API
82
+ 2. Retrieves the current token for the account
83
+ 3. Uses the `refresh` method from your tokenService to obtain a new token
84
+ 4. Updates the stored token with the new one
85
+ 5. Retries the original API request with the new token
86
+
87
+ To use this feature:
88
+
89
+ 1. Ensure your tokenService implements the `refresh` method
90
+ 2. Don't specify a custom 401 hook (the default will be used automatically)
91
+
92
+ ```typescript
93
+ // Example token service with refresh capability
94
+ const tokenService = {
95
+ async get(accountId = 'default') {
96
+ // Get token from storage
97
+ return storedToken;
98
+ },
99
+
100
+ async set(token, accountId = 'default') {
101
+ // Save token to storage
102
+ },
103
+
104
+ async refresh(refreshToken, accountId = 'default') {
105
+ // Refresh the token with your OAuth provider
106
+ const response = await fetch('https://api.example.com/oauth/token', {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({
110
+ grant_type: 'refresh_token',
111
+ refresh_token: refreshToken,
112
+ client_id: 'your-client-id'
113
+ })
114
+ });
115
+
116
+ if (!response.ok) {
117
+ throw new Error('Failed to refresh token');
118
+ }
119
+
120
+ return await response.json();
121
+ }
122
+ };
123
+ ```
124
+
125
+ If you prefer to handle token refresh yourself, you can either:
126
+
127
+ 1. Provide your own handler for 401 errors which will override the default
128
+ 2. Disable the default handler by setting `hooks: { 401: null }`
129
+
130
+ ```typescript
131
+ // Disable default 401 handler without providing a custom one
132
+ api.setup({
133
+ provider: 'my-service',
134
+ tokenService,
135
+ hooks: {
136
+ 401: null // Explicitly disable the default handler
137
+ },
138
+ cacheTime: 30000
42
139
  });
43
140
  ```
44
141
 
142
+ ## Account Management
143
+
144
+ ApiService supports multiple accounts through the `accountId` parameter. This allows you to:
145
+
146
+ 1. **Manage multiple tokens** - Maintain separate authentication tokens for different users or services
147
+ 2. **Track state by account** - Each account has its own state tracking (request times, failures)
148
+ 3. **Apply account-specific retry logic** - Hooks can behave differently based on the account
149
+
150
+ For simple applications that only need a single account, you can omit the accountId parameter:
151
+
152
+ ```typescript
153
+ // Make calls without specifying accountId - uses 'default' automatically
154
+ const result = await api.call({
155
+ method: 'GET',
156
+ route: '/users'
157
+ });
158
+ ```
159
+
160
+ If no accountId is provided, ApiService automatically uses 'default' as the account ID.
161
+
162
+ ## Token Service
163
+
164
+ ApiService requires a `tokenService` for authentication. This service manages tokens for different accounts and handles token retrieval, storage, and refresh operations.
165
+
166
+ ### TokenService Interface
167
+
168
+ ```typescript
169
+ interface TokenService {
170
+ // Get a token for an account (accountId is optional, defaults to 'default')
171
+ get: (accountId?: string) => Promise<Token>;
172
+
173
+ // Save a token for an account
174
+ set: (token: Partial<Token>, accountId?: string) => Promise<void>;
175
+
176
+ // Optional: Refresh an expired token
177
+ refresh?: (refreshToken: string, accountId?: string) => Promise<OAuthToken>;
178
+ }
179
+
180
+ // The Token interface
181
+ interface Token {
182
+ accountId: string;
183
+ access_token: string;
184
+ refresh_token: string;
185
+ provider: string;
186
+ enabled?: boolean;
187
+ updatedAt?: string;
188
+ primary?: boolean;
189
+ }
190
+
191
+ // OAuth token response
192
+ interface OAuthToken {
193
+ access_token: string;
194
+ expires_in: number;
195
+ id_token: string;
196
+ refresh_token: string;
197
+ scope: string;
198
+ token_type: string;
199
+ }
200
+ ```
201
+
202
+ ### Example Implementation
203
+
204
+ Here's a simple `tokenService` implementation using localStorage:
205
+
206
+ ```typescript
207
+ // Simple token service implementation
208
+ const tokenService = {
209
+ // Get token for an account
210
+ async get(accountId = 'default'): Promise<Token> {
211
+ const storedToken = localStorage.getItem(`token-${accountId}`);
212
+ if (!storedToken) {
213
+ throw new Error(`No token found for account ${accountId}`);
214
+ }
215
+ return JSON.parse(storedToken);
216
+ },
217
+
218
+ // Save token for an account
219
+ async set(token: Partial<Token>, accountId = 'default'): Promise<void> {
220
+ const existingToken = localStorage.getItem(`token-${accountId}`);
221
+ const currentToken = existingToken ? JSON.parse(existingToken) : { accountId };
222
+ const updatedToken = { ...currentToken, ...token, updatedAt: new Date().toISOString() };
223
+ localStorage.setItem(`token-${accountId}`, JSON.stringify(updatedToken));
224
+ },
225
+
226
+ // Refresh token implementation
227
+ async refresh(refreshToken: string, accountId = 'default'): Promise<OAuthToken> {
228
+ // Make a request to your OAuth token endpoint
229
+ const response = await fetch('https://api.example.com/oauth/token', {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({
233
+ grant_type: 'refresh_token',
234
+ refresh_token: refreshToken,
235
+ client_id: 'your-client-id'
236
+ })
237
+ });
238
+
239
+ if (!response.ok) {
240
+ throw new Error('Failed to refresh token');
241
+ }
242
+
243
+ const newToken = await response.json();
244
+
245
+ // Update the stored token
246
+ await this.set({
247
+ access_token: newToken.access_token,
248
+ refresh_token: newToken.refresh_token
249
+ }, accountId);
250
+
251
+ return newToken;
252
+ }
253
+ };
254
+ ```
255
+
256
+ ### Complete Authorization Flow Example
257
+
258
+ Here's a complete example showing how to use ApiService with automatic token refresh:
259
+
260
+ ```typescript
261
+ import ApiService from 'apiservice';
262
+
263
+ // Create token service with refresh capability
264
+ const tokenService = {
265
+ // Get token from storage
266
+ async get(accountId = 'default') {
267
+ const storedToken = localStorage.getItem(`token-${accountId}`);
268
+ if (!storedToken) {
269
+ throw new Error(`No token found for account ${accountId}`);
270
+ }
271
+ return JSON.parse(storedToken);
272
+ },
273
+
274
+ // Save token to storage
275
+ async set(token, accountId = 'default') {
276
+ const existingToken = localStorage.getItem(`token-${accountId}`);
277
+ const currentToken = existingToken ? JSON.parse(existingToken) : { accountId };
278
+ const updatedToken = { ...currentToken, ...token, updatedAt: new Date().toISOString() };
279
+ localStorage.setItem(`token-${accountId}`, JSON.stringify(updatedToken));
280
+ },
281
+
282
+ // Refresh token with OAuth provider
283
+ async refresh(refreshToken, accountId = 'default') {
284
+ // Real implementation would call your OAuth endpoint
285
+ const response = await fetch('https://api.example.com/oauth/token', {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ body: JSON.stringify({
289
+ grant_type: 'refresh_token',
290
+ refresh_token: refreshToken,
291
+ client_id: 'your-client-id'
292
+ })
293
+ });
294
+
295
+ if (!response.ok) {
296
+ throw new Error('Failed to refresh token');
297
+ }
298
+
299
+ return await response.json();
300
+ }
301
+ };
302
+
303
+ // Create API service instance
304
+ const api = new ApiService();
305
+
306
+ // Configure API service with automatic token refresh
307
+ api.setup({
308
+ provider: 'example-api',
309
+ tokenService,
310
+ cacheTime: 30000,
311
+ baseUrl: 'https://api.example.com',
312
+
313
+ // You can still add custom hooks for other status codes
314
+ // The default 401 handler will be used automatically
315
+ hooks: {
316
+ // Handle 403 Forbidden errors - typically for insufficient permissions
317
+ 403: {
318
+ shouldRetry: false,
319
+ handler: async (accountId, response) => {
320
+ console.warn('Permission denied:', response);
321
+ // You could trigger a permissions UI here
322
+ window.dispatchEvent(new CustomEvent('permission:required', {
323
+ detail: { accountId, resource: response.resource }
324
+ }));
325
+ return null;
326
+ }
327
+ }
328
+ }
329
+ });
330
+
331
+ // Use the API service
332
+ async function fetchUserData(userId) {
333
+ try {
334
+ return await api.call({
335
+ // No accountId needed - will use 'default' automatically
336
+ method: 'GET',
337
+ route: `/users/${userId}`,
338
+ useAuth: true
339
+ });
340
+ } catch (error) {
341
+ // 401 errors with valid refresh tokens will be automatically handled
342
+ // This catch will only trigger for other errors or if refresh fails
343
+ console.error('Failed to fetch user data:', error);
344
+ throw error;
345
+ }
346
+ }
347
+ ```
348
+
349
+ ## Hook Options
350
+
351
+ Hooks can be configured to handle specific HTTP status codes:
352
+
353
+ ```typescript
354
+ const hooks = {
355
+ 401: {
356
+ // Core settings
357
+ shouldRetry: true, // Whether to retry the API call when this hook is triggered
358
+ useRetryDelay: true, // Whether to apply delay between retries
359
+ maxRetries: 3, // Maximum number of retry attempts for this status code (default: 4)
360
+
361
+ // Advanced options
362
+ preventConcurrentCalls: true, // Wait for an existing hook to complete before starting a new one
363
+ // Useful for avoiding duplicate refresh token calls
364
+
365
+ // Handler functions
366
+ handler: async (accountId, response) => {
367
+ // Main handler function called when this status code is encountered
368
+ // Return an object to update the API call parameters for the retry
369
+ return { /* updated parameters */ };
370
+ },
371
+
372
+ onMaxRetriesExceeded: async (accountId, error) => {
373
+ // Called when all retry attempts for this status code have failed
374
+ },
375
+
376
+ onHandlerError: async (accountId, error) => {
377
+ // Called when the handler function throws an error
378
+ },
379
+
380
+ // Delay strategy settings
381
+ delayStrategy: {
382
+ calculate: (attempt, response) => {
383
+ // Custom strategy for calculating delay between retries
384
+ return 1000 * Math.pow(2, attempt - 1); // Exponential backoff
385
+ }
386
+ },
387
+ maxDelay: 30000 // Maximum delay in milliseconds between retries (default: 60000)
388
+ }
389
+ }
390
+ ```
391
+
45
392
  ## Architecture
46
393
 
47
394
  The codebase is built around a main `ApiService` class that coordinates several component managers:
@@ -50,4 +397,55 @@ The codebase is built around a main `ApiService` class that coordinates several
50
397
  - `CacheManager`: Implements data caching with customizable expiration times
51
398
  - `RetryManager`: Manages retry logic with exponential backoff and other delay strategies
52
399
  - `HookManager`: Provides a way to hook into specific status codes and handle them
53
- - `AccountManager`: Tracks account state and handles account-specific data
400
+ - `AccountManager`: Tracks account state and handles account-specific data
401
+
402
+ ## Advanced Usage
403
+
404
+ ### Multiple API Providers
405
+
406
+ ```javascript
407
+ // Configure multiple API providers
408
+ api.setup({
409
+ provider: 'primary-api',
410
+ tokenService: primaryTokenService,
411
+ cacheTime: 30000,
412
+ baseUrl: 'https://api.primary.com'
413
+ });
414
+
415
+ api.setup({
416
+ provider: 'secondary-api',
417
+ tokenService: secondaryTokenService,
418
+ cacheTime: 60000,
419
+ baseUrl: 'https://api.secondary.com'
420
+ });
421
+
422
+ // Use different providers in API calls
423
+ async function fetchCombinedData() {
424
+ const [primaryData, secondaryData] = await Promise.all([
425
+ api.call({
426
+ provider: 'primary-api',
427
+ method: 'GET',
428
+ route: '/data',
429
+ useAuth: true
430
+ }),
431
+
432
+ api.call({
433
+ provider: 'secondary-api',
434
+ method: 'GET',
435
+ route: '/data',
436
+ useAuth: true
437
+ }),
438
+
439
+ // Override baseUrl for a specific API call
440
+ api.call({
441
+ provider: 'primary-api',
442
+ method: 'GET',
443
+ route: '/special-data',
444
+ useAuth: true,
445
+ base: 'https://special-api.primary.com'
446
+ })
447
+ ]);
448
+
449
+ return { primaryData, secondaryData };
450
+ }
451
+ ```
@@ -4,24 +4,25 @@ import { AccountData } from './types';
4
4
  */
5
5
  export declare class AccountManager {
6
6
  private accounts;
7
+ private readonly DEFAULT_ACCOUNT;
7
8
  /**
8
9
  * Update account data for a specific account
9
10
  */
10
- updateAccountData(accountId: string, data: Partial<AccountData>): void;
11
+ updateAccountData(accountId: string | undefined, data: Partial<AccountData>): void;
11
12
  /**
12
13
  * Get account data for a specific account
13
14
  */
14
- getAccountData(accountId: string): AccountData;
15
+ getAccountData(accountId?: string): AccountData;
15
16
  /**
16
17
  * Check if an account's last request failed
17
18
  */
18
- didLastRequestFail(accountId: string): boolean;
19
+ didLastRequestFail(accountId?: string): boolean;
19
20
  /**
20
21
  * Set account's last request as failed
21
22
  */
22
- setLastRequestFailed(accountId: string, failed?: boolean): void;
23
+ setLastRequestFailed(accountId?: string, failed?: boolean): void;
23
24
  /**
24
25
  * Update the last request time for an account
25
26
  */
26
- updateLastRequestTime(accountId: string): void;
27
+ updateLastRequestTime(accountId?: string): void;
27
28
  }
@@ -7,11 +7,12 @@ exports.AccountManager = void 0;
7
7
  class AccountManager {
8
8
  constructor() {
9
9
  this.accounts = {};
10
+ this.DEFAULT_ACCOUNT = 'default';
10
11
  }
11
12
  /**
12
13
  * Update account data for a specific account
13
14
  */
14
- updateAccountData(accountId, data) {
15
+ updateAccountData(accountId = this.DEFAULT_ACCOUNT, data) {
15
16
  this.accounts[accountId] = {
16
17
  ...this.accounts[accountId],
17
18
  ...data
@@ -20,26 +21,26 @@ class AccountManager {
20
21
  /**
21
22
  * Get account data for a specific account
22
23
  */
23
- getAccountData(accountId) {
24
+ getAccountData(accountId = this.DEFAULT_ACCOUNT) {
24
25
  return this.accounts[accountId] || {};
25
26
  }
26
27
  /**
27
28
  * Check if an account's last request failed
28
29
  */
29
- didLastRequestFail(accountId) {
30
+ didLastRequestFail(accountId = this.DEFAULT_ACCOUNT) {
30
31
  var _a;
31
32
  return !!((_a = this.accounts[accountId]) === null || _a === void 0 ? void 0 : _a.lastFailed);
32
33
  }
33
34
  /**
34
35
  * Set account's last request as failed
35
36
  */
36
- setLastRequestFailed(accountId, failed = true) {
37
+ setLastRequestFailed(accountId = this.DEFAULT_ACCOUNT, failed = true) {
37
38
  this.updateAccountData(accountId, { lastFailed: failed });
38
39
  }
39
40
  /**
40
41
  * Update the last request time for an account
41
42
  */
42
- updateLastRequestTime(accountId) {
43
+ updateLastRequestTime(accountId = this.DEFAULT_ACCOUNT) {
43
44
  this.updateAccountData(accountId, { lastRequestTime: Date.now() });
44
45
  }
45
46
  }
@@ -26,26 +26,26 @@ class HookManager {
26
26
  */
27
27
  async processHook(accountId, status, error) {
28
28
  const hook = this.hooks[status];
29
- if (!hook || !hook.callback)
29
+ if (!hook || !hook.handler)
30
30
  return null;
31
- const hookKey = `${accountId}-${status}`;
31
+ const hookKey = `${accountId || 'default'}-${status}`;
32
32
  try {
33
33
  // Handle waiting for existing hook call if needed
34
- if (hook.waitUntilFinished) {
34
+ if (hook.preventConcurrentCalls) {
35
35
  if (!this.hookPromises[hookKey]) {
36
- this.hookPromises[hookKey] = Promise.resolve(hook.callback(accountId, error.response) || {});
36
+ this.hookPromises[hookKey] = Promise.resolve(hook.handler(accountId, error.response) || {});
37
37
  }
38
38
  const result = await this.hookPromises[hookKey];
39
39
  delete this.hookPromises[hookKey];
40
40
  return result;
41
41
  }
42
42
  // Otherwise just call the hook directly
43
- return await hook.callback(accountId, error.response) || {};
43
+ return await hook.handler(accountId, error.response) || {};
44
44
  }
45
45
  catch (hookError) {
46
- console.error(`Hook callback failed for status ${status}:`, hookError);
47
- if (hook.errorCallback) {
48
- await hook.errorCallback(accountId, hookError);
46
+ console.error(`Hook handler failed for status ${status}:`, hookError);
47
+ if (hook.onHandlerError) {
48
+ await hook.onHandlerError(accountId, hookError);
49
49
  }
50
50
  throw hookError;
51
51
  }
@@ -55,8 +55,8 @@ class HookManager {
55
55
  */
56
56
  async handleRetryFailure(accountId, status, error) {
57
57
  const hook = this.hooks[status];
58
- if (hook === null || hook === void 0 ? void 0 : hook.onRetryFail) {
59
- await hook.onRetryFail(accountId, error);
58
+ if (hook === null || hook === void 0 ? void 0 : hook.onMaxRetriesExceeded) {
59
+ await hook.onMaxRetriesExceeded(accountId, error);
60
60
  }
61
61
  }
62
62
  /**
@@ -64,7 +64,7 @@ class HookManager {
64
64
  */
65
65
  shouldRetry(status) {
66
66
  const hook = this.hooks[status];
67
- return !!hook && !!hook.retryCall;
67
+ return !!hook && !!hook.shouldRetry;
68
68
  }
69
69
  }
70
70
  exports.HookManager = HookManager;
@@ -15,7 +15,7 @@ class HttpClient {
15
15
  * Make an HTTP request
16
16
  */
17
17
  async makeRequest(apiParams, authToken) {
18
- const { accountId, method, route, base, body, data, headers, queryParams, contentType = 'application/json', accessToken: forcedAccessToken, requireAuth = true, files, } = apiParams;
18
+ const { accountId, method, route, base, body, data, headers, queryParams, contentType = 'application/json', accessToken: forcedAccessToken, useAuth = true, files, } = apiParams;
19
19
  // Build URL and request body
20
20
  const url = this.buildUrl(base, route, queryParams);
21
21
  const requestBody = body || data;
@@ -30,7 +30,7 @@ class HttpClient {
30
30
  contentType,
31
31
  authToken,
32
32
  forcedAccessToken,
33
- requireAuth,
33
+ useAuth,
34
34
  headers,
35
35
  });
36
36
  // Make the request
@@ -48,7 +48,8 @@ class HttpClient {
48
48
  * Build URL with query parameters
49
49
  */
50
50
  buildUrl(base, route, queryParams) {
51
- let url = `${base}${route || ''}`;
51
+ const baseUrl = base || '';
52
+ let url = `${baseUrl}${route || ''}`;
52
53
  if (queryParams)
53
54
  url += `?${qs_1.default.stringify(queryParams)}`;
54
55
  return url;
@@ -70,12 +71,12 @@ class HttpClient {
70
71
  /**
71
72
  * Build fetch options for request
72
73
  */
73
- buildFetchOptions({ method, body, formData, contentType, authToken, forcedAccessToken, requireAuth, headers, }) {
74
+ buildFetchOptions({ method, body, formData, contentType, authToken, forcedAccessToken, useAuth, headers, }) {
74
75
  const allowedMethods = ['POST', 'PUT', 'PATCH'];
75
76
  return {
76
77
  method,
77
78
  headers: {
78
- ...(requireAuth && {
79
+ ...(useAuth && {
79
80
  Authorization: `Bearer ${forcedAccessToken || authToken.access_token}`
80
81
  }),
81
82
  ...(!formData && { 'content-type': contentType }),
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ import { TokenService, ApiCallParams, HookSettings, StatusCode } from './types';
6
6
  declare class ApiService {
7
7
  provider: string;
8
8
  private tokenService;
9
+ private baseUrl;
9
10
  private cacheManager;
10
11
  private retryManager;
11
12
  private hookManager;
@@ -16,12 +17,18 @@ declare class ApiService {
16
17
  /**
17
18
  * Setup the API service
18
19
  */
19
- setup({ provider, tokenService, hooks, cacheTime, }: {
20
+ setup({ provider, tokenService, hooks, cacheTime, baseUrl, }: {
20
21
  provider: string;
21
22
  tokenService: TokenService;
22
- hooks?: Record<StatusCode, HookSettings>;
23
+ hooks?: Record<StatusCode, HookSettings | null>;
23
24
  cacheTime: number;
25
+ baseUrl?: string;
24
26
  }): void;
27
+ /**
28
+ * Create a default handler for 401 (Unauthorized) errors
29
+ * that implements standard token refresh behavior
30
+ */
31
+ private createDefaultTokenRefreshHandler;
25
32
  /**
26
33
  * Set the maximum number of retry attempts
27
34
  */
@@ -31,9 +38,18 @@ declare class ApiService {
31
38
  */
32
39
  updateAccountData(accountId: string, data: Partial<Record<string, any>>): void;
33
40
  /**
34
- * Main API call method
41
+ * Make an API call with all features (caching, retry, hooks)
42
+ */
43
+ call(apiCallParams: Omit<ApiCallParams, 'accountId'> & {
44
+ accountId?: string;
45
+ }): Promise<any>;
46
+ /**
47
+ * Legacy method for backward compatibility
48
+ * @deprecated Use call() instead
35
49
  */
36
- makeApiCall(apiCallParams: ApiCallParams): Promise<any>;
50
+ makeApiCall(apiCallParams: Omit<ApiCallParams, 'accountId'> & {
51
+ accountId?: string;
52
+ }): Promise<any>;
37
53
  /**
38
54
  * Make a request with retry capability
39
55
  */
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ const components_1 = require("./components");
7
7
  */
8
8
  class ApiService {
9
9
  constructor() {
10
+ this.baseUrl = ''; // Default base URL
10
11
  // Default max attempts for API calls
11
12
  this.maxAttempts = 10;
12
13
  this.provider = '';
@@ -21,16 +22,73 @@ class ApiService {
21
22
  /**
22
23
  * Setup the API service
23
24
  */
24
- setup({ provider, tokenService, hooks, cacheTime, }) {
25
+ setup({ provider, tokenService, hooks = {}, cacheTime, baseUrl = '', }) {
25
26
  this.provider = provider;
26
27
  this.tokenService = tokenService;
27
- if (hooks) {
28
- this.hookManager.setHooks(hooks);
28
+ this.baseUrl = baseUrl;
29
+ // Create a copy of hooks to avoid modifying the input
30
+ const finalHooks = {};
31
+ // Apply default 401 handler if:
32
+ // 1. No 401 hook is explicitly defined (or is explicitly null)
33
+ // 2. TokenService has a refresh method
34
+ if (hooks[401] === undefined && typeof this.tokenService.refresh === 'function') {
35
+ finalHooks[401] = this.createDefaultTokenRefreshHandler();
36
+ }
37
+ // Add user-defined hooks (skipping null/undefined values)
38
+ for (const [statusCode, hook] of Object.entries(hooks)) {
39
+ if (hook) {
40
+ finalHooks[statusCode] = hook;
41
+ }
42
+ }
43
+ // Set the hooks if we have any
44
+ if (Object.keys(finalHooks).length > 0) {
45
+ this.hookManager.setHooks(finalHooks);
29
46
  }
30
47
  if (typeof cacheTime !== 'undefined') {
31
48
  this.cacheManager.setCacheTime(cacheTime);
32
49
  }
33
50
  }
51
+ /**
52
+ * Create a default handler for 401 (Unauthorized) errors
53
+ * that implements standard token refresh behavior
54
+ */
55
+ createDefaultTokenRefreshHandler() {
56
+ return {
57
+ shouldRetry: true,
58
+ useRetryDelay: true,
59
+ preventConcurrentCalls: true,
60
+ maxRetries: 1,
61
+ handler: async (accountId) => {
62
+ try {
63
+ console.log(`🔄 Using default token refresh handler for ${accountId}`);
64
+ // Get current token to extract refresh token
65
+ const currentToken = await this.tokenService.get(accountId);
66
+ if (!currentToken.refresh_token) {
67
+ throw new Error(`No refresh token available for account ${accountId}`);
68
+ }
69
+ // Refresh the token
70
+ const newToken = await this.tokenService.refresh(currentToken.refresh_token, accountId);
71
+ if (!newToken || !newToken.access_token) {
72
+ throw new Error('Token refresh returned invalid data');
73
+ }
74
+ // Update the token in storage
75
+ await this.tokenService.set({
76
+ access_token: newToken.access_token,
77
+ refresh_token: newToken.refresh_token || currentToken.refresh_token
78
+ }, accountId);
79
+ // Return empty object to retry with same parameters
80
+ return {};
81
+ }
82
+ catch (error) {
83
+ console.error(`Token refresh failed for ${accountId}:`, error);
84
+ throw error;
85
+ }
86
+ },
87
+ onMaxRetriesExceeded: async (accountId, error) => {
88
+ console.error(`Authentication failed after refresh attempt for ${accountId}:`, error);
89
+ }
90
+ };
91
+ }
34
92
  /**
35
93
  * Set the maximum number of retry attempts
36
94
  */
@@ -44,20 +102,34 @@ class ApiService {
44
102
  this.accountManager.updateAccountData(accountId, data);
45
103
  }
46
104
  /**
47
- * Main API call method
105
+ * Make an API call with all features (caching, retry, hooks)
48
106
  */
49
- async makeApiCall(apiCallParams) {
50
- console.log('🔄 makeApiCall', this.provider, apiCallParams.accountId);
107
+ async call(apiCallParams) {
108
+ // Use 'default' as fallback if accountId is not provided
109
+ // and use default baseUrl if not provided
110
+ const params = {
111
+ ...apiCallParams,
112
+ accountId: apiCallParams.accountId || 'default',
113
+ base: apiCallParams.base || this.baseUrl,
114
+ };
115
+ console.log('🔄 API call', this.provider, params.accountId, params.method, params.route);
51
116
  // Check cache first
52
- const cachedData = this.cacheManager.getFromCache(apiCallParams);
117
+ const cachedData = this.cacheManager.getFromCache(params);
53
118
  if (cachedData)
54
119
  return cachedData;
55
120
  // Make the API call with retry capability
56
- const result = await this.makeRequestWithRetry(apiCallParams);
121
+ const result = await this.makeRequestWithRetry(params);
57
122
  // Cache the result
58
- this.cacheManager.saveToCache(apiCallParams, result);
123
+ this.cacheManager.saveToCache(params, result);
59
124
  return result;
60
125
  }
126
+ /**
127
+ * Legacy method for backward compatibility
128
+ * @deprecated Use call() instead
129
+ */
130
+ async makeApiCall(apiCallParams) {
131
+ return this.call(apiCallParams);
132
+ }
61
133
  /**
62
134
  * Make a request with retry capability
63
135
  */
@@ -73,11 +145,11 @@ class ApiService {
73
145
  attempts++;
74
146
  try {
75
147
  // Get authentication token if needed
76
- const authToken = apiCallParams.requireAuth !== false
148
+ const authToken = apiCallParams.useAuth !== false
77
149
  ? await this.tokenService.get(accountId)
78
150
  : {};
79
151
  // Verify we have authentication if required
80
- if (apiCallParams.requireAuth !== false && !apiCallParams.accessToken && !authToken.access_token) {
152
+ if (apiCallParams.useAuth !== false && !apiCallParams.accessToken && !authToken.access_token) {
81
153
  throw new Error(`${this.provider} credentials not found for account ID ${accountId}`);
82
154
  }
83
155
  // Make the actual API call
@@ -114,7 +186,7 @@ class ApiService {
114
186
  throw hookError;
115
187
  }
116
188
  // Wait before retrying if needed
117
- if (activeHook === null || activeHook === void 0 ? void 0 : activeHook.retryDelay) {
189
+ if (activeHook === null || activeHook === void 0 ? void 0 : activeHook.useRetryDelay) {
118
190
  await this.retryManager.calculateAndDelay({
119
191
  attempt: statusRetries[status],
120
192
  response: error.response,
package/dist/types.d.ts CHANGED
@@ -27,33 +27,62 @@ interface ApiCallParams {
27
27
  accountId: string;
28
28
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
29
29
  route: string;
30
- base: string;
30
+ base?: string;
31
31
  body?: object;
32
32
  data?: object;
33
33
  headers?: Record<string, string>;
34
34
  queryParams?: URLSearchParams;
35
35
  accessToken?: string;
36
- requireAuth?: boolean;
36
+ useAuth?: boolean;
37
37
  noContentType?: boolean;
38
38
  contentType?: string;
39
39
  cacheTime?: number;
40
40
  files?: File[];
41
41
  }
42
42
  interface HookSettings {
43
- retryCall: boolean;
44
- retryDelay: boolean;
45
- waitUntilFinished?: boolean;
43
+ /**
44
+ * Whether to retry the API call when this hook is triggered
45
+ */
46
+ shouldRetry: boolean;
47
+ /**
48
+ * Whether to apply delay between retries
49
+ */
50
+ useRetryDelay: boolean;
51
+ /**
52
+ * The maximum number of retry attempts for this status code
53
+ */
46
54
  maxRetries?: number;
47
- callback: (accountId: string, response: any) => Promise<any>;
48
- onRetryFail?: (accountId: string, error: any) => Promise<void>;
49
- errorCallback?: (accountId: string, error: any) => Promise<void>;
55
+ /**
56
+ * Wait for an existing hook to complete before starting a new one
57
+ * Useful for avoiding duplicate refresh token calls
58
+ */
59
+ preventConcurrentCalls?: boolean;
60
+ /**
61
+ * The main handler function called when this status code is encountered
62
+ * Return an object to update the API call parameters for the retry
63
+ */
64
+ handler: (accountId: string, response: any) => Promise<any>;
65
+ /**
66
+ * Called when all retry attempts for this status code have failed
67
+ */
68
+ onMaxRetriesExceeded?: (accountId: string, error: any) => Promise<void>;
69
+ /**
70
+ * Called when the handler function throws an error
71
+ */
72
+ onHandlerError?: (accountId: string, error: any) => Promise<void>;
73
+ /**
74
+ * Custom strategy for calculating delay between retries
75
+ */
50
76
  delayStrategy?: DelayStrategy;
77
+ /**
78
+ * Maximum delay in milliseconds between retries
79
+ */
51
80
  maxDelay?: number;
52
81
  }
53
82
  type StatusCode = string | number;
54
83
  type TokenService = {
55
- get: (accountId: string) => Promise<Token>;
56
- set: (accountId: string, token: Partial<Token>) => Promise<void>;
57
- refresh?: (accountId: string, refreshToken: string) => Promise<OAuthToken>;
84
+ get: (accountId?: string) => Promise<Token>;
85
+ set: (token: Partial<Token>, accountId?: string) => Promise<void>;
86
+ refresh?: (refreshToken: string, accountId?: string) => Promise<OAuthToken>;
58
87
  };
59
88
  export { OAuthToken, DelayStrategy, Token, AccountData, ApiCallParams, HookSettings, StatusCode, TokenService, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rendomnet/apiservice",
3
- "version": "1.0.0",
3
+ "version": "1.3.2",
4
4
  "description": "A robust TypeScript API service framework for making authenticated API calls",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,9 @@
12
12
  "clean": "rimraf dist",
13
13
  "prebuild": "npm run clean",
14
14
  "prepublishOnly": "npm run build",
15
- "test": "echo \"Error: no test specified\" && exit 1",
15
+ "test": "jest",
16
+ "test:watch": "jest --watch",
17
+ "test:coverage": "jest --coverage",
16
18
  "prepare": "npm run build"
17
19
  },
18
20
  "keywords": [
@@ -38,12 +40,15 @@
38
40
  "qs": "^6.11.0"
39
41
  },
40
42
  "devDependencies": {
43
+ "@types/jest": "^29.5.5",
41
44
  "@types/node": "^18.0.0",
42
45
  "@types/qs": "^6.9.7",
46
+ "jest": "^29.7.0",
43
47
  "rimraf": "^3.0.2",
48
+ "ts-jest": "^29.1.1",
44
49
  "typescript": "^4.7.4"
45
50
  },
46
51
  "publishConfig": {
47
52
  "access": "public"
48
53
  }
49
- }
54
+ }