Passed
Pull Request — main (#20)
by Dante
01:50
created

BEditaApiClient.getRequestinterceptorsMap   A

Complexity

Conditions 1

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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