-
Notifications
You must be signed in to change notification settings - Fork 2
/
index.js
536 lines (462 loc) · 15.4 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
import EventEmitter from 'eventemitter3';
import modification from 'modification';
import diagnostics from 'diagnostics';
import series from 'async/series';
import TickTock from 'tick-tock';
import failure from 'failure';
import Queue from 'queueback';
import URL from 'url-parse';
import once from 'one-time';
import prop from 'propget';
import emits from 'emits';
//
// Import our actual API endpoints.
//
import CharacterEndpoint from './endpoints/character';
import UserEndpoint from './endpoints/user';
//
// Import various of models.
//
import { Characters } from './models';
//
// Setup our debug utility.
//
const debug = diagnostics('destiny-api');
/**
* Destiny API interactions.
*
* Options:
*
* - api: Location of the API server that we're requesting.
* - platform: Platform (console) that we're using.
* - username: Username of your account.
* - timeout: Maximum request timeout.
* - definitions: Include definitions.
* - language: Language the API should return.
*
* @constructor
* @param {Bungie} bungie The bungie-auth instance.
* @param {Object} options Configuration.
* @api public
*/
export default class Destiny extends EventEmitter {
constructor(bungie, options = {}) {
super();
//
// Spice up the EventEmitter API with some addition useful methods.
//
this.change = modification('changed');
this.emits = emits;
this.api = 'https://www.bungie.net/Platform/'; // URL of the API server.
this.definitions = true; // Fetch definition info from API.
this.timeout = 30000; // API timeout.
this.language = 'en'; // Language.
this.platform = ''; // Console that is used.
this.username = ''; // Bungie username.
this.key = ''; // Bungie API key.
this.id = ''; // Bungie membership id.
this.change(options);
//
// These properties should NOT be overridden by the supplied options object.
//
this.bungie = bungie;
this.queue = new Queue();
this.timers = new TickTock();
this.readystate = Destiny.CLOSED;
this.characters = new Characters(this);
this.XHR = options.XHR || global.XMLHttpRequest;
this.initialize();
}
/**
* Initialize all the things.
*
* @api private
*/
initialize() {
debug('initializing API');
this.on('refresh', function reset(hard) {
this.characters.destroy();
this.change({
characters: new Characters(this),
platform: '',
username: '',
id: ''
});
});
//
// If there is an error we want to completely nuke all information.
//
this.on('error', this.emits('refresh', true));
this.on('error', (err) => {
debug('received an error %s', err.message, err.stack);
});
this.refresh();
}
/**
* Refresh all our chars and internal settings.
*
* @api private
*/
refresh() {
debug('refreshing our internals');
this.change({ readystate: Destiny.LOADING }).emit('refresh');
//
// We need to pre-gather all the information from the Bungie API.
//
series({
//
// Phase 1: Get the platform and username from the API.
//
user: (next) => {
debug('fetching user information');
this.user.get(next);
},
//
// Phase 2: Now that we've successfully received our user information
// we're ready to process API calls so we set our readyState to complete.
//
readystate: (next) => {
debug('searching for membership id');
this.user.current((err, data) => {
if (err) return next(err);
const accounts = data.destinyAccounts;
if (!Array.isArray(accounts) || !accounts.length) {
return next(failure('No active bungie/destiny account found'));
}
accounts.forEach((account) => {
if (account.userInfo.membershipType !== this.console()) return;
this.change({
id: account.userInfo.membershipId,
readystate: Destiny.COMPLETE
});
});
next();
});
},
//
// Phase 3: Get all characters for the given membership id.
//
account: (next) => {
debug('retrieving all chars for the membership id');
this.user.account(this.platform, this.id, (err, data) => {
if (err) return next(err);
this.characters.set(data);
next();
});
}
}, (err) => {
if (err) return this.emit('error', failure(err, {
reason: 'Failed to retrieve account information from the Bungie API',
action: 'login'
}));
//
// Flush all possible queued requests as we got all the information we
// desire and require in order to make requests.
//
this.emit('refreshed');
});
return this;
}
/**
* Execute the function when the instance is loaded.
*
* @param {Function} fn
* @returns {Destiny}
* @public
*/
go(fn) {
if (this.readystate !== Destiny.COMPLETE) {
return this.once('refreshed', fn);
}
fn();
return this;
}
/**
* Send a request over the API.
*
* @param {Object} options The request options.
* @param {Function} next Completion callback.
* @returns {Destiny}
* @api public
*/
send(options, next) {
//
// Check if we're allowed to make these http requests yet or if they require
// login or additional account information.
//
if (!options.bypass && this.readystate !== Destiny.COMPLETE) {
debug('queue api call for %s, readyState is not yet complete', options.url);
return this.once('refreshed', function refreshed(err) {
if (err) return next(err);
//
// Re-call the `send` method so we can process this outgoing HTTP request
// as all information has been gathered from the required API endpoints.
//
this.send(options, next);
});
}
//
// Setup the XHR request with the correct formatted URL.
//
const using = Object.assign({ method: 'GET' }, options);
const url = options.url;
//
// Small but really important optimization: For GET requests the last thing
// we want to do is to make API calls that we've just send and are being
// processed as we speak. We have no idea where the consumer is making API
// calls from so it can be that they are asking for the same data from
// multiple locations in their code. We want to group these API requests.
//
const method = using.method;
const href = url.href;
//
// We want to have better control on how time-out's are handled so we've
// added our own setTimeout handlers. In order to prevent duplicate
// execution and be able to clear the timer we're wrapping the supplied
// callback.
//
const fn = once((...args) => {
this.timers.clear(href);
//
// BUG: We have this bug where using ...arguments for spreading will
// actually somehow get the incorrect arguments, it takes the arguments of
// the parent scope instead of the one of this function. So we have to
// spread it as an other variable name, which we'll spread again on the
// next function.. So changing ...args to ...arguments breaks this.
//
next(...args);
});
if (this.queue.add(method, href, fn)) {
return debug('request already queued, ignoring '+ href);
}
const xhr = new this.XHR();
xhr.open(method, href, true);
xhr.onerror = () => {
debug('Received an error event on the XHR instance', xhr.statusText, xhr.responseText);
this.queue.run(method, href, failure(xhr.statusText || 'Unknown error occured while making an API request'));
};
xhr.onload = () => {
let data = xhr.response || xhr.responseText;
if (xhr.status !== 200) {
debug('Received an invalid status code from the Bungie server', data);
return this.queue.run(method, href, failure('There seems to be problem with the Bungie API', {
code: xhr.status,
action: 'retry',
text: xhr.text,
using: using,
body: ''
}));
}
try { data = JSON.parse(data); }
catch (e) {
debug('Unable to parse response from Bungie API server', data);
return this.queue.run(method, href, failure('Unable to parse the JSON response from the Bungie API', {
code: xhr.status,
text: xhr.text,
action: 'rety',
using: using,
body: data
}));
}
//
// Handle API based errors. It seems that error code 1 is usually returned
// for valid requests while an ErrorCode of 0 was expected to be save we're
// going to assume that 0 and 1 are both valid values.
//
if (data.ErrorCode > 1) {
debug('we received an error code (%s) from the bungie api for %s', data.ErrorCode, href);
//
// We've reached the throttle limit, so we should defer the request until
// we're allowed to request again.
//
if (data.ErrorCode === 36) {
debug('reached throttle limit, rescheduling API call', href);
this.queue.remove(method, href, fn);
this.timers.clear(href);
return setTimeout(send.bind(this, options, next), 1000 * data.ThrottleSeconds);
}
//
// At this point we don't really know what kind of error we received so we
// should fail hard and return a new error object.
//
debug('received an error from the api: %s', data.Message, href);
return this.queue.run(method, href, failure(data.Message, data));
}
//
// Check if we need filter the data down using our filter property.
//
if (!using.filter) return this.queue.run(method, href, undefined, data.Response);
this.queue.run(method, href, undefined, prop(data.Response, using.filter));
};
/**
* Send the actual HTTP request as everything is setup as intended.
*
* @private
*/
const send = (key) => {
xhr.setRequestHeader('X-API-Key', key || this.key);
xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
debug('send API request to %s', href);
//
// Not all XHR implementations have timeout handling build in. So we have
// to implement this our selfs. So in this case we're going to register
// and setTimeout for each href that we process and clear it once our
// callback has been called.
//
this.timers.setTimeout(href, () => {
debug('failed to process %s in a timely manner, canceling call.', href);
this.queue.run(method, href, failure('API requested timed out'));
try { xhr.abort(); } catch (e) {}
}, this.timeout);
xhr.send(using.body);
};
//
// Retrieve the token from our bungie-auth instance so we can access the
// secured API's
//
if (!this.bungie) send();
else this.bungie.token((err, payload) => {
if (err) {
debug('failed to retreive an accessToken: %s', err.message);
return this.queue.run(method, href, err);
}
//
// Setup the required.
//
xhr.setRequestHeader('Authorization', 'Bearer '+ payload.accessToken.value);
send(this.bungie.config.key);
});
return this;
}
/**
* Argument parser that helps with the generation of the API endpoint URL as
* well as handling of options can callbacks. We assume that the arg object
* contains the following keys:
*
* - endpoint: URL we need to hit
* - options: Optional object to configure the URL.
* - template: Variables used to process the given URL.
* - fn: Completion callback for the URL.
*
* @param {Object} arg The arg object.
* @returns {Object} Callback and resulting URL
* @api public
*/
args(arg) {
let { endpoint, options, callback, template } = arg;
//
// Support multiple forms of API structuring. For complex URL's it might
// make sense to chop them up in Array's so we'lll merge them back here.
//
endpoint = Array.isArray(endpoint) ? endpoint.join('/') : endpoint;
//
// We want to make sure that certain information is always filled in with
// the information of the user we've authenticated with.
//
template = Object.assign({
displayName: this.username,
destinyMembershipId: this.id,
membershipType: this.console()
}, template);
//
// Process our options and callback to see if they we have any OPTIONAL
// options or if we need to fix our callback.
//
if (typeof options === 'function') {
callback = options;
options = {};
}
//
// Process the template variables to create a full API endpoint.
//
let api = this.api + endpoint;
for(let prop in template) {
api = api.replace(new RegExp('{'+ prop +'}','g'), template[prop]);
}
const url = new URL(api, true);
//
// Final check, we need to make sure that the pathname has a leading slash
// so we don't have to follow potential redirects as all documented API
// calls have the leading slash.
//
if (url.pathname.charAt(url.pathname.length - 1) !== '/') {
url.set('pathname', url.pathname + '/');
}
//
// Introduce query string params to the API, we'll leverage the build-in
// query string functionality of the URL instance to transform our object in
// something human readable.
//
const query = {};
if (this.language) query.lc = this.language;
if (this.definitions === true) query.definitions = true;
//
// Process all available options.
//
if (options.summary) url.set('pathname', url.pathname + 'Summary/');
if (options.mode) query.mode = options.mode;
url.set('query', query);
return {
fn: callback,
url: url,
};
}
/**
* Transform the given platform in to the correct console type that is required
* by the Bungie API.
*
* @param {Number|String} platform Console name.
* @param {Boolean} apiname Return the API name instead.
* @returns {Number}
* @api public
*/
console(platform, apiname) {
if (!platform) platform = this.platform;
if ('number' !== typeof platform) {
if (~platform.toString().toLowerCase().indexOf('xb')) platform = 1;
else platform = 2;
}
if (!apiname) return platform;
return 'Tiger'+ (platform === 1 ? 'Xbox' : 'PSN');
}
}
/**
* Define an lazy load new API's.
*
* @param {String} name The name of the property
* @param {Function} fn The function that returns the new value
* @api private
*/
Destiny.define = function define(name, fn) {
const where = this.prototype;
Object.defineProperty(where, name, {
configurable: true,
get: function get() {
return Object.defineProperty(this, name, {
value: fn.call(this)
})[name];
},
set: function set(value) {
return Object.defineProperty(this, name, {
value: value
})[name];
}
});
};
//
// Add the lazy loaded API endpoint initialization.
//
[
{ name: 'user', Endpoint: UserEndpoint },
{ name: 'character', Endpoint: CharacterEndpoint }
].forEach(function each(spec) {
Destiny.define(spec.name, function defined() {
return new spec.Endpoint(this);
});
});
//
// Internal ready state..
//
Destiny.CLOSED = 1;
Destiny.LOADING = 2;
Destiny.COMPLETE = 3;