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