UNPKG

destiny-api

Version:

Destiny API through bungie.net

537 lines (462 loc) 15.8 kB
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;