@technomoron/apicore-client 1.0.0-beta.1
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/LICENSE +21 -0
- package/dist/cjs/apicore-client.d.ts +186 -0
- package/dist/cjs/apicore-client.js +636 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +11 -0
- package/dist/esm/apicore-client.d.ts +186 -0
- package/dist/esm/apicore-client.js +632 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +2 -0
- package/package.json +66 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Bjørn Erik Jacobsen / Technomoron
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function getErrorMessage(error) {
|
|
11
|
+
if (error instanceof Error) {
|
|
12
|
+
return error.message || undefined;
|
|
13
|
+
}
|
|
14
|
+
if (isPlainObject(error)) {
|
|
15
|
+
const potentialMessage = error['message'];
|
|
16
|
+
if (typeof potentialMessage === 'string') {
|
|
17
|
+
return potentialMessage;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function getErrorCode(error) {
|
|
23
|
+
if (isPlainObject(error)) {
|
|
24
|
+
const potentialCode = error['code'];
|
|
25
|
+
if (typeof potentialCode === 'string') {
|
|
26
|
+
return potentialCode;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function hasErrorName(error, expected) {
|
|
32
|
+
if (isPlainObject(error)) {
|
|
33
|
+
const potentialName = error['name'];
|
|
34
|
+
if (typeof potentialName === 'string') {
|
|
35
|
+
return potentialName === expected;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parses an error message from various error types
|
|
42
|
+
* @param {unknown} error - The error to parse
|
|
43
|
+
* @param {string} def - Default message if no message can be extracted
|
|
44
|
+
* @returns {string} The extracted error message or default message
|
|
45
|
+
*/
|
|
46
|
+
function parseMessage(error, def = '') {
|
|
47
|
+
if (typeof error === 'string') {
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
const message = getErrorMessage(error);
|
|
51
|
+
if (message) {
|
|
52
|
+
return message;
|
|
53
|
+
}
|
|
54
|
+
return def || '[Unknown error type - neither string, Error nor has .message]';
|
|
55
|
+
}
|
|
56
|
+
export class ApiResponse {
|
|
57
|
+
constructor(response = {}) {
|
|
58
|
+
this.success = false;
|
|
59
|
+
this.code = 500;
|
|
60
|
+
this.message = '[No Message]';
|
|
61
|
+
this.data = null;
|
|
62
|
+
this.errors = {};
|
|
63
|
+
this.success = response.success ?? false;
|
|
64
|
+
this.code = response.code ?? 500;
|
|
65
|
+
this.message = parseMessage(response.message, '[No Message]');
|
|
66
|
+
this.data = response.data ?? null;
|
|
67
|
+
this.errors = response.errors ?? {};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Creates a successful response with the provided data
|
|
71
|
+
* @param {T} data - The response data
|
|
72
|
+
* @param {Partial<ApiResponseData<T>>} overrides - Optional overrides for response properties
|
|
73
|
+
* @returns {ApiResponse<T>} A configured successful response
|
|
74
|
+
*/
|
|
75
|
+
static ok(data, overrides = {}) {
|
|
76
|
+
return new ApiResponse({
|
|
77
|
+
success: true,
|
|
78
|
+
code: 200,
|
|
79
|
+
message: 'OK',
|
|
80
|
+
data,
|
|
81
|
+
...overrides
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Creates an error response with the provided message
|
|
86
|
+
* @param {unknown} messageOrError - The error message, error object, or existing ApiResponse
|
|
87
|
+
* @param {Partial<ApiResponseData<T>>} overrides - Optional overrides for response properties
|
|
88
|
+
* @returns {ApiResponse<T>} A configured error response
|
|
89
|
+
*/
|
|
90
|
+
static error(messageOrError, overrides = {}) {
|
|
91
|
+
if (messageOrError instanceof ApiResponse) {
|
|
92
|
+
return messageOrError;
|
|
93
|
+
}
|
|
94
|
+
// Use parseMessage to handle any error type consistently
|
|
95
|
+
const message = parseMessage(messageOrError, 'Unknown error');
|
|
96
|
+
return new ApiResponse({
|
|
97
|
+
success: false,
|
|
98
|
+
code: overrides.code ?? 500,
|
|
99
|
+
message,
|
|
100
|
+
data: null,
|
|
101
|
+
...overrides
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Adds a named error to the errors collection
|
|
106
|
+
* @param {string} name - The name/key for the error
|
|
107
|
+
* @param {string} value - The error message
|
|
108
|
+
*/
|
|
109
|
+
addError(name, value) {
|
|
110
|
+
this.errors[name] = value;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Type guard to check if the response is successful and contains data
|
|
114
|
+
* When this returns true, TypeScript narrows the type to ensure data is not null
|
|
115
|
+
* @returns {boolean} True if the response is successful and contains data
|
|
116
|
+
*/
|
|
117
|
+
isSuccess() {
|
|
118
|
+
return this.success && this.data !== null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
class ApiClient {
|
|
122
|
+
constructor(apiUrl, config = {}) {
|
|
123
|
+
this.apiKey = null;
|
|
124
|
+
this.aToken = null;
|
|
125
|
+
this.rToken = null;
|
|
126
|
+
// Shared promise so that concurrent 401s all await the same refresh attempt.
|
|
127
|
+
this._refreshPromise = null;
|
|
128
|
+
this.timeoutMs = 15000;
|
|
129
|
+
this.maxRetries = 1;
|
|
130
|
+
this.debug = false;
|
|
131
|
+
this.tokens = true;
|
|
132
|
+
this.apiUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
|
|
133
|
+
this.apiKey = config.apiKey ?? null;
|
|
134
|
+
this.timeoutMs = config.timeout ?? 15000;
|
|
135
|
+
this.maxRetries = config.maxRetries ?? 1;
|
|
136
|
+
this.debug = config.debug ?? false;
|
|
137
|
+
this.tokens = config.enableTokens ?? true;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Updates the client configuration
|
|
141
|
+
* @param {Partial<ApiClientConfig>} config - Configuration updates
|
|
142
|
+
*/
|
|
143
|
+
configure(config) {
|
|
144
|
+
if (config.apiKey !== undefined)
|
|
145
|
+
this.apiKey = config.apiKey;
|
|
146
|
+
if (config.timeout !== undefined && config.timeout !== null)
|
|
147
|
+
this.timeoutMs = config.timeout;
|
|
148
|
+
if (config.maxRetries !== undefined && config.maxRetries !== null)
|
|
149
|
+
this.maxRetries = config.maxRetries;
|
|
150
|
+
if (config.debug !== undefined && config.debug !== null)
|
|
151
|
+
this.debug = config.debug;
|
|
152
|
+
if (config.enableTokens !== undefined && config.enableTokens !== null)
|
|
153
|
+
this.tokens = config.enableTokens;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Gets the current configuration
|
|
157
|
+
* @returns {ApiClientConfig} Current configuration
|
|
158
|
+
*/
|
|
159
|
+
getConfig() {
|
|
160
|
+
return {
|
|
161
|
+
// Omit the raw API key — return a boolean sentinel so callers can check
|
|
162
|
+
// whether a key is configured without being able to extract its value.
|
|
163
|
+
apiKey: this.apiKey ? '***' : null,
|
|
164
|
+
timeout: this.timeoutMs,
|
|
165
|
+
maxRetries: this.maxRetries,
|
|
166
|
+
debug: this.debug,
|
|
167
|
+
enableTokens: this.tokens
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
tryStringify(obj) {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.stringify(obj);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (this.debug) {
|
|
176
|
+
console.error('[ApiClient] JSON.stringify failed:', err);
|
|
177
|
+
}
|
|
178
|
+
throw ApiResponse.error('Failed to serialize request body', {
|
|
179
|
+
code: 400
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Retrieves the stored access token
|
|
185
|
+
* Designed to be overridden in subclasses for custom token storage
|
|
186
|
+
* @returns {string|null} The current access token or null if not set
|
|
187
|
+
*/
|
|
188
|
+
get_access_token() {
|
|
189
|
+
return this.aToken;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Stores the access token
|
|
193
|
+
* Designed to be overridden in subclasses for custom token storage
|
|
194
|
+
* @param {string|null} access - The access token to store, or null to clear
|
|
195
|
+
*/
|
|
196
|
+
set_access_token(access) {
|
|
197
|
+
this.aToken = access;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Retrieves the stored refresh token
|
|
201
|
+
* Designed to be overridden in subclasses for custom token storage
|
|
202
|
+
* @returns {string|null} The current refresh token or null if not set
|
|
203
|
+
*/
|
|
204
|
+
get_refresh_token() {
|
|
205
|
+
return this.rToken;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Stores the refresh token
|
|
209
|
+
* Designed to be overridden in subclasses for custom token storage
|
|
210
|
+
* @param {string|null} refresh - The refresh token to store, or null to clear
|
|
211
|
+
*/
|
|
212
|
+
set_refresh_token(refresh) {
|
|
213
|
+
this.rToken = refresh;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Sets the API key for authentication
|
|
217
|
+
* Designed to be overridden in subclasses for custom API key storage
|
|
218
|
+
* @param {string|null} apikey - The API key to use for authentication, or null to clear
|
|
219
|
+
*/
|
|
220
|
+
set_apikey(apikey) {
|
|
221
|
+
this.apiKey = apikey;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Retrieves the stored API key
|
|
225
|
+
* Designed to be overridden in subclasses for custom API key storage
|
|
226
|
+
* @returns {string|null} The current API key or null if not set
|
|
227
|
+
*/
|
|
228
|
+
get_apikey() {
|
|
229
|
+
return this.apiKey;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Builds authentication headers based on current configuration
|
|
233
|
+
* @returns {Record<string, string>} Headers object with authentication if available
|
|
234
|
+
*/
|
|
235
|
+
buildAuthHeaders() {
|
|
236
|
+
const headers = {};
|
|
237
|
+
if (this.apiKey) {
|
|
238
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
239
|
+
}
|
|
240
|
+
else if (this.tokens) {
|
|
241
|
+
const accessToken = this.get_access_token();
|
|
242
|
+
if (accessToken) {
|
|
243
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return headers;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A drop‑in replacement for fetch() that handles timeouts and
|
|
250
|
+
* normalized network errors and throws an ApiResponse on failure.
|
|
251
|
+
* Uses the instance's configured timeout value.
|
|
252
|
+
*/
|
|
253
|
+
async safeFetch(input, init = {}) {
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
const id = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
256
|
+
const headers = new Headers(init.headers);
|
|
257
|
+
const authHeaders = this.buildAuthHeaders();
|
|
258
|
+
for (const [k, v] of Object.entries(authHeaders)) {
|
|
259
|
+
headers.set(k, v);
|
|
260
|
+
}
|
|
261
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
262
|
+
if (method !== 'GET' &&
|
|
263
|
+
init.body !== undefined &&
|
|
264
|
+
init.body !== null &&
|
|
265
|
+
!headers.has('Content-Type') &&
|
|
266
|
+
!(init.body instanceof FormData)) {
|
|
267
|
+
headers.set('Content-Type', 'application/json');
|
|
268
|
+
}
|
|
269
|
+
if (this.debug) {
|
|
270
|
+
console.log('[safeFetch] Request:', input);
|
|
271
|
+
console.log('[safeFetch] Method:', method);
|
|
272
|
+
console.log('[safeFetch] Headers:', headers);
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const res = await fetch(input, {
|
|
276
|
+
...init,
|
|
277
|
+
headers,
|
|
278
|
+
signal: controller.signal
|
|
279
|
+
});
|
|
280
|
+
return res;
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
if (hasErrorName(err, 'AbortError')) {
|
|
284
|
+
throw ApiResponse.error('Fetch timed out', { code: 504 });
|
|
285
|
+
}
|
|
286
|
+
let reason = '';
|
|
287
|
+
let status = 500;
|
|
288
|
+
const isBrowser = typeof window !== 'undefined' &&
|
|
289
|
+
typeof window.document !== 'undefined';
|
|
290
|
+
if (isBrowser) {
|
|
291
|
+
// Browser environment
|
|
292
|
+
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
|
293
|
+
reason = 'Possible CORS issue or server unreachable';
|
|
294
|
+
status = 503;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
reason = getErrorMessage(err) || 'Unknown browser fetch error';
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// Node.js environment
|
|
302
|
+
switch (getErrorCode(err)) {
|
|
303
|
+
case 'ECONNREFUSED':
|
|
304
|
+
reason = 'Connection refused by server';
|
|
305
|
+
status = 503;
|
|
306
|
+
break;
|
|
307
|
+
case 'ENOTFOUND':
|
|
308
|
+
reason = 'DNS lookup failed - host not found';
|
|
309
|
+
status = 503;
|
|
310
|
+
break;
|
|
311
|
+
case 'ETIMEDOUT':
|
|
312
|
+
reason = 'Connection timed out';
|
|
313
|
+
status = 504;
|
|
314
|
+
break;
|
|
315
|
+
case 'ECONNRESET':
|
|
316
|
+
reason = 'Connection reset by server';
|
|
317
|
+
status = 503;
|
|
318
|
+
break;
|
|
319
|
+
case 'EHOSTUNREACH':
|
|
320
|
+
reason = 'Host unreachable';
|
|
321
|
+
status = 503;
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
reason = getErrorMessage(err) || 'Unknown network error';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
throw ApiResponse.error(`Fetch failed: ${reason}`, { code: status });
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
clearTimeout(id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Safely parses JSON response with proper error handling
|
|
335
|
+
* @param {Response} response - The fetch response
|
|
336
|
+
* @returns {Promise<RawApiResponse<T>>} The parsed JSON data
|
|
337
|
+
*/
|
|
338
|
+
async parseJsonResponse(response) {
|
|
339
|
+
try {
|
|
340
|
+
return (await response.json());
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
throw ApiResponse.error(`Failed to parse JSON response: ${parseMessage(err)}`, {
|
|
344
|
+
code: response.status
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async login(username, password, domain = '', fingerprint = '') {
|
|
349
|
+
try {
|
|
350
|
+
const loginBody = { login: username, password };
|
|
351
|
+
if (domain)
|
|
352
|
+
loginBody.domain = domain;
|
|
353
|
+
if (fingerprint)
|
|
354
|
+
loginBody.fingerprint = fingerprint;
|
|
355
|
+
const response = await this.safeFetch(`${this.apiUrl}/api/auth/v1/login`, {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
body: this.tryStringify(loginBody),
|
|
358
|
+
credentials: 'include'
|
|
359
|
+
});
|
|
360
|
+
const jsondata = await this.parseJsonResponse(response);
|
|
361
|
+
if (this.debug) {
|
|
362
|
+
console.log('Login Response Headers:');
|
|
363
|
+
response.headers.forEach((value, name) => {
|
|
364
|
+
console.log(`${name}: ${value}`);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (response.ok) {
|
|
368
|
+
// Type-safe access to token data — only update tokens when explicitly present
|
|
369
|
+
const tokenData = jsondata.data;
|
|
370
|
+
if (tokenData?.accessToken !== undefined) {
|
|
371
|
+
this.set_access_token(tokenData.accessToken ?? null);
|
|
372
|
+
}
|
|
373
|
+
if (tokenData?.refreshToken !== undefined) {
|
|
374
|
+
this.set_refresh_token(tokenData.refreshToken ?? null);
|
|
375
|
+
}
|
|
376
|
+
return ApiResponse.ok(jsondata.data, {
|
|
377
|
+
message: jsondata.message || 'Login successful',
|
|
378
|
+
code: response.status
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
return ApiResponse.error(jsondata.message || 'Login Failed', {
|
|
383
|
+
code: response.status,
|
|
384
|
+
errors: jsondata.errors || {}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (e instanceof ApiResponse) {
|
|
390
|
+
return e;
|
|
391
|
+
}
|
|
392
|
+
return ApiResponse.error(e);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async logout(token = '') {
|
|
396
|
+
try {
|
|
397
|
+
// Use the caller-supplied token; fall back to the stored refresh token so
|
|
398
|
+
// that cookieless environments (Node.js, no cookie jar) still revoke the token.
|
|
399
|
+
const refreshToken = token || this.get_refresh_token() || '';
|
|
400
|
+
const response = await this.safeFetch(`${this.apiUrl}/api/auth/v1/logout`, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
body: refreshToken ? this.tryStringify({ token: refreshToken }) : '{}',
|
|
403
|
+
credentials: 'include'
|
|
404
|
+
});
|
|
405
|
+
const jsondata = await this.parseJsonResponse(response);
|
|
406
|
+
if (this.debug) {
|
|
407
|
+
console.log('Logout Response Headers:');
|
|
408
|
+
response.headers.forEach((value, name) => {
|
|
409
|
+
console.log(`${name}: ${value}`);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// Always clear local tokens on logout attempt, regardless of server response
|
|
413
|
+
this.set_access_token(null);
|
|
414
|
+
this.set_refresh_token(null);
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
return ApiResponse.error(jsondata.message || 'Logout Failed', {
|
|
417
|
+
code: response.status,
|
|
418
|
+
errors: jsondata.errors || {}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return ApiResponse.ok(jsondata.data, {
|
|
422
|
+
message: jsondata.message || 'Logout Successful',
|
|
423
|
+
code: response.status
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
if (e instanceof ApiResponse) {
|
|
428
|
+
return e;
|
|
429
|
+
}
|
|
430
|
+
return ApiResponse.error(e);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async refreshAccessToken() {
|
|
434
|
+
// Serialise concurrent refresh attempts: if a refresh is already in flight,
|
|
435
|
+
// await it instead of launching a second one (which would fail because the
|
|
436
|
+
// server rotates the refresh token on every use).
|
|
437
|
+
if (this._refreshPromise) {
|
|
438
|
+
return this._refreshPromise;
|
|
439
|
+
}
|
|
440
|
+
this._refreshPromise = this.performRefresh();
|
|
441
|
+
try {
|
|
442
|
+
return (await this._refreshPromise);
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
this._refreshPromise = null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async performRefresh() {
|
|
449
|
+
try {
|
|
450
|
+
if (!this.get_refresh_token()) {
|
|
451
|
+
return ApiResponse.error('No Refresh Token Available', { code: 401 });
|
|
452
|
+
}
|
|
453
|
+
const response = await this.safeFetch(`${this.apiUrl}/api/auth/v1/refresh`, {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
body: this.tryStringify({ refreshToken: this.get_refresh_token() }),
|
|
456
|
+
credentials: 'include'
|
|
457
|
+
});
|
|
458
|
+
const jsondata = await this.parseJsonResponse(response);
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
// Clear stale tokens when the server rejects the refresh token.
|
|
461
|
+
if (response.status === 401 || response.status === 403) {
|
|
462
|
+
this.set_access_token(null);
|
|
463
|
+
this.set_refresh_token(null);
|
|
464
|
+
}
|
|
465
|
+
return ApiResponse.error(jsondata.message || 'Token Refresh Failed', {
|
|
466
|
+
code: response.status,
|
|
467
|
+
errors: jsondata.errors || {}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// Only update tokens when the field is explicitly present in the response.
|
|
471
|
+
const tokenData = jsondata.data;
|
|
472
|
+
if (tokenData?.accessToken !== undefined) {
|
|
473
|
+
this.set_access_token(tokenData.accessToken ?? null);
|
|
474
|
+
}
|
|
475
|
+
if (tokenData?.refreshToken !== undefined) {
|
|
476
|
+
this.set_refresh_token(tokenData.refreshToken ?? null);
|
|
477
|
+
}
|
|
478
|
+
return ApiResponse.ok(jsondata.data, {
|
|
479
|
+
message: jsondata.message || 'Token Refreshed',
|
|
480
|
+
code: response.status
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
if (e instanceof ApiResponse) {
|
|
485
|
+
return e;
|
|
486
|
+
}
|
|
487
|
+
return ApiResponse.error(e);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async fetchWithRetry(url, init = {}) {
|
|
491
|
+
let allowAuthRetry = true;
|
|
492
|
+
// Iterative retry loop to avoid deep call stack
|
|
493
|
+
for (let timeoutRetryCount = 0; timeoutRetryCount <= this.maxRetries; timeoutRetryCount++) {
|
|
494
|
+
try {
|
|
495
|
+
const response = await this.safeFetch(url, {
|
|
496
|
+
...init,
|
|
497
|
+
credentials: 'include'
|
|
498
|
+
});
|
|
499
|
+
// Parse JSON with type safety
|
|
500
|
+
const payload = await this.parseJsonResponse(response);
|
|
501
|
+
// Success case (HTTP layer)
|
|
502
|
+
// If the API uses a JSON { success: false } shape even on 2xx, respect it.
|
|
503
|
+
if (response.ok) {
|
|
504
|
+
if (payload.success === false) {
|
|
505
|
+
return ApiResponse.error(payload.message || 'Request failed', {
|
|
506
|
+
code: payload.code ?? response.status,
|
|
507
|
+
errors: payload.errors || {}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return ApiResponse.ok(payload.data, {
|
|
511
|
+
message: payload.message || 'OK',
|
|
512
|
+
code: payload.code ?? response.status,
|
|
513
|
+
errors: payload.errors || {}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// If using API key, don't retry on auth failures
|
|
517
|
+
if (this.apiKey) {
|
|
518
|
+
return ApiResponse.error(payload.message || 'Request failed with API key authentication', {
|
|
519
|
+
code: response.status,
|
|
520
|
+
errors: payload.errors || {}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// Try to refresh token on 401 (only once)
|
|
524
|
+
if (allowAuthRetry && response.status === 401 && this.get_refresh_token()) {
|
|
525
|
+
if (this.debug) {
|
|
526
|
+
console.log('Received 401, attempting token refresh...');
|
|
527
|
+
}
|
|
528
|
+
const refreshRes = await this.refreshAccessToken();
|
|
529
|
+
if (!refreshRes.success) {
|
|
530
|
+
return ApiResponse.error(refreshRes.message, {
|
|
531
|
+
code: refreshRes.code,
|
|
532
|
+
errors: refreshRes.errors
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
// Allow one more iteration but disable further auth retries
|
|
536
|
+
timeoutRetryCount = 0;
|
|
537
|
+
allowAuthRetry = false;
|
|
538
|
+
continue; // Retry with new token
|
|
539
|
+
}
|
|
540
|
+
// Final fallback error
|
|
541
|
+
return ApiResponse.error(payload.message || 'Unable to serve request', {
|
|
542
|
+
code: response.status,
|
|
543
|
+
errors: payload.errors || {}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
// Handle thrown ApiResponse errors from safeFetch or parseJsonResponse
|
|
548
|
+
if (err instanceof ApiResponse) {
|
|
549
|
+
// Check if this is a timeout error and we can retry
|
|
550
|
+
if (err.code === 504 && timeoutRetryCount < this.maxRetries) {
|
|
551
|
+
if (this.debug) {
|
|
552
|
+
console.log(`Request timed out, retrying... (attempt ${timeoutRetryCount + 1}/${this.maxRetries + 1})`);
|
|
553
|
+
}
|
|
554
|
+
continue; // Retry
|
|
555
|
+
}
|
|
556
|
+
return err; // Return error
|
|
557
|
+
}
|
|
558
|
+
return ApiResponse.error(err);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// This should never be reached, but TypeScript needs it
|
|
562
|
+
return ApiResponse.error('Maximum retries exceeded', { code: 500 });
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Performs a generic HTTP request to the API
|
|
566
|
+
* @param {'GET'|'POST'|'PUT'|'DELETE'} method - The HTTP method to use
|
|
567
|
+
* @param {string} command - The API endpoint path
|
|
568
|
+
* @param {SerializableBody} [body] - Optional request body (for POST/PUT/DELETE requests)
|
|
569
|
+
* @returns {Promise<ApiResponse<T>>} A promise resolving to the API response
|
|
570
|
+
* @template T - The expected type of the response data
|
|
571
|
+
*/
|
|
572
|
+
async request(method, command, body, files) {
|
|
573
|
+
const url = `${this.apiUrl}${command}`;
|
|
574
|
+
const options = {
|
|
575
|
+
method
|
|
576
|
+
};
|
|
577
|
+
if (files && files.length > 0) {
|
|
578
|
+
const form = new FormData();
|
|
579
|
+
if (body !== undefined && body !== null) {
|
|
580
|
+
if (isPlainObject(body)) {
|
|
581
|
+
for (const [key, value] of Object.entries(body)) {
|
|
582
|
+
form.append(key, typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
form.append('payload', this.tryStringify(body));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const fileEntry of files) {
|
|
590
|
+
if (Array.isArray(fileEntry)) {
|
|
591
|
+
const [name, file] = fileEntry;
|
|
592
|
+
form.append(name, file);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
form.append('files', fileEntry); // default field name
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
options.body = form;
|
|
599
|
+
}
|
|
600
|
+
else if (body !== undefined) {
|
|
601
|
+
try {
|
|
602
|
+
options.body = this.tryStringify(body);
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
return ApiResponse.error(e);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (this.debug) {
|
|
609
|
+
console.log(`Making ${method} request to: ${url} (timeout: ${this.timeoutMs}ms, max retries: ${this.maxRetries})`);
|
|
610
|
+
if (body !== undefined) {
|
|
611
|
+
console.log('Request body:', options.body);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return this.fetchWithRetry(url, options);
|
|
615
|
+
}
|
|
616
|
+
async get(command) {
|
|
617
|
+
return this.request('GET', command);
|
|
618
|
+
}
|
|
619
|
+
async post(command, body, files) {
|
|
620
|
+
return this.request('POST', command, body, files);
|
|
621
|
+
}
|
|
622
|
+
async put(command, body) {
|
|
623
|
+
return this.request('PUT', command, body);
|
|
624
|
+
}
|
|
625
|
+
async delete(command, body) {
|
|
626
|
+
return this.request('DELETE', command, body);
|
|
627
|
+
}
|
|
628
|
+
async ping() {
|
|
629
|
+
return this.get('/api/v1/ping');
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
export default ApiClient;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as ApiClient, ApiResponse } from './apicore-client.js';
|
|
2
|
+
export { default } from './apicore-client.js';
|
|
3
|
+
export type { maybeFile } from './apicore-client.js';
|
|
4
|
+
export type { ApiClientConfig, ApiResponseData, AuthIdentifier, AuthTokenData, LogoutResponseData, PingResponseData, SafeUser, WhoAmIResponseData } from './apicore-client.js';
|