1 |
|
2 |
|
3 |
|
4 |
|
5 | import {EventEmitter} from 'events';
|
6 | import util from 'util';
|
7 |
|
8 | import {proxyEvents, retry, transferEvents} from '@webex/common';
|
9 | import {HttpStatusInterceptor, defaults as requestDefaults} from '@webex/http-core';
|
10 | import {defaultsDeep, get, isFunction, isString, last, merge, omit, set, unset} from 'lodash';
|
11 | import AmpState from 'ampersand-state';
|
12 | import uuid from 'uuid';
|
13 |
|
14 | import AuthInterceptor from './interceptors/auth';
|
15 | import NetworkTimingInterceptor from './interceptors/network-timing';
|
16 | import PayloadTransformerInterceptor from './interceptors/payload-transformer';
|
17 | import RedirectInterceptor from './interceptors/redirect';
|
18 | import RequestEventInterceptor from './interceptors/request-event';
|
19 | import RequestLoggerInterceptor from './interceptors/request-logger';
|
20 | import RequestTimingInterceptor from './interceptors/request-timing';
|
21 | import ResponseLoggerInterceptor from './interceptors/response-logger';
|
22 | import WebexHttpError from './lib/webex-http-error';
|
23 | import UserAgentInterceptor from './interceptors/user-agent';
|
24 | import WebexTrackingIdInterceptor from './interceptors/webex-tracking-id';
|
25 | import WebexUserAgentInterceptor from './interceptors/webex-user-agent';
|
26 | import RateLimitInterceptor from './interceptors/rate-limit';
|
27 | import EmbargoInterceptor from './interceptors/embargo';
|
28 | import DefaultOptionsInterceptor from './interceptors/default-options';
|
29 | import config from './config';
|
30 | import {makeWebexStore} from './lib/storage';
|
31 | import mixinWebexCorePlugins from './lib/webex-core-plugin-mixin';
|
32 | import mixinWebexInternalCorePlugins from './lib/webex-internal-core-plugin-mixin';
|
33 | import WebexInternalCore from './webex-internal-core';
|
34 |
|
35 |
|
36 |
|
37 |
|
38 | const interceptors = {
|
39 | WebexTrackingIdInterceptor: WebexTrackingIdInterceptor.create,
|
40 | RequestEventInterceptor: RequestEventInterceptor.create,
|
41 | RateLimitInterceptor: RateLimitInterceptor.create,
|
42 |
|
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 |
|
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 |
|
65 | const preInterceptors = [
|
66 | 'ResponseLoggerInterceptor',
|
67 | 'RequestTimingInterceptor',
|
68 | 'RequestEventInterceptor',
|
69 | 'WebexTrackingIdInterceptor',
|
70 | 'RateLimitInterceptor'
|
71 | ];
|
72 |
|
73 | const postInterceptors = [
|
74 | 'HttpStatusInterceptor',
|
75 | 'NetworkTimingInterceptor',
|
76 | 'EmbargoInterceptor',
|
77 | 'RequestLoggerInterceptor',
|
78 | 'RateLimitInterceptor'
|
79 | ];
|
80 |
|
81 |
|
82 |
|
83 |
|
84 | const 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 |
|
97 | access_token: attrs
|
98 | }
|
99 | }
|
100 | };
|
101 | }
|
102 | else {
|
103 |
|
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 |
|
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 |
|
171 |
|
172 |
|
173 |
|
174 |
|
175 |
|
176 | loaded: {
|
177 | default: false,
|
178 | type: 'boolean'
|
179 | },
|
180 | request: {
|
181 | setOnce: true,
|
182 |
|
183 |
|
184 | type: 'any'
|
185 | },
|
186 | sessionId: {
|
187 | setOnce: true,
|
188 | type: 'string'
|
189 | }
|
190 | },
|
191 |
|
192 | |
193 |
|
194 |
|
195 |
|
196 |
|
197 |
|
198 | refresh(...args) {
|
199 | return this.credentials.refresh(...args);
|
200 | },
|
201 |
|
202 | |
203 |
|
204 |
|
205 |
|
206 |
|
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 |
|
224 | .then((target) => ({
|
225 | name: p.name,
|
226 | target
|
227 | }));
|
228 | })))
|
229 | .then((data) => data
|
230 | .filter((d) => Boolean(d))
|
231 |
|
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 |
|
244 |
|
245 |
|
246 |
|
247 |
|
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 |
|
264 |
|
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 |
|
277 |
|
278 |
|
279 | getWindow() {
|
280 |
|
281 | return window;
|
282 | },
|
283 |
|
284 | |
285 |
|
286 |
|
287 |
|
288 |
|
289 |
|
290 |
|
291 |
|
292 |
|
293 |
|
294 | initialize(attrs = {}) {
|
295 | this.config = merge({}, config, attrs.config);
|
296 |
|
297 |
|
298 |
|
299 |
|
300 |
|
301 |
|
302 |
|
303 | this.trigger('change:config');
|
304 |
|
305 | const onLoaded = () => {
|
306 | if (this.loaded) {
|
307 | |
308 |
|
309 |
|
310 |
|
311 |
|
312 |
|
313 | this.trigger('loaded');
|
314 |
|
315 | this.stopListening(this, 'change:loaded', onLoaded);
|
316 | }
|
317 | };
|
318 |
|
319 |
|
320 | process.nextTick(() => {
|
321 | this.listenToAndRun(this, 'change:loaded', onLoaded);
|
322 | });
|
323 |
|
324 | const onReady = () => {
|
325 | if (this.ready) {
|
326 | |
327 |
|
328 |
|
329 |
|
330 |
|
331 |
|
332 | this.trigger('ready');
|
333 |
|
334 | this.stopListening(this, 'change:ready', onReady);
|
335 | }
|
336 | };
|
337 |
|
338 |
|
339 | process.nextTick(() => {
|
340 | this.listenToAndRun(this, 'change:ready', onReady);
|
341 | });
|
342 |
|
343 |
|
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 |
|
387 |
|
388 |
|
389 |
|
390 |
|
391 |
|
392 |
|
393 |
|
394 |
|
395 | setConfig(newConfig = {}) {
|
396 | this.config = merge({}, this.config, newConfig);
|
397 | },
|
398 |
|
399 | |
400 |
|
401 |
|
402 |
|
403 |
|
404 |
|
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 |
|
416 |
|
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, ' ');
|
427 | },
|
428 |
|
429 | |
430 |
|
431 |
|
432 |
|
433 |
|
434 |
|
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 |
|
446 |
|
447 |
|
448 |
|
449 |
|
450 |
|
451 |
|
452 |
|
453 |
|
454 |
|
455 |
|
456 |
|
457 | logout(options, ...rest) {
|
458 |
|
459 |
|
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 |
|
465 |
|
466 |
|
467 |
|
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 |
|
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 |
|
486 |
|
487 |
|
488 |
|
489 |
|
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 |
|
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 |
|
618 | WebexCore.version = PACKAGE_VERSION;
|
619 |
|
620 | mixinWebexInternalCorePlugins(WebexInternalCore, config, interceptors);
|
621 | mixinWebexCorePlugins(WebexCore, config, interceptors);
|
622 |
|
623 | export default WebexCore;
|
624 |
|
625 |
|
626 |
|
627 |
|
628 |
|
629 |
|
630 |
|
631 |
|
632 |
|
633 |
|
634 | export 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 | */
|
648 | export function registerInternalPlugin(name, constructor, options) {
|
649 | WebexInternalCore.registerPlugin(name, constructor, options);
|
650 | }
|