Source: connector.js

'use strict';

var request = require('request'),
  url = require('url'),
  _ = require('lodash'),
  querystring = require('querystring'),
  base = require('base-framework'),
  fs = require('fs'),
  path = require('path'),
  async = require('async'),
  Monologue = require('monologue.js')(_),
  FormData = require('form-data');

/**
 * @class Connection
 * @classdesc The Connection object: One instance for each email id. Has {@link external:Monologue|Monologue}
 * methods mixed in.
 * @param {Box} box - The Box object though which this connection is managed.
 * @param {string} email - The email account identifier to connect to.
 */
var Connection = base.createChild().addInstanceMethods(
  /** @lends Connection.prototype */
  {
    init: function (box, email) {
      this.email = email;
      this.csrf = Math.random().toString(36).slice(2);
      this.concurrency = 7;

      _.each(['host', 'port', 'log', 'client_id', 'client_secret'], function (key) {
        this[key] = box[key];
      }, this);

      this.queue = async.queue(function (task, cb) {
        var r = request(task.opts, function (err, res, body) {
          task.handler(err, res, body);
          cb();
        });
        if (task.form) {
          r._form = task.form;
        }
        if (task['content-length']) {
          r.setHeader('content-length', task['content-length']);
        }
      }, this.concurrency);

      return this;
    },

    /**
     * The returned URL should be provided to the end user when running in standalone mode.
     * @summary Get the authentication URL to manually navigate to.
     * @returns {string} The authentication URL.
     */
    getAuthURL: function () {
      if (this.auth_url) {
        return this.auth_url;
      }
      var self = this,
        destination = {
          protocol: 'https',
          host: 'www.box.com',
          pathname: '/api/oauth2/authorize',
          search: querystring.stringify({
            response_type: 'code',
            client_id: self.client_id,
            state: self.csrf,
            redirect_uri: 'http://' + self.host + ':' + self.port + '/authorize?id=' + self.email
          })
        };

      self.auth_url = url.format(destination);
      return self.auth_url;
    },

    /**
     * Authentication token object for a connection.
     * @typedef {Object} AuthTokens
     * @property {string} access_token - The Access Token.
     * @property {string} refresh_token - The Refresh Token.
     * @property {number} [expires_in] - Optional lifetime value of access token in seconds.
     * @property {Array} [restricted_to] - Optional (possibly) list of IP from which to grant access.
     * @property {string} [token_type] - Optional. Value should normally be 'bearer'.
     */

    /**
     * Do not call this method directly.
     * @summary Set the authentication tokens for this connection.
     * @private
     * @param {AuthTokens} tokens - The authentication tokens.
     * @fires Connection#"tokens.set"
     */
    _setTokens: function (tokens) {
      _.merge(this, tokens);
      /**
       * Fires when access tokens have been set on this connection. Could be triggered more than once,
       * so listeners must deregister after receiving the first event.
       * Preferably use the {@link Connection#ready} method.
       * @event Connection#"tokens.set"
       * @type {AuthTokens}
       * @see {@link Connection#ready}
       */
      this.emit('tokens.set');
    },

    /**
     * Do not  call this function directly.
     * @summary Set the authentication tokens for this connection.
     * @private
     */
    _revokeAccess: function () {
      delete this.access_token;
    },

    /**
     * Standard {@linkcode fields/limit/offset} options, used in several connection methods.
     * @external OptsFLO
     * @see {@link https://developers.box.com/docs/#folders-retrieve-a-folders-items}
     */

    /**
     * Headers to pass alongwith a request on a connection.
     * @typedef {Object} RequestHeaders
     * @property {string} [If-Match] - If-Match ETAG.
     * @property {string} [If-None-Match] - If-None-Match ETAG.
     * @see {@link https://developers.box.com/docs/#if-match}
     */

    /**
     * Options to configure the behaviour of the request itself.
     * @typedef {Object} RequestConfig
     * @property {number} [timeout] - The response timeout in milliseconds. Defaults to {@linkcode 120000}.
     * @property {number} [num_retries] - The number of retries to attempt before giving up. {@linkcode 0}
     * implies no retries. A negative number implies unlimited retries.
     * @property {number} [ebackoff] - Initial backoff interval in milliseconds. The request dispatcher will
     * wait for this interval before attempting a retry. The interval is doubled on each successive retry.
     * Defaults to {@linkcode 1000}.
     */

    /**
     * A Readable Stream.
     * @external Readable
     * @see {@link http://nodejs.org/api/stream.html#stream_class_stream_readable}
     */

    /**
     * Called after a request is performed on a connection.
     * @callback requestCallback
     * @param {Error} [error] - Any error that occurred.
     * @param {Object} body - The response body.
     */

    /**
     * Do not call this method directly.
     * Use one of the wrapper API methods instead.
     * @summary Perform an HTTP request on this connection.
     * @private
     * @param {(string | Array.<string>)} segments - The path segments to append to the API base URL.
     * @param {string} method - The HTTP verb to use for this request.
     * @param {requestCallback} callback - The callback to invoke (with possible errors) when the request returns.
     * @param {?Object} [query] - A map of query parameters.
     * @param {?Object} [payload] - The request payload.
     * @param {?external:Readable} [data] - Readable stream representing file data to be uploaded.
     * @param {?RequestHeaders} [headers] - Additional headers.
     * @param {external:Writable} [pipe] - Writable stream representing file data to be saved.
     * @param {?RequestConfig} [config] - Configure the request behaviour.
     */
    _request: function (segments, method, callback, query, payload, data, headers, pipe, config) {
      //Self-invocation with the same params should work transparently for the caller.
      var methods = ['POST', 'GET', 'DELETE', 'PUT', 'OPTIONS'];
      if (!_.contains(methods, method)) {
        throw new Error('Unsupported method: ' + method);
      }

      var self = this,
        url;

      config = _.merge({
        timeout: 120000,
        num_retries: 3,
        ebackoff: 1000
      }, config || {});

      if (_.isString(segments)) {
        url = segments;
      } else if (data) {
        url = 'https://upload.box.com/api/2.0/' + segments.join('/');
      } else {
        url = 'https://www.box.com/api/2.0/' + segments.join('/');
      }

      var opts = {
        url: url,
        method: method,
        headers: {
          Authorization: 'Bearer ' + self.access_token
        },
        timeout: config.timeout
      };

      if (data) {
        opts.json = true;
      } else {
        _.merge(opts, {
          json: (_.contains(['POST', 'PUT'], method)) ? payload : true,
          qs: query
        });
      }

      headers = headers || {};
      _.merge(opts.headers, headers);

      if (data) {
        var form = new FormData();

        form.append('filename', data);
        _.forIn(payload, function (value, key) {
          form.append(key, value);
        });

        form.getLength(function (err, length) {
          if (err) {
            return callback(err);
          }

          // var r = request(opts, _handler);
          // r._form = form;
          // r.setHeader('content-length', length);
          self.queue.push({
            opts: opts,
            handler: _handler,
            form: form,
            'content-length': length
          });
        });
      } else {
        if (pipe && method === 'GET') {
          opts.followRedirect = false;
        }
        // request(opts, _handler);
        self.queue.push({
          opts: opts,
          handler: _handler
        });
      }

      function _handler(err, res, body) {
        if (err) {
          if (config.num_retries === 0) {
            return callback(err);
          }

          var ebck = config.ebackoff;

          config.num_retries--;
          config.ebackoff *= 2;
          self.log.debug('Waiting %d milliseconds before retrying...', ebck);

          return _.delay(function () {
            self._request(segments, method, callback, query, payload, data, headers, pipe, config);
          }, ebck);
        }

        switch (res.statusCode) {
        case 200:
          if (pipe) {
            pipe.end(new Buffer(body, 'binary'));
          } else {
            callback(err, body);
          }
          break;

        case 202:
        case 429:
          var wait = res.headers['retry-after'] * 1000 + _.random(0, 10000);
          self.log.debug('Status: %d, Retry-After: %d', res.statusCode, res.headers['retry-after']);
          self.log.debug('Waiting %d milliseconds before retrying...', wait);
          _.delay(function () {
            self._request(segments, method, callback, query, payload, data, headers, pipe, config);
          }, wait);
          break;

        case 301:
          opts.url = res.headers.location;
          // request(opts, _handler);
          self.queue.push({
            opts: opts,
            handler: _handler
          });
          break;

        case 302:
          request.get(res.headers.location, callback).pipe(pipe);
          break;

        case 400:
        case 403:
        case 404:
        case 405:
        case 409:
        case 412:
          callback(new Error(JSON.stringify(body)));
          break;

        case 401:
          //First check if stale access tokens still exist, and only then refresh.
          //Prevents multiple refreshes.
          if (self.isAuthenticated()) {
            self.log.debug('Access token expired. Refreshing...');
            self._revokeAccess();
            var tokenParams = {
                client_id: self.client_id,
                client_secret: self.client_secret,
                grant_type: 'refresh_token',
                refresh_token: self.refresh_token
              },
              authUrl = 'https://www.box.com/api/oauth2/token';

            request.post({
              url: authUrl,
              form: tokenParams,
              json: true
            }, function (err, res, body) {
              if (err) {
                return callback(err);
              }
              if (res.statusCode === 200) {
                self.log.debug('New tokens received.');
                self._setTokens(body);
                self._request(segments, method, callback, query, payload, data, headers, pipe, config);
              } else {
                callback(new Error(JSON.stringify(body)));
              }
            });
          } else {
            self.ready(function () {
              self._request(segments, method, callback, query, payload, data, headers, pipe, config);
            });
          }
          break;

        default:
          callback(err, body);
        }
      }
    },

    /**
     * Wait for a connection to get ready.
     * @param {optionalErrorCallback} callback - The callback with an optional err argument,
     * to be invoked once ready.
     */
    ready: function (callback) {
      if (this.isAuthenticated()) {
        callback();
      } else {
        this.once('tokens.*', callback);
      }
    },

    /**
     * Determine id the connection has been authenticated.
     * @returns {boolean} True if authenticated.
     */
    isAuthenticated: function () {
      return !_.isEmpty(this.access_token);
    },

    /**
     * A connection starts with a default concurrency of {@linkcode 7}. Note that long-polling will consume a
     * request from the request pool, so it is generally not a good idea to set this to less than {@linkcode 2}.
     * @summary Set the maximum concurrent requests that this connection will process.
     * @param {number} concurrency - The number of concurrent requests.
     * @throws {RangeError} Concurrency must be an integer > {@linkcode 0}.
     */
    setConcurrency: function (concurrency) {
      if (concurrency < 1) {
        throw new RangeError('Concurrency must be an integer > 0');
      }
      this.queue.concurrency = Math.floor(concurrency);
    }
  });

//Load API Methods
(function loadAPI(mainpath) {
  var items = fs.readdirSync(mainpath);
  _.each(items, function (item) {
    var subpath = path.join(mainpath, item);
    var stats = fs.statSync(subpath);
    if (stats.isDirectory()) {
      loadAPI(subpath);
    } else if (stats.isFile() && item.match(/.*\.js$/)) {
      require(subpath)(Connection);
    }
  });
})(path.join(__dirname, 'api'));

/**
 * All {@link https://github.com/postaljs/monologue.js|Monologue} methods are mixed into the
 * {@link Connection} object.
 * @external Monologue
 * @see {@link https://github.com/postaljs/monologue.js}
 */
Monologue.mixin(Connection);

module.exports = Connection;
Copyright © 2014-2015 Aditya Mukhopadhyay
Documentation generated by JSDoc 3.2.2 on Sun Jul 27 2014 08:27:52 GMT+0530 (IST) using the DocStrap template.