Completed
Push — main ( db7e0a...6d480a )
by Alberto
27s queued 12s
created

BEditaApiClient.getResponseInterceptorsMap   A

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 7
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
    public getRequestInterceptorsMap(): Map<string, number>
226
    {
227
        return this.#requestInterceptorsMap;
228
    }
229
230
    /**
231
     * Return the response interceptors map
232
     */
233
    public getResponseInterceptorsMap(): Map<string, number>
234
    {
235
        return this.#responseInterceptorsMap;
236
    }
237
238
    /**
239
     * Return the Axios instance.
240
     */
241
    public getHttpClient(): AxiosInstance {
242
        return this.#axiosInstance;
243
    }
244
245
    /**
246
     * Return the token service.
247
     */
248
    public getStorageService(): StorageService {
249
        return this.#storageService;
250
    }
251
252
    /**
253
     * Proxy to axios generic request.
254
     * It assure to resolve the Promise with a BEditaClientResponse.
255
     *
256
     * @param config Request configuration
257
     */
258
    public async request(config: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
259
        const reqIntercetorsIds = [], respInterceptorsIds = [];
260
        if (config.requestInterceptors) {
261
            config.requestInterceptors.forEach(interceptorInstance => {
262
                reqIntercetorsIds.push(this.addInterceptor(interceptorInstance));
263
            });
264
265
            delete config.requestInterceptors;
266
        }
267
268
        if (config.responseInterceptors) {
269
            config.responseInterceptors.forEach(interceptorInstance => {
270
                respInterceptorsIds.push(this.addInterceptor(interceptorInstance));
271
            });
272
273
            delete config.responseInterceptors;
274
        }
275
        const response = await this.#axiosInstance.request(config);
276
277
        reqIntercetorsIds.forEach(id => this.removeInterceptor(id, 'request'));
278
        respInterceptorsIds.forEach(id => this.removeInterceptor(id, 'response'));
279
280
        return response as BEditaClientResponse;
281
    }
282
283
    /**
284
     * Send a GET request.
285
     *
286
     * @param url The endpoint URL path to invoke
287
     * @param config Request configuration
288
     */
289
    public get(url: string, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
290
        config = config || {}
291
        config.method = 'get';
292
        config.url = url;
293
294
        return this.request(config);
295
    }
296
297
    /**
298
     * Send a POST request.
299
     *
300
     * @param url The endpoint URL path to invoke
301
     * @param data Payload to send
302
     * @param config Request configuration
303
     */
304
    public post(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
305
        config = config || {}
306
        config.method = 'post';
307
        config.url = url;
308
        config.data = data || null;
309
310
        return this.request(config);
311
    }
312
313
    /**
314
     * Send a PATCH request.
315
     *
316
     * @param url The endpoint URL path to invoke
317
     * @param data Payload to send
318
     * @param config Request configuration
319
     */
320
    public patch(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
321
        config = config || {}
322
        config.method = 'patch';
323
        config.url = url;
324
        config.data = data || null;
325
326
        return this.request(config);
327
    }
328
329
    /**
330
     * Send a DELETE request.
331
     *
332
     * @param url The endpoint URL path to invoke
333
     * @param data Payload to send
334
     * @param config Request configuration
335
     */
336
    public delete(url: string, data?: any, config?: BEditaClientRequestConfig): Promise<BEditaClientResponse<any>> {
337
        config = config || {}
338
        config.method = 'delete';
339
        config.url = url;
340
        config.data = data || null;
341
342
        return this.request(config);
343
    }
344
345
    /**
346
     * Authenticate a user, saving in storage access and refresh token.
347
     *
348
     * @param username The username
349
     * @param password The password
350
     */
351
    public async authenticate(username: string, password: string): Promise<BEditaClientResponse<any>> {
352
        this.#storageService.clearTokens().remove('user');
353
        const data = { username, password };
354
        const response = await this.post('/auth', data)
355
        const tokens = response.data && response.data.meta || {};
356
        if (!tokens.jwt || !tokens.renew) {
357
            return Promise.reject('Something was wrong with response data.');
358
        }
359
        this.#storageService.accessToken = tokens.jwt;
360
        this.#storageService.refreshToken = tokens.renew;
361
362
        return response;
363
    }
364
365
    /**
366
     * Get the authenticated user and store it.
367
     * Format user data using `FormatUserInterceptor`.
368
     */
369
    public async getUserAuth(): Promise<BEditaClientResponse<any>> {
370
        const response = await this.get(
371
            '/auth/user',
372
            {
373
                responseInterceptors: [new FormatUserInterceptor(this)]
374
            }
375
        );
376
377
        this.#storageService.set('user', JSON.stringify(response.formattedData));
378
379
        return response;
380
    }
381
382
    /**
383
     * Renew access and refresh tokens.
384
     */
385
    public async renewTokens(): Promise<BEditaClientResponse<any>> {
386
        const refreshToken = this.#storageService.refreshToken;
387
        if (!refreshToken) {
388
            return Promise.reject('Missing refresh token.');
389
        }
390
391
        const config = {
392
            headers: {
393
                Authorization: `Bearer ${refreshToken}`,
394
            },
395
        };
396
397
        try {
398
            const response = await this.post('/auth', null, config);
399
            const tokens = response.data.meta || {};
400
            if (!tokens.jwt || !tokens.renew) {
401
                throw new Error('Something was wrong with response data.');
402
            }
403
            this.#storageService.accessToken = tokens.jwt;
404
            this.#storageService.refreshToken = tokens.renew;
405
406
            return response;
407
        } catch (error) {
408
            this.#storageService.clearTokens().remove('user');
409
            throw error;
410
        }
411
    }
412
413
    /**
414
     * Save a resource.
415
     * If data contains `id` then it create new one resource
416
     * else it update existing resource.
417
     *
418
     * @param type The resource type
419
     * @param data The data to save
420
     */
421
    public async save(type: string, data: {[s: string]: any}): Promise<BEditaClientResponse> {
422
        if (!type) {
423
            throw new Error('Missing required type');
424
        }
425
426
        const body: {data: JsonApiResourceObject} = { data: {type} };
427
        const id: string|null = data?.id;
428
        if (id) {
429
            body.data.id = id;
430
        }
431
        delete data.id;
432
        body.data.attributes = data;
433
434
        if (id) {
435
            return await this.patch(`${type}/${id}`, body);
436
        }
437
438
        return await this.post(`${type}`, body);
439
    }
440
}
441