Passed
Pull Request — main (#22)
by Alberto
01:25
created

src/bedita-api-client.ts   B

Complexity

Total Complexity 47
Complexity/F 2.35

Size

Lines of Code 517
Function Count 20

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 47
eloc 271
mnd 27
bc 27
fnc 20
dl 0
loc 517
rs 8.64
bpm 1.35
cpm 2.35
noi 0
c 0
b 0
f 0

20 Functions

Rating   Name   Duplication   Size   Complexity  
A BEditaApiClient.addInterceptor 0 33 4
A BEditaApiClient.addDefaultInterceptors 0 8 1
B BEditaApiClient.getConfig 0 11 6
A BEditaApiClient.getHttpClient 0 6 1
A BEditaApiClient.clientCredentials 0 12 1
A BEditaApiClient.getUserAuth 0 25 2
A BEditaApiClient.hasInterceptor 0 13 2
B BEditaApiClient.removeInterceptor 0 27 6
A BEditaApiClient.get 0 13 1
B BEditaApiClient.request 0 34 5
A BEditaApiClient.renewTokens 0 21 3
A BEditaApiClient.delete 0 15 1
A BEditaApiClient.authenticate 0 15 2
A BEditaApiClient.post 0 15 1
A BEditaApiClient.getRequestInterceptorsMap 0 7 1
A BEditaApiClient.getStorageService 0 6 1
A BEditaApiClient.auth 0 17 2
A BEditaApiClient.patch 0 15 1
A BEditaApiClient.getResponseInterceptorsMap 0 7 1
A BEditaApiClient.save 0 27 5

How to fix   Complexity   

Complexity

Complex classes like src/bedita-api-client.ts often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse }  from 'axios';
2
import AuthInterceptor from './interceptors/auth-interceptor';
3
import RefreshAuthInterceptor from './interceptors/refresh-auth-interceptor';
4
import StorageService from './services/storage-service';
5
import FormatUserInterceptor from './interceptors/format-user.interceptor';
6
import ContentTypeInterceptor from './interceptors/content-type-interceptor';
7
import { RequestInterceptorInterface } from './interceptors/request-interceptor';
8
import { ResponseInterceptorInterface } from './interceptors/response-interceptor';
9
import MapIncludedInterceptor from './interceptors/map-included-interceptor';
10
11
/**
12
 * Interface for API client configuration.
13
 *
14
 * - baseUrl: the BEdita API base URL
15
 * - apiKey: the API KEY to use (optional). Deprecated, you are encouraged to use `clientId` and `clientSecret` instead.
16
 * - name: the name of the client instance (optional, default 'bedita')
17
 * - clientId: the client id used for client credentials flow (optional)
18
 * - clientSecret: the client secret used for client credentials flow (optional)
19
 */
20
export interface ApiClientConfig {
21
    baseUrl: string,
22
    apiKey?: string,
23
    name?: string,
24
    clientId?: string,
25
    clientSecret?: string,
26
}
27
28
/**
29
 * Interface of JSON API resource object
30
 *
31
 * see https://jsonapi.org/format/#document-resource-objects
32
 */
33
export interface JsonApiResourceObject {
34
    type: string,
35
    id?: string,
36
    attributes?: { [s: string]: any },
37
    relationships?:  { [s: string]: any },
38
    links?: { [s: string]: any },
39
    meta?:  { [s: string]: any },
40
}
41
42
/**
43
 * Interface for a successfully API response body.
44
 */
45
export interface ApiResponseBodyOk {
46
    data: JsonApiResourceObject | JsonApiResourceObject[],
47
    meta: { [s: string]: any },
48
    links?: { [s: string]: any },
49
    included?: JsonApiResourceObject[],
50
}
51
52
/**
53
 * Interface for a errored API response body.
54
 */
55
export interface ApiResponseBodyError {
56
    error: { [s: string]: any },
57
    links?: { [s: string]: any },
58
    meta?: { [s: string]: any },
59
}
60
61
/**
62
 * Interface for configuration used for BEdita API requests.
63
 * Extends AxiosRequestConfig adding configuration for
64
 * dynamic uses of request and response interceptors.
65
 */
66
export interface BEditaClientRequestConfig extends AxiosRequestConfig {
67
    requestInterceptors?: RequestInterceptorInterface[],
68
    responseInterceptors?: ResponseInterceptorInterface[],
69
}
70
71
/**
72
 * Interface of BEdita client response.
73
 * It extends AxiosResponse adding an optional `formatData`
74
 * that can be used to store fromatted data.
75
 */
76
export interface BEditaClientResponse<T = any> extends AxiosResponse {
77
    formattedData?: T;
78
}
79
80
/**
81
 * String enums for grant types.
82
 */
83
export enum GrantType {
84
    Password = 'password',
85
    ClientCredentials = 'client_credentials',
86
    RefreshToken = 'refresh_token',
87
}
88
89
/**
90
 * Interface describing data used for auth action.
91
 */
92
export interface AuthData {
93
    username?: string,
94
    password?: string,
95
    client_id?: string,
96
    client_secret?: string,
97
    [s: string]: any,
98
    grant_type: GrantType | string,
99
}
100
101
/**
102
 * BEdita API client.
103
 */
104
export class BEditaApiClient {
105
106
    /**
107
     * The Api client configuration.
108
     */
109
    #config: ApiClientConfig;
110
111
    /**
112
     * Keep The axios instance.
113
     */
114
    #axiosInstance: AxiosInstance;
115
116
    /**
117
     * Keep the token service instance.
118
     */
119
    #storageService: StorageService;
120
121
    /**
122
     * Map of request interceptors added to avoid double addition.
123
     *
124
     * The values are the interceptor contructor names
125
     * and the keys are the corresponding index in Axios.
126
     */
127
    #requestInterceptorsMap: Map<string, number> = new Map();
128
129
    /**
130
     * Map of response interceptors added to avoid double addition.
131
     *
132
     * The values are the interceptor contructor names
133
     * and the keys are the corresponding index in Axios.
134
     */
135
    #responseInterceptorsMap: Map<string, number> = new Map();
136
137
    /**
138
     * Constructor.
139
     *
140
     * @param config The configuration for the API client
141
     */
142
    constructor(config: ApiClientConfig) {
143
        if (!config.name) {
144
            config.name = 'bedita';
145
        }
146
147
        const axiosConfig: AxiosRequestConfig = {
148
            baseURL: config.baseUrl,
149
            headers: {
150
                Accept: 'application/vnd.api+json',
151
            },
152
        };
153
154
        if (config.clientId) {
155
            delete config.apiKey; // remove deprecated API key
156
        }
157
158
        if (config.apiKey) {
159
            axiosConfig.headers['X-Api-Key'] = config.apiKey;
160
        }
161
162
        this.#config = { ...config };
163
        this.#axiosInstance = axios.create(axiosConfig);
164
        this.#storageService = new StorageService(config.name);
165
166
        this.addDefaultInterceptors();
167
    }
168
169
    /**
170
     * Return the client configuration.
171
     * If key is specified return only the value related.
172
     */
173
    public getConfig(key?: string): ApiClientConfig | any {
174
        if (key) {
175
            return this.#config?.[key] || null;
176
        }
177
178
        return this.#config;
179
    }
180
181
    /**
182
     * Add default interceptors.
183
     */
184
    protected addDefaultInterceptors(): void {
185
        this.addInterceptor(new AuthInterceptor(this));
186
        this.addInterceptor(new ContentTypeInterceptor(this));
187
        this.addInterceptor(new RefreshAuthInterceptor(this));
188
    }
189
190
    /**
191
     * Add an interceptor to the axios instance.
192
     *
193
     * @param interceptor The interceptor instance
194
     */
195
    public addInterceptor(interceptor: RequestInterceptorInterface | ResponseInterceptorInterface): number {
196
        const name = interceptor.constructor.name;
197
        if ('requestHandler' in interceptor) {
198
            if (this.#requestInterceptorsMap.has(name)) {
199
                return this.#requestInterceptorsMap.get(name);
200
            }
201
202
            const idx = this.#axiosInstance.interceptors.request.use(
203
                interceptor.requestHandler.bind(interceptor),
204
                interceptor.errorHandler.bind(interceptor)
205
            );
206
            this.#requestInterceptorsMap.set(name, idx);
207
208
            return idx;
209
        }
210
211
        if (this.#responseInterceptorsMap.has(name)) {
212
            return this.#responseInterceptorsMap.get(name);
213
        }
214
215
        const index = this.#axiosInstance.interceptors.response.use(
216
            interceptor.responseHandler.bind(interceptor),
217
            interceptor.errorHandler.bind(interceptor)
218
        );
219
        this.#responseInterceptorsMap.set(name, index);
220
221
        return index;
222
    }
223
224
    /**
225
     * Say if interceptor is already present.
226
     *
227
     * @param interceptor The interceptor instance
228
     */
229
    public hasInterceptor(interceptor: RequestInterceptorInterface | ResponseInterceptorInterface): boolean {
230
        const name = interceptor.constructor.name;
231
        if ('requestHandler' in interceptor) {
232
            return this.#requestInterceptorsMap.has(name);
233
        }
234
235
        return this.#responseInterceptorsMap.has(name);
236
    }
237
238
    /**
239
     * Remove an interceptor from axios instance.
240
     *
241
     * @param id The interceptor id
242
     * @param type The interceptor type
243
     */
244
    public removeInterceptor(id: number, type: 'request' | 'response'): void {
245
        if (type === 'request') {
246
            for (const item of this.#requestInterceptorsMap) {
247
                if (item[1] === id) {
248
                    this.#requestInterceptorsMap.delete(item[0]);
249
                    break;
250
                }
251
            }
252
253
            return this.#axiosInstance.interceptors.request.eject(id);
254
        }
255
256
        for (const item of this.#responseInterceptorsMap) {
257
            if (item[1] === id) {
258
                this.#responseInterceptorsMap.delete(item[0]);
259
                break;
260
            }
261
        }
262
263
        this.#axiosInstance.interceptors.response.eject(id);
264
    }
265
266
    /**
267
     * Return the request interceptors map
268
     */
269
    public getRequestInterceptorsMap(): Map<string, number>
270
    {
271
        return this.#requestInterceptorsMap;
272
    }
273
274
    /**
275
     * Return the response interceptors map
276
     */
277
    public getResponseInterceptorsMap(): Map<string, number>
278
    {
279
        return this.#responseInterceptorsMap;
280
    }
281
282
    /**
283
     * Return the Axios instance.
284
     */
285
    public getHttpClient(): AxiosInstance {
286
        return this.#axiosInstance;
287
    }
288
289
    /**
290
     * Return the token service.
291
     */
292
    public getStorageService(): StorageService {
293
        return this.#storageService;
294
    }
295
296
    /**
297
     * Proxy to axios generic request.
298
     * It assure to resolve the Promise with a BEditaClientResponse.
299
     *
300
     * @param config Request configuration
301
     */
302
    public async request(config: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
303
        const reqIntercetorsIds = [], respInterceptorsIds = [];
304
        if (config.requestInterceptors) {
305
            config.requestInterceptors.forEach(interceptorInstance => {
306
                if (!this.hasInterceptor(interceptorInstance)) {
307
                    reqIntercetorsIds.push(this.addInterceptor(interceptorInstance));
308
                }
309
            });
310
311
            delete config.requestInterceptors;
312
        }
313
314
        if (config.responseInterceptors) {
315
            config.responseInterceptors.forEach(interceptorInstance => {
316
                if (!this.hasInterceptor(interceptorInstance)) {
317
                    respInterceptorsIds.push(this.addInterceptor(interceptorInstance));
318
                }
319
            });
320
321
            delete config.responseInterceptors;
322
        }
323
        const response = await this.#axiosInstance.request(config);
324
325
        reqIntercetorsIds.forEach(id => this.removeInterceptor(id, 'request'));
326
        respInterceptorsIds.forEach(id => this.removeInterceptor(id, 'response'));
327
328
        return response as BEditaClientResponse;
329
    }
330
331
    /**
332
     * Send a GET request.
333
     *
334
     * @param url The endpoint URL path to invoke
335
     * @param config Request configuration
336
     */
337
    public get(url: string, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
338
        config = config || {}
339
        config.method = 'get';
340
        config.url = url;
341
342
        return this.request(config);
343
    }
344
345
    /**
346
     * Send a POST request.
347
     *
348
     * @param url The endpoint URL path to invoke
349
     * @param data Payload to send
350
     * @param config Request configuration
351
     */
352
    public post(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
353
        config = config || {}
354
        config.method = 'post';
355
        config.url = url;
356
        config.data = data || null;
357
358
        return this.request(config);
359
    }
360
361
    /**
362
     * Send a PATCH request.
363
     *
364
     * @param url The endpoint URL path to invoke
365
     * @param data Payload to send
366
     * @param config Request configuration
367
     */
368
    public patch(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
369
        config = config || {}
370
        config.method = 'patch';
371
        config.url = url;
372
        config.data = data || null;
373
374
        return this.request(config);
375
    }
376
377
    /**
378
     * Send a DELETE request.
379
     *
380
     * @param url The endpoint URL path to invoke
381
     * @param data Payload to send
382
     * @param config Request configuration
383
     */
384
    public delete(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
385
        config = config || {}
386
        config.method = 'delete';
387
        config.url = url;
388
        config.data = data || null;
389
390
        return this.request(config);
391
    }
392
393
    /**
394
     * Authenticate a user, saving in storage access and refresh token.
395
     *
396
     * @param username The username
397
     * @param password The password
398
     */
399
    public async authenticate(username: string, password: string): Promise<BEditaClientResponse<any>> {
400
        if (this.getConfig('apiKey')) {
401
            this.#storageService.clearTokens();
402
        }
403
        this.#storageService.remove('user');
404
        const data: AuthData = { username, password, grant_type: GrantType.Password };
405
406
        return await this.auth(data);
407
    }
408
409
    /**
410
     * Execute an auth request.
411
     *
412
     * @param data The auth data
413
     * @param config Additional request configuration
414
     */
415
    protected async auth(data: AuthData, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
416
        const response = await this.post('/auth', data, config);
417
        const tokens = response.data && response.data.meta || {};
418
        if (!tokens.jwt || !tokens.renew) {
419
            return Promise.reject('Something was wrong with response data.');
420
        }
421
        this.#storageService.accessToken = tokens.jwt;
422
        this.#storageService.refreshToken = tokens.renew;
423
424
        return response;
425
    }
426
427
    /**
428
     * Client credentials auth.
429
     */
430
    public async clientCredentials(): Promise<BEditaClientResponse<any>> {
431
        const data: AuthData = {
432
            client_id: this.getConfig('clientId'),
433
            client_secret: this.getConfig('clientSecret'),
434
            grant_type: GrantType.ClientCredentials,
435
        };
436
437
        return await this.auth(data);
438
    }
439
440
    /**
441
     * Get the authenticated user and store it.
442
     * Format user data using `FormatUserInterceptor`.
443
     * If `include` is passed than `MapIncludedInterceptor` will be used too
444
     * to put related objects inside the relative relationship key.
445
     *
446
     * @param include A list of relationships to include
447
     */
448
    public async getUserAuth(include?: Array<string>): Promise<BEditaClientResponse<any>> {
449
        const responseInterceptors: Array<ResponseInterceptorInterface> = [new FormatUserInterceptor(this)];
450
        const params: any = {};
451
        if (include && include.length > 0) {
452
            responseInterceptors.unshift(new MapIncludedInterceptor());
453
            params.include = include.join(',');
454
        }
455
456
        const response = await this.get('/auth/user', {
457
            params,
458
            responseInterceptors,
459
        });
460
461
        this.#storageService.set('user', JSON.stringify(response.formattedData));
462
463
        return response;
464
    }
465
466
    /**
467
     * Renew access and refresh tokens.
468
     */
469
    public async renewTokens(): Promise<BEditaClientResponse<any>> {
470
        const refreshToken = this.#storageService.refreshToken;
471
        if (!refreshToken) {
472
            return Promise.reject('Missing refresh token.');
473
        }
474
475
        const config = {
476
            headers: {
477
                Authorization: `Bearer ${refreshToken}`,
478
            },
479
        };
480
481
        try {
482
            return await this.auth({ grant_type: GrantType.RefreshToken }, config);
483
        } catch (error) {
484
            this.#storageService.clearTokens().remove('user');
485
            throw error;
486
        }
487
    }
488
489
    /**
490
     * Save a resource.
491
     * If data contains `id` then it create new one resource
492
     * else it update existing resource.
493
     *
494
     * @param type The resource type
495
     * @param data The data to save
496
     */
497
    public async save(type: string, data: {[s: string]: any}): Promise<BEditaClientResponse> {
498
        if (!type) {
499
            throw new Error('Missing required type');
500
        }
501
502
        const body: {data: JsonApiResourceObject} = { data: {type} };
503
        const id: string|null = data?.id;
504
        if (id) {
505
            body.data.id = id;
506
        }
507
        delete data.id;
508
        body.data.attributes = data;
509
510
        if (id) {
511
            return await this.patch(`${type}/${id}`, body);
512
        }
513
514
        return await this.post(`${type}`, body);
515
    }
516
}
517