@js4cytoscape/ndex-client 0.5.0-alpha.9 → 0.6.0-alpha.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.
@@ -0,0 +1,445 @@
1
+ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
2
+ import {
3
+ NDExClientConfig,
4
+ APIResponse,
5
+ APIError,
6
+ BasicAuth,
7
+ OAuthAuth,
8
+ NDExError,
9
+ NDExNetworkError,
10
+ NDExAuthError,
11
+ NDExNotFoundError,
12
+ NDExValidationError,
13
+ NDExServerError
14
+ } from '../types';
15
+ import { version } from '../../package.json';
16
+
17
+ /**
18
+ * HTTPService - Core HTTP client for NDEx API communication
19
+ * Handles v2/v3 endpoint routing, authentication, and error handling
20
+ *
21
+ * @note User-Agent header is set to 'NDEx-JS-Client/${version}' but only works in Node.js.
22
+ * Browsers will ignore custom User-Agent headers for security reasons.
23
+ */
24
+ export class HTTPService {
25
+ private axiosInstance: AxiosInstance;
26
+ private config: NDExClientConfig;
27
+
28
+ constructor(config: NDExClientConfig = {}) {
29
+ this.config = {
30
+ baseURL: 'https://www.ndexbio.org',
31
+ timeout: 30000,
32
+ retries: 3,
33
+ retryDelay: 1000,
34
+ debug: false,
35
+ ...config,
36
+ };
37
+
38
+ // Create headers object
39
+ const headers: Record<string, string> = {
40
+ 'Content-Type': 'application/json',
41
+ ...this.config.headers,
42
+ ...this.getAuthHeaders(),
43
+ };
44
+
45
+ // Only set User-Agent in Node.js environments (not in browsers)
46
+ if (typeof window === 'undefined') {
47
+ headers['User-Agent'] = `NDEx-JS-Client/${version}`;
48
+ }
49
+
50
+ this.axiosInstance = axios.create({
51
+ baseURL: this.config.baseURL,
52
+ timeout: this.config.timeout,
53
+ headers,
54
+ });
55
+
56
+ this.setupInterceptors();
57
+ }
58
+
59
+ /**
60
+ * Get authentication headers based on config.auth
61
+ */
62
+ private getAuthHeaders(): Record<string, string> {
63
+ if (!this.config.auth) {
64
+ return {};
65
+ }
66
+
67
+ if (this.config.auth.type === 'basic') {
68
+ // Basic authentication: encode username:password in base64
69
+ const credentials = btoa(`${this.config.auth.username}:${this.config.auth.password}`);
70
+ return {
71
+ 'Authorization': `Basic ${credentials}`,
72
+ };
73
+ } else if (this.config.auth.type === 'oauth') {
74
+ // OAuth Bearer token
75
+ return {
76
+ 'Authorization': `Bearer ${this.config.auth.idToken}`,
77
+ };
78
+ }
79
+
80
+ return {};
81
+ }
82
+
83
+ /**
84
+ * Build API endpoint URL with version routing
85
+ */
86
+ private buildUrl(endpoint: string, version: 'v2' | 'v3' = 'v2'): string {
87
+ const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
88
+ return `/${version}/${cleanEndpoint}`;
89
+ }
90
+
91
+ /**
92
+ * Generic GET request with intelligent version routing
93
+ */
94
+ async get<T = any>(
95
+ endpoint: string,
96
+ config?: AxiosRequestConfig & { version?: 'v2' | 'v3' }
97
+ ): Promise<T> {
98
+ const { version, ...axiosConfig } = config || {};
99
+ const url = this.buildUrl(endpoint, version);
100
+
101
+ try {
102
+ const response = await this.axiosInstance.get<APIResponse<T>>(url, axiosConfig);
103
+ return this.handleResponse<T>(response);
104
+ } catch (error) {
105
+ this.handleError(error);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Generic POST request with intelligent version routing
111
+ */
112
+ async post<T = any>(
113
+ endpoint: string,
114
+ data?: any,
115
+ config?: AxiosRequestConfig & { version?: 'v2' | 'v3' }
116
+ ): Promise<T> {
117
+ const { version, ...axiosConfig } = config || {};
118
+ const url = this.buildUrl(endpoint, version);
119
+
120
+ try {
121
+ const response = await this.axiosInstance.post<APIResponse<T>>(url, data, axiosConfig);
122
+ return this.handleResponse<T>(response);
123
+ } catch (error) {
124
+ this.handleError(error);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Generic PUT request with intelligent version routing
130
+ */
131
+ async put<T = any>(
132
+ endpoint: string,
133
+ data?: any,
134
+ config?: AxiosRequestConfig & { version?: 'v2' | 'v3' }
135
+ ): Promise<T> {
136
+ const { version, ...axiosConfig } = config || {};
137
+ const url = this.buildUrl(endpoint, version);
138
+
139
+ try {
140
+ const response = await this.axiosInstance.put<APIResponse<T>>(url, data, axiosConfig);
141
+ return this.handleResponse<T>(response);
142
+ } catch (error) {
143
+ this.handleError(error);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Generic DELETE request with intelligent version routing
149
+ */
150
+ async delete<T = any>(
151
+ endpoint: string,
152
+ config?: AxiosRequestConfig & { version?: 'v2' | 'v3' }
153
+ ): Promise<T> {
154
+ const { version, ...axiosConfig } = config || {};
155
+ const url = this.buildUrl(endpoint, version);
156
+
157
+ try {
158
+ const response = await this.axiosInstance.delete<APIResponse<T>>(url, axiosConfig);
159
+ return this.handleResponse<T>(response);
160
+ } catch (error) {
161
+ this.handleError(error);
162
+ }
163
+ }
164
+
165
+
166
+ /**
167
+ * Upload a file to the NDEx API with progress tracking support
168
+ *
169
+ * This method handles file uploads using multipart/form-data encoding and supports
170
+ * various input types for maximum flexibility across different environments.
171
+ *
172
+ * @template T - The expected response type from the API
173
+ * @param endpoint - API endpoint path (e.g., 'networks' for network upload)
174
+ * @param file - File data to upload. Supports multiple input types:
175
+ * - `File` (browser): HTML5 File object from file input or drag-and-drop
176
+ * - `Blob` (browser): Binary data as Blob object
177
+ * - `Buffer` (Node.js): Binary data as Buffer for server-side uploads
178
+ * - `string`: Text content that will be converted to Blob (useful for CX/CX2 JSON)
179
+ * @param options - Upload configuration options
180
+ * @param options.filename - Custom filename for the upload. Defaults:
181
+ * - File objects: uses `file.name`
182
+ * - Other types: 'file.cx2'
183
+ * @param options.contentType - MIME type for string content (default: 'application/json')
184
+ * @param options.onProgress - Progress callback that receives percentage (0-100)
185
+ * Called periodically during upload with current progress
186
+ * @param options.version - API version to use ('v2' or 'v3', defaults to 'v2')
187
+ * @param options.config - Additional Axios configuration options
188
+ *
189
+ * @returns Promise resolving to upload result
190
+ *
191
+ * @example
192
+ * ```typescript
193
+ * // Upload a File object from file input (browser)
194
+ * const fileInput = document.getElementById('file') as HTMLInputElement;
195
+ * const file = fileInput.files[0];
196
+ * const result = await httpService.uploadFile('networks', file, {
197
+ * filename: 'my-network.cx2',
198
+ * onProgress: (progress) => console.log(`Upload: ${progress}%`),
199
+ * version: 'v3'
200
+ * });
201
+ *
202
+ * // Upload CX2 network data as string
203
+ * const cx2Data = JSON.stringify({ CXVersion: '2.0', networks: [...] });
204
+ * const result = await httpService.uploadFile('networks', cx2Data, {
205
+ * filename: 'network.cx2',
206
+ * contentType: 'application/json',
207
+ * version: 'v3'
208
+ * });
209
+ *
210
+ * // Upload with progress tracking
211
+ * const result = await httpService.uploadFile('networks', file, {
212
+ * onProgress: (progress) => {
213
+ * progressBar.style.width = `${progress}%`;
214
+ * console.log(`Uploading: ${progress}%`);
215
+ * }
216
+ * });
217
+ * ```
218
+ *
219
+ * @throws Will throw NDExError on network errors,
220
+ * server errors, or invalid file data
221
+ *
222
+ * @note The Content-Type header is automatically set to 'multipart/form-data'
223
+ * and Axios will handle the boundary parameter automatically
224
+ */
225
+ async uploadFile<T = any>(
226
+ endpoint: string,
227
+ file: File | Blob | Buffer | string,
228
+ options: {
229
+ filename?: string;
230
+ contentType?: string;
231
+ onProgress?: (progress: number) => void;
232
+ version?: 'v2' | 'v3';
233
+ } = {}
234
+ ): Promise<T> {
235
+ const { version, onProgress, filename, contentType, ...config } = options;
236
+ const url = this.buildUrl(endpoint, version);
237
+
238
+ const formData = new FormData();
239
+
240
+ if (file instanceof File) {
241
+ formData.append('file', file, filename || file.name);
242
+ } else if (file instanceof Blob) {
243
+ formData.append('file', file, filename || 'file.cx2');
244
+ } else if (typeof file === 'string') {
245
+ const blob = new Blob([file], { type: contentType || 'application/json' });
246
+ formData.append('file', blob, filename || 'file.cx2');
247
+ } else {
248
+ // Buffer (Node.js environment)
249
+ formData.append('file', file as any, filename || 'file.cx2');
250
+ }
251
+
252
+ try {
253
+ const response = await this.axiosInstance.post<APIResponse<T>>(url, formData, {
254
+ headers: {
255
+ 'Content-Type': 'multipart/form-data',
256
+ },
257
+ onUploadProgress: onProgress ? (progressEvent) => {
258
+ const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
259
+ onProgress(progress);
260
+ } : undefined,
261
+ ...config,
262
+ });
263
+
264
+ return this.handleResponse<T>(response);
265
+ } catch (error) {
266
+ this.handleError(error);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Setup axios interceptors for debugging and retry logic
272
+ */
273
+ private setupInterceptors(): void {
274
+ // Request interceptor for debugging
275
+ this.axiosInstance.interceptors.request.use(
276
+ (config) => {
277
+ if (this.config.debug) {
278
+ console.log('NDEx API Request:', {
279
+ method: config.method?.toUpperCase(),
280
+ url: config.url,
281
+ headers: config.headers,
282
+ });
283
+ }
284
+ return config;
285
+ },
286
+ (error) => {
287
+ if (this.config.debug) {
288
+ console.error('NDEx API Request Error:', error);
289
+ }
290
+ return Promise.reject(error);
291
+ }
292
+ );
293
+
294
+ // Response interceptor for debugging
295
+ this.axiosInstance.interceptors.response.use(
296
+ (response) => {
297
+ if (this.config.debug) {
298
+ console.log('NDEx API Response:', {
299
+ status: response.status,
300
+ url: response.config.url,
301
+ data: response.data,
302
+ });
303
+ }
304
+ return response;
305
+ },
306
+ (error) => {
307
+ if (this.config.debug) {
308
+ console.error('NDEx API Response Error:', error.response?.data || error.message);
309
+ }
310
+ return Promise.reject(error);
311
+ }
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Handle successful API responses
317
+ */
318
+ private handleResponse<T>(response: AxiosResponse<any>): T {
319
+ // Handle different response formats from NDEx API
320
+ if (response.data && typeof response.data === 'object') {
321
+ if ('errorCode' in response.data) {
322
+ // NDEx error format - throw appropriate error
323
+ this.throwNDExError(
324
+ response.data.message || 'Unknown error occurred',
325
+ response.status,
326
+ response.data.errorCode || 'UNKNOWN_ERROR',
327
+ response.data.description
328
+ );
329
+ }
330
+ }
331
+
332
+ // Handle wrapped vs direct responses
333
+ const data = response.data && typeof response.data === 'object' && 'data' in response.data
334
+ ? response.data.data
335
+ : response.data;
336
+
337
+ return data as T;
338
+ }
339
+
340
+ /**
341
+ * Handle API errors by throwing appropriate NDEx error types
342
+ */
343
+ private handleError(error: any): never {
344
+ if (error.response) {
345
+ // Server responded with error status
346
+ const message = error.response.data?.message || error.message;
347
+ const errorCode = error.response.data?.errorCode || `HTTP_${error.response.status}`;
348
+ const description = error.response.data?.description;
349
+
350
+ this.throwNDExError(message, error.response.status, errorCode, description);
351
+ } else if (error.request) {
352
+ // Request made but no response received
353
+ throw new NDExNetworkError('Network error - no response received', error);
354
+ } else {
355
+ // Something else happened
356
+ throw new NDExError(error.message || 'Unknown error occurred', undefined, 'CLIENT_ERROR', error.toString());
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Throw appropriate NDEx error based on status code
362
+ */
363
+ private throwNDExError(message: string, statusCode: number, errorCode: string, description?: string): never {
364
+ switch (statusCode) {
365
+ case 400:
366
+ throw new NDExValidationError(message, statusCode);
367
+ case 401:
368
+ case 403:
369
+ throw new NDExAuthError(message, statusCode);
370
+ case 404:
371
+ throw new NDExNotFoundError(message, statusCode);
372
+ case 500:
373
+ case 502:
374
+ case 503:
375
+ case 504:
376
+ throw new NDExServerError(message, statusCode);
377
+ default:
378
+ throw new NDExError(message, statusCode, errorCode, description);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Update client configuration
384
+ */
385
+ updateConfig(newConfig: Partial<NDExClientConfig>): void {
386
+ this.config = { ...this.config, ...newConfig };
387
+
388
+ if (newConfig.baseURL) {
389
+ this.axiosInstance.defaults.baseURL = newConfig.baseURL;
390
+ }
391
+
392
+ if (newConfig.timeout) {
393
+ this.axiosInstance.defaults.timeout = newConfig.timeout;
394
+ }
395
+
396
+ if (newConfig.headers) {
397
+ Object.assign(this.axiosInstance.defaults.headers, newConfig.headers);
398
+ }
399
+
400
+ // Update auth headers if auth config changed
401
+ if ('auth' in newConfig) {
402
+ const authHeaders = this.getAuthHeaders();
403
+
404
+ // Clear existing authorization header first
405
+ delete this.axiosInstance.defaults.headers.common['Authorization'];
406
+
407
+ // Set new auth headers
408
+ Object.assign(this.axiosInstance.defaults.headers.common, authHeaders);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Get current configuration
414
+ */
415
+ getConfig(): NDExClientConfig {
416
+ return { ...this.config };
417
+ }
418
+
419
+ /**
420
+ * Get current authentication type
421
+ * @returns The type of authentication configured ('basic' | 'oauth'), or undefined if no auth is configured
422
+ */
423
+ getAuthType(): 'basic' | 'oauth' | undefined {
424
+ return this.config.auth?.type;
425
+ }
426
+
427
+ /**
428
+ * Get ID token from OAuth authentication
429
+ * @returns The ID token if OAuth auth is configured, undefined otherwise
430
+ */
431
+ getIdToken(): string | undefined {
432
+ return this.config.auth?.type === 'oauth' ? this.config.auth.idToken : undefined;
433
+ }
434
+
435
+ /**
436
+ * Set ID token for OAuth authentication
437
+ * Creates OAuth auth configuration if no auth is currently configured
438
+ * @param idToken - The ID token to set
439
+ */
440
+ setIdToken(idToken: string): void {
441
+ this.updateConfig({
442
+ auth: { type: 'oauth', idToken }
443
+ });
444
+ }
445
+ }