Completed
Push — main ( 6d480a...457c6f )
by Stefano
18s queued 12s
created

BEditaApiClient.clientCredentials   A

Complexity

Conditions 1

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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