UNPKG

19.6 kBJavaScriptView Raw
1/*!
2 * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3 */
4
5import {EventEmitter} from 'events';
6import util from 'util';
7
8import {proxyEvents, retry, transferEvents} from '@webex/common';
9import {HttpStatusInterceptor, defaults as requestDefaults} from '@webex/http-core';
10import {defaultsDeep, get, isFunction, isString, last, merge, omit, set, unset} from 'lodash';
11import AmpState from 'ampersand-state';
12import uuid from 'uuid';
13
14import AuthInterceptor from './interceptors/auth';
15import NetworkTimingInterceptor from './interceptors/network-timing';
16import PayloadTransformerInterceptor from './interceptors/payload-transformer';
17import RedirectInterceptor from './interceptors/redirect';
18import RequestEventInterceptor from './interceptors/request-event';
19import RequestLoggerInterceptor from './interceptors/request-logger';
20import RequestTimingInterceptor from './interceptors/request-timing';
21import ResponseLoggerInterceptor from './interceptors/response-logger';
22import WebexHttpError from './lib/webex-http-error';
23import UserAgentInterceptor from './interceptors/user-agent';
24import WebexTrackingIdInterceptor from './interceptors/webex-tracking-id';
25import WebexUserAgentInterceptor from './interceptors/webex-user-agent';
26import RateLimitInterceptor from './interceptors/rate-limit';
27import EmbargoInterceptor from './interceptors/embargo';
28import DefaultOptionsInterceptor from './interceptors/default-options';
29import config from './config';
30import {makeWebexStore} from './lib/storage';
31import mixinWebexCorePlugins from './lib/webex-core-plugin-mixin';
32import mixinWebexInternalCorePlugins from './lib/webex-internal-core-plugin-mixin';
33import WebexInternalCore from './webex-internal-core';
34
35// TODO replace the Interceptor.create with Reflect.construct (
36// Interceptor.create exists because new was really hard to call on an array of
37// constructors)
38const interceptors = {
39 WebexTrackingIdInterceptor: WebexTrackingIdInterceptor.create,
40 RequestEventInterceptor: RequestEventInterceptor.create,
41 RateLimitInterceptor: RateLimitInterceptor.create,
42 /* eslint-disable no-extra-parens */
43 RequestLoggerInterceptor: (process.env.ENABLE_NETWORK_LOGGING || process.env.ENABLE_VERBOSE_NETWORK_LOGGING) ? RequestLoggerInterceptor.create : undefined,
44 ResponseLoggerInterceptor: (process.env.ENABLE_NETWORK_LOGGING || process.env.ENABLE_VERBOSE_NETWORK_LOGGING) ? ResponseLoggerInterceptor.create : undefined,
45 /* eslint-enable no-extra-parens */
46 RequestTimingInterceptor: RequestTimingInterceptor.create,
47 ServiceInterceptor: undefined,
48 UserAgentInterceptor: UserAgentInterceptor.create,
49 WebexUserAgentInterceptor: WebexUserAgentInterceptor.create,
50 AuthInterceptor: AuthInterceptor.create,
51 KmsDryErrorInterceptor: undefined,
52 PayloadTransformerInterceptor: PayloadTransformerInterceptor.create,
53 ConversationInterceptor: undefined,
54 RedirectInterceptor: RedirectInterceptor.create,
55 HttpStatusInterceptor() {
56 return HttpStatusInterceptor.create({
57 error: WebexHttpError
58 });
59 },
60 NetworkTimingInterceptor: NetworkTimingInterceptor.create,
61 EmbargoInterceptor: EmbargoInterceptor.create,
62 DefaultOptionsInterceptor: DefaultOptionsInterceptor.create
63};
64
65const preInterceptors = [
66 'ResponseLoggerInterceptor',
67 'RequestTimingInterceptor',
68 'RequestEventInterceptor',
69 'WebexTrackingIdInterceptor',
70 'RateLimitInterceptor'
71];
72
73const postInterceptors = [
74 'HttpStatusInterceptor',
75 'NetworkTimingInterceptor',
76 'EmbargoInterceptor',
77 'RequestLoggerInterceptor',
78 'RateLimitInterceptor'
79];
80
81/**
82 * @class
83 */
84const WebexCore = AmpState.extend({
85 version: PACKAGE_VERSION,
86
87 children: {
88 internal: WebexInternalCore
89 },
90
91 constructor(attrs = {}, options) {
92 if (typeof attrs === 'string') {
93 attrs = {
94 credentials: {
95 supertoken: {
96 // eslint-disable-next-line camelcase
97 access_token: attrs
98 }
99 }
100 };
101 }
102 else {
103 // Reminder: order is important here
104 [
105 'credentials.authorization',
106 'authorization',
107 'credentials.supertoken.supertoken',
108 'supertoken',
109 'access_token',
110 'credentials.authorization.supertoken'
111 ].forEach((path) => {
112 const val = get(attrs, path);
113
114 if (val) {
115 unset(attrs, path);
116 set(attrs, 'credentials.supertoken', val);
117 }
118 });
119
120 [
121 'credentials',
122 'credentials.authorization'
123 ]
124 .forEach((path) => {
125 const val = get(attrs, path);
126
127 if (typeof val === 'string') {
128 unset(attrs, path);
129 set(attrs, 'credentials.supertoken', val);
130 }
131 });
132
133 if (typeof get(attrs, 'credentials.access_token') === 'string') {
134 // Send access_token to get validated and corrected and then set it
135 set(attrs, 'credentials.access_token', this.bearerValidator(get(attrs, 'credentials.access_token').trim()));
136
137 set(attrs, 'credentials.supertoken', attrs.credentials);
138 }
139 }
140
141 return Reflect.apply(AmpState, this, [attrs, options]);
142 },
143
144 derived: {
145 boundedStorage: {
146 deps: [],
147 fn() {
148 return makeWebexStore('bounded', this);
149 }
150 },
151 unboundedStorage: {
152 deps: [],
153 fn() {
154 return makeWebexStore('unbounded', this);
155 }
156 },
157 ready: {
158 deps: ['loaded', 'internal.ready'],
159 fn() {
160 return this.loaded && Object.keys(this._children).reduce((ready, name) => ready && this[name] && this[name].ready !== false, true);
161 }
162 }
163 },
164
165 session: {
166 config: {
167 type: 'object'
168 },
169 /**
170 * When true, indicates that the initial load from the storage layer is
171 * complete
172 * @instance
173 * @memberof WebexCore
174 * @type {boolean}
175 */
176 loaded: {
177 default: false,
178 type: 'boolean'
179 },
180 request: {
181 setOnce: true,
182 // It's supposed to be a function, but that's not a type defined in
183 // Ampersand
184 type: 'any'
185 },
186 sessionId: {
187 setOnce: true,
188 type: 'string'
189 }
190 },
191
192 /**
193 * @instance
194 * @memberof WebexCore
195 * @param {[type]} args
196 * @returns {[type]}
197 */
198 refresh(...args) {
199 return this.credentials.refresh(...args);
200 },
201
202 /**
203 * Applies the directionally appropriate transforms to the specified object
204 * @param {string} direction
205 * @param {Object} object
206 * @returns {Promise}
207 */
208 transform(direction, object) {
209 const predicates = this.config.payloadTransformer.predicates.filter(
210 (p) => !p.direction || p.direction === direction
211 );
212 const ctx = {
213 webex: this
214 };
215
216 return Promise.all(predicates.map((p) => p.test(ctx, object)
217 .then((shouldTransform) => {
218 if (!shouldTransform) {
219 return undefined;
220 }
221
222 return p.extract(object)
223 // eslint-disable-next-line max-nested-callbacks
224 .then((target) => ({
225 name: p.name,
226 target
227 }));
228 })))
229 .then((data) => data
230 .filter((d) => Boolean(d))
231 // eslint-disable-next-line max-nested-callbacks
232 .reduce((promise, {name, target, alias}) => promise.then(() => {
233 if (alias) {
234 return this.applyNamedTransform(direction, alias, target);
235 }
236
237 return this.applyNamedTransform(direction, name, target);
238 }), Promise.resolve()))
239 .then(() => object);
240 },
241
242 /**
243 * Applies the directionally appropriate transform to the specified parameters
244 * @param {string} direction
245 * @param {Object} ctx
246 * @param {string} name
247 * @returns {Promise}
248 */
249 applyNamedTransform(direction, ctx, name, ...rest) {
250 if (isString(ctx)) {
251 rest.unshift(name);
252 name = ctx;
253 ctx = {
254 webex: this,
255 transform: (...args) => this.applyNamedTransform(direction, ctx, ...args)
256 };
257 }
258
259 const transforms = ctx.webex.config.payloadTransformer.transforms.filter(
260 (tx) => tx.name === name && (!tx.direction || tx.direction === direction)
261 );
262
263 // too many implicit returns on the same line is difficult to interpret
264 // eslint-disable-next-line arrow-body-style
265 return transforms.reduce((promise, tx) => promise.then(() => {
266 if (tx.alias) {
267 return ctx.transform(tx.alias, ...rest);
268 }
269
270 return Promise.resolve(tx.fn(ctx, ...rest));
271 }), Promise.resolve())
272 .then(() => last(rest));
273 },
274
275 /**
276 * @private
277 * @returns {Window}
278 */
279 getWindow() {
280 // eslint-disable-next-line
281 return window;
282 },
283
284 /**
285 * Initializer
286 *
287 * @emits WebexCore#loaded
288 * @emits WebexCore#ready
289 * @instance
290 * @memberof WebexCore
291 * @param {Object} attrs
292 * @returns {WebexCore}
293 */
294 initialize(attrs = {}) {
295 this.config = merge({}, config, attrs.config);
296
297
298 // There's some unfortunateness with the way {@link AmpersandState#children}
299 // get initialized. We'll fire the change:config event so that
300 // {@link WebexPlugin#initialize()} can use
301 // `this.listenToOnce(parent, 'change:config', () => {});` to act on config
302 // during initialization
303 this.trigger('change:config');
304
305 const onLoaded = () => {
306 if (this.loaded) {
307 /**
308 * Fires when all data has been loaded from the storage layer
309 * @event loaded
310 * @instance
311 * @memberof WebexCore
312 */
313 this.trigger('loaded');
314
315 this.stopListening(this, 'change:loaded', onLoaded);
316 }
317 };
318
319 // This needs to run on nextTick or we'll never be able to wire up listeners
320 process.nextTick(() => {
321 this.listenToAndRun(this, 'change:loaded', onLoaded);
322 });
323
324 const onReady = () => {
325 if (this.ready) {
326 /**
327 * Fires when all plugins have fully initialized
328 * @event ready
329 * @instance
330 * @memberof WebexCore
331 */
332 this.trigger('ready');
333
334 this.stopListening(this, 'change:ready', onReady);
335 }
336 };
337
338 // This needs to run on nextTick or we'll never be able to wire up listeners
339 process.nextTick(() => {
340 this.listenToAndRun(this, 'change:ready', onReady);
341 });
342
343 // Make nested events propagate in a consistent manner
344 Object.keys(this.constructor.prototype._children).forEach((key) => {
345 this.listenTo(this[key], 'change', (...args) => {
346 args.unshift(`change:${key}`);
347 this.trigger(...args);
348 });
349 });
350
351 const addInterceptor = (ints, key) => {
352 const interceptor = interceptors[key];
353
354 if (!isFunction(interceptor)) {
355 return ints;
356 }
357
358 ints.push(Reflect.apply(interceptor, this, []));
359
360 return ints;
361 };
362
363 let ints = [];
364
365 ints = preInterceptors.reduce(addInterceptor, ints);
366 ints = Object.keys(interceptors)
367 .filter((key) => !(preInterceptors.includes(key) || postInterceptors.includes(key)))
368 .reduce(addInterceptor, ints);
369 ints = postInterceptors.reduce(addInterceptor, ints);
370
371 this.request = requestDefaults({
372 json: true,
373 interceptors: ints
374 });
375
376 let sessionId = `${get(this, 'config.trackingIdPrefix', 'webex-js-sdk')}_${get(this, 'config.trackingIdBase', uuid.v4())}`;
377
378 if (get(this, 'config.trackingIdSuffix')) {
379 sessionId += `_${get(this, 'config.trackingIdSuffix')}`;
380 }
381
382 this.sessionId = sessionId;
383 },
384
385 /**
386 * setConfig
387 *
388 * Allows updating config
389 *
390 * @instance
391 * @memberof WebexCore
392 * @param {Object} newConfig
393 * @returns {null}
394 */
395 setConfig(newConfig = {}) {
396 this.config = merge({}, this.config, newConfig);
397 },
398
399 /**
400 *
401 * Check if access token is correctly formated and correct if it's not
402 * Warn user if token string has errors in it
403 * @param {string} token
404 * @returns {string}
405 */
406 bearerValidator(token) {
407 if (token.includes('Bearer') && token.split(' ').length - 1 === 0) {
408 console.warn(
409 `Your access token does not have a space between 'Bearer' and the token, please add a space to it or replace it with this already fixed version:\n\n${token.replace('Bearer', 'Bearer ').replace(/\s+/g, ' ')}`
410 );
411 console.info("Tip: You don't need to add 'Bearer' to the access_token field. The token by itself is fine");
412
413 return token.replace('Bearer', 'Bearer ').replace(/\s+/g, ' ');
414 }
415 // Allow elseIf return
416 // eslint-disable-next-line no-else-return
417 else if (token.split(' ').length - 1 > 1) {
418 console.warn(
419 `Your access token has ${token.split(' ').length - 2} too many spaces, please use this format:\n\n${token.replace(/\s+/g, ' ')}`
420 );
421 console.info("Tip: You don't need to add 'Bearer' to the access_token field, the token by itself is fine");
422
423 return token.replace(/\s+/g, ' ');
424 }
425
426 return token.replace(/\s+/g, ' '); // Clean it anyway (just in case)
427 },
428
429 /**
430 * @instance
431 * @memberof WebexPlugin
432 * @param {number} depth
433 * @private
434 * @returns {Object}
435 */
436 inspect(depth) {
437 return util.inspect(omit(this.serialize({
438 props: true,
439 session: true,
440 derived: true
441 }), 'boundedStorage', 'unboundedStorage', 'request', 'config'), {depth});
442 },
443
444 /**
445 * Invokes all `onBeforeLogout` handlers in the scope of their plugin, clears
446 * all stores, and revokes the access token
447 * Note: If you're using the sdk in a server environment, you may be more
448 * interested in {@link `webex.internal.mercury.disconnect()`| Mercury#disconnect()}
449 * and {@link `webex.internal.device.unregister()`|Device#unregister()}
450 * or {@link `webex.phone.unregister()`|Phone#unregister}
451 * @instance
452 * @memberof WebexCore
453 * @param {Object} options Passed as the first argument to all
454 * `onBeforeLogout` handlers
455 * @returns {Promise}
456 */
457 logout(options, ...rest) {
458 // prefer the refresh token, but for clients that don't have one, fallback
459 // to the access token
460 const token = this.credentials.supertoken && (this.credentials.supertoken.refresh_token || this.credentials.supertoken.access_token);
461
462 options = Object.assign({token}, options);
463
464 // onBeforeLogout should be executed in the opposite order in which handlers
465 // were registered. In that way, wdm unregister() will be above mercury
466 // disconnect(), but disconnect() will execute first.
467 // eslint-disable-next-line arrow-body-style
468 return this.config.onBeforeLogout.reverse().reduce((promise, {plugin, fn}) => promise.then(() => {
469 return Promise.resolve(Reflect.apply(fn, this[plugin] || this.internal[plugin], [options, ...rest]))
470 // eslint-disable-next-line max-nested-callbacks
471 .catch((err) => {
472 this.logger.warn(`onBeforeLogout from plugin ${plugin}: failed`, err);
473 });
474 }), Promise.resolve())
475 .then(() => Promise.all([
476 this.boundedStorage.clear(),
477 this.unboundedStorage.clear()
478 ]))
479 .then(() => this.credentials.invalidate(...rest))
480 .then(() => this.authorization && this.authorization.logout && this.authorization.logout(options, ...rest))
481 .then(() => this.trigger('client:logout'));
482 },
483
484 /**
485 * General purpose wrapper to submit metrics via the metrics plugin (if the
486 * metrics plugin is installed)
487 * @instance
488 * @memberof WebexCore
489 * @returns {Promise}
490 */
491 measure(...args) {
492 if (this.metrics) {
493 return this.metrics.sendUnstructured(...args);
494 }
495
496 return Promise.resolve();
497 },
498
499 async upload(options) {
500 let s3UploadsSupported;
501
502 if (this.internal && this.internal.feature) {
503 s3UploadsSupported = await this.internal.feature.getFeature('developer', 'files-s3');
504 }
505 if (!options.file) {
506 return Promise.reject(new Error('`options.file` is required'));
507 }
508
509 options.phases = options.phases || {};
510 options.phases.initialize = options.phases.initialize || {};
511 options.phases.upload = options.phases.upload || {};
512 options.phases.finalize = options.phases.finalize || {};
513
514 defaultsDeep(options.phases.initialize, {
515 method: 'POST',
516 body: {
517 uploadProtocol: s3UploadsSupported ? 'content-length' : null
518 }
519 }, omit(options, 'file', 'phases'));
520
521 defaultsDeep(options.phases.upload, {
522 method: 'PUT',
523 json: false,
524 withCredentials: false,
525 body: options.file,
526 headers: {
527 'x-trans-id': uuid.v4(),
528 authorization: undefined
529 }
530 });
531
532 defaultsDeep(options.phases.finalize, {
533 method: 'POST'
534 }, omit(options, 'file', 'phases'));
535
536 const shunt = new EventEmitter();
537
538 const promise = this._uploadPhaseInitialize(options)
539 .then(() => {
540 const p = this._uploadPhaseUpload(options);
541
542 transferEvents('progress', p, shunt);
543
544 return p;
545 })
546 .then((...args) => this._uploadPhaseFinalize(options, ...args))
547 .then((res) => ({...res.body, ...res.headers}));
548
549 proxyEvents(shunt, promise);
550
551 return promise;
552 },
553
554 _uploadPhaseInitialize: function _uploadPhaseInitialize(options) {
555 this.logger.debug('client: initiating upload session');
556
557 return this.request(options.phases.initialize)
558 .then((...args) => this._uploadApplySession(options, ...args))
559 .then((res) => {
560 this.logger.debug('client: initiated upload session');
561
562 return res;
563 });
564 },
565
566 _uploadApplySession(options, res) {
567 const session = res.body;
568
569 ['upload', 'finalize'].reduce((opts, key) => {
570 opts[key] = Object.keys(opts[key]).reduce((phaseOptions, phaseKey) => {
571 if (phaseKey.startsWith('$')) {
572 phaseOptions[phaseKey.substr(1)] = phaseOptions[phaseKey](session);
573 Reflect.deleteProperty(phaseOptions, phaseKey);
574 }
575
576 return phaseOptions;
577 }, opts[key]);
578
579 return opts;
580 }, options.phases);
581 },
582
583 @retry
584 _uploadPhaseUpload(options) {
585 this.logger.debug('client: uploading file');
586
587 const promise = this.request(options.phases.upload)
588 .then((res) => {
589 this.logger.debug('client: uploaded file');
590
591 return res;
592 });
593
594 proxyEvents(options.phases.upload.upload, promise);
595
596 /* istanbul ignore else */
597 if (process.env.NODE_ENV === 'test') {
598 promise.on('progress', (event) => {
599 this.logger.info('upload progress', event.loaded, event.total);
600 });
601 }
602
603 return promise;
604 },
605
606 _uploadPhaseFinalize: function _uploadPhaseFinalize(options) {
607 this.logger.debug('client: finalizing upload session');
608
609 return this.request(options.phases.finalize)
610 .then((res) => {
611 this.logger.debug('client: finalized upload session');
612
613 return res;
614 });
615 }
616});
617
618WebexCore.version = PACKAGE_VERSION;
619
620mixinWebexInternalCorePlugins(WebexInternalCore, config, interceptors);
621mixinWebexCorePlugins(WebexCore, config, interceptors);
622
623export default WebexCore;
624
625/**
626 * @method registerPlugin
627 * @param {string} name
628 * @param {function} constructor
629 * @param {Object} options
630 * @param {Array<string>} options.proxies
631 * @param {Object} options.interceptors
632 * @returns {null}
633 */
634export function registerPlugin(name, constructor, options) {
635 WebexCore.registerPlugin(name, constructor, options);
636}
637
638/**
639 * Registers plugins used by internal products that do not talk to public APIs.
640 * @method registerInternalPlugin
641 * @param {string} name
642 * @param {function} constructor
643 * @param {Object} options
644 * @param {Object} options.interceptors
645 * @private
646 * @returns {null}
647 */
648export function registerInternalPlugin(name, constructor, options) {
649 WebexInternalCore.registerPlugin(name, constructor, options);
650}