'use strict';
var libVersion = require('../package.json').version,
request = require('request'),
throttledRequestLib = require('throttled-request');
/**
* Creates instance of {@link RedditApi}.
* @class
* @classdesc Reddit API Controller which facilitates authentication and API endpoint queries.
* @param {Object} options - Options object
* @param {String} options.app_id - Reddit app ID
* @param {String} options.app_secret - Reddit app secret
* @param {String} [options.redirect_uri=null] - Reddit app redirect URI
* @param {String} [options.user_agent=reddit-oauth/x.y.z by aihamh] - HTTP user agent header sent to Reddit for each request
* @param {String} [options.access_token=null] - OAuth access token
* @param {String} [options.refresh_token=null] - OAuth refresh token
* @param {Number} [options.request_buffer=2000] - Time in milliseconds to buffer for each request
*/
function RedditApi(options) {
if (!options || typeof options !== 'object') {
throw 'Invalid options: ' + options;
}
if (typeof options.app_id !== 'string' || options.app_id.length < 1) {
throw 'Invalid app ID: ' + options.app_id;
}
if (typeof options.app_secret !== 'string' || options.app_secret.length < 1) {
throw 'Invalid app secret: ' + options.app_secret;
}
/**
* Reddit app ID
* @type {String}
*/
this.app_id = options.app_id;
/**
* Reddit app secret
* @type {String}
*/
this.app_secret = options.app_secret;
/**
* Reddit app redirect URI
* @type {?String}
* @default null
*/
this.redirect_uri = options.redirect_uri || null;
/**
* HTTP user agent header sent to Reddit for each request
* @type {String}
* @default reddit-oauth/x.y.z by aihamh
*/
this.user_agent = options.user_agent || 'reddit-oauth/' + libVersion + ' by aihamh';
/**
* OAuth access token
* @type {?String}
* @default null
*/
this.access_token = options.access_token || null;
/**
* OAuth refresh token
* @type {?String}
* @default null
*/
this.refresh_token = options.refresh_token || null;
/**
* Throttled request function
* Refer to: https://www.npmjs.com/package/throttled-request
* @type {Function}
*/
this.throttled_request = throttledRequestLib(request);
this.throttled_request.configure({
requests: 1,
milliseconds: options.request_buffer || 2000
});
}
RedditApi.prototype = {
constructor: RedditApi,
/**
* Checks if user is authenticated based on the presence of an access token.
* @return {Boolean}
*/
isAuthed: function RedditApi__isAuthed() {
return typeof this.access_token === 'string' &&
this.access_token.length > 0;
},
/**
* @callback RedditApi~ApiRequestCallback
* @param {?Object} error
* @param {Object} incomingMessage
* @param {String|Buffer|Object} responseBody
*/
/**
* @callback RedditApi~ApiListingRequestCallback
* @param {?Object} error
* @param {Object} incomingMessage
* @param {String|Buffer|Object} responseBody
* @param {?Function} next - Invoke to retrieve the next page in the listing, until next equals null
*/
/**
* @callback RedditApi~ApiTokenCallback
* @param {Boolean} success
*/
/**
* Create new API request to specified API endpoint with custom options and callback to be invoked on request completion. If authentication error occurs, is_refreshing_token is false and a refresh token is currently defined, then it will automatically attempt to retrieve a new access token then try again.
* @param {String} path - API endpoint path
* @param {external:Request~Options} [options={}] - Request options
* @param {String} [options.method=GET] - HTTP method
* @param {String} [options.url=https://(oauth|ssl).reddit.com/:path] - Request URL. ssl subdomain used for authentication; oauth for authenticated queries
* @param {Object} [options.headers={}] - HTTP headers
* @param {String} [options.headers.User-Agent] - User agent
* @param {String} [options.headers.Authorization] - Bearer token if available
* @param {RedditApi~ApiRequestCallback} callback - Callback function
* @param {Boolean} [is_refreshing_token=false] - If false, will attempt to refresh tokens then retry request
*/
request: function RedditApi__request(path, options, callback, is_refreshing_token) {
if (!options) {
options = {};
}
if (!options.headers) {
options.headers = {};
}
options.headers['User-Agent'] = this.user_agent;
if (this.isAuthed()) {
options.headers['Authorization'] = 'bearer ' + this.access_token;
}
if (!options.url) {
var subdomain = this.isAuthed() ? 'oauth' : 'ssl';
options.url = 'https://' + subdomain + '.reddit.com' + path;
}
if (!options.method) {
options.method = 'GET';
}
this.throttled_request(options, (function (api) {
return function (error, response, body) {
if (!error && response.statusCode === 200) {
try {
response.jsonData = JSON.parse(body);
} catch (e) {
error = e;
}
} else if (!is_refreshing_token && response.statusCode === 401 && api.refresh_token) {
api.refreshToken(function (success) {
if (success) {
api.request(path, options, callback);
} else {
callback.call(api, error, response, data);
}
});
return;
} else {
console.log(
'reddit-oauth Error:', error,
', Status code:', response.statusCode,
', Status message:', response.statusMessage
);
}
callback.call(api, error, response, body);
};
})(this));
},
/**
* Authenticate with username and password
* @param {String} username - Reddit username
* @param {String} password - Reddit password
* @param {RedditApi~ApiRequestCallback} callback - Request callback
*/
passAuth: function RedditApi__passAuth(username, password, callback) {
this.access_token = null;
this.refresh_token = null;
this.request('/api/v1/access_token', {
method: 'POST',
form: {
grant_type: 'password',
username: username,
password: password
},
auth: {
username: this.app_id,
password: this.app_secret
}
}, function (error, response, body) {
var success = !error &&
typeof response.jsonData === 'object' &&
typeof response.jsonData.access_token === 'string' &&
response.jsonData.access_token.length > 0;
if (success) {
this.access_token = response.jsonData.access_token;
}
if (callback) {
callback(success);
}
});
},
/**
* Get OAuth authorization URL for specific scope
* @param {String} state - An arbitrary string that is checked when user returns with the code
* @param {String|Array.<String>} scope - Array or comma separated list of scopes to request from user
* @return {String} URL to send user's browser to
*/
oAuthUrl: function RedditApi__oAuthUrl(state, scope) {
if (Array.isArray(scope)) {
scope = scope.join(',');
}
if (typeof scope !== 'string') {
throw 'Invalid scope: ' + scope;
}
var url = 'https://ssl.reddit.com/api/v1/authorize' +
'?client_id=' + encodeURIComponent(this.app_id) +
'&response_type=code' +
'&state=' + encodeURIComponent(state) +
'&redirect_uri=' + encodeURIComponent(this.redirect_uri || '') +
'&duration=permanent' +
'&scope=' + encodeURIComponent(scope);
return url;
},
/**
* Upon user returning from authorization URL, use supplied code to request access and fresh tokens.
* @param {String} state - The same arbitrary string that was used in {@link RedditApi#oAuthUrl}
* @param {Object} query - Key/value pairs from HTTP query string constructed by Reddit
* @param {String} query.state - Should be the string passed into {@link RedditApi#oAuthUrl}
* @param {String} query.code - A one time use token provided by Reddit to be exchanged for access and refresh tokens
* @param {RedditApi~ApiTokenCallback} callback - Callback function to invoke after tokens are retrieved
*/
oAuthTokens: function RedditApi__oAuthTokens(state, query, callback) {
if (query.state !== state || !query.code) {
callback(false);
return;
}
this.access_token = null;
this.refresh_token = null;
this.request('/api/v1/access_token', {
method: 'POST',
form: {
grant_type: 'authorization_code',
code: query.code,
redirect_uri: this.redirect_uri || ''
},
auth: {
username: this.app_id,
password: this.app_secret
}
}, function (error, response, body) {
var success = !error &&
typeof response.jsonData === 'object' &&
typeof response.jsonData.access_token === 'string' &&
typeof response.jsonData.refresh_token === 'string' &&
response.jsonData.access_token.length > 0 &&
response.jsonData.refresh_token.length > 0;
if (success) {
this.access_token = response.jsonData.access_token;
this.refresh_token = response.jsonData.refresh_token;
}
if (callback) {
callback(success);
}
});
},
/**
* Request a new access token using the existing refresh token.
* @param {RedditApi~ApiTokenCallback} callback - Callback function to invoke after the access token is retrieved
*/
refreshToken: function RedditApi__refreshToken(callback) {
this.access_token = null;
this.request('/api/v1/access_token', {
method: 'POST',
form: {
grant_type: 'refresh_token',
refresh_token: this.refresh_token
},
auth: {
username: this.app_id,
password: this.app_secret
}
}, function (error, response, body) {
var success = !error &&
typeof response.jsonData === 'object' &&
typeof response.jsonData.access_token === 'string' &&
response.jsonData.access_token.length > 0;
if (success) {
this.access_token = response.jsonData.access_token;
}
if (callback) {
callback(!error);
}
}, true);
},
/**
* Execute an authenticated GET request to the specified API endpoint.
* @param {String} path - API endpoint path
* @param {Object} params - Key/value pairs to send as the request query string
* @param {RedditApi~ApiRequestCallback} callback - Callback function
*/
get: function RedditApi__get(path, params, callback) {
var options = null;
if (params) {
for (var key in params) {
if (params.hasOwnProperty(key)) {
if (!options) options = {};
options.form = params;
break;
}
}
}
this.request(path, options, callback);
},
/**
* Execute an authenticated POST request to the specified API endpoint.
* @param {String} path - API endpoint path
* @param {Object} params - Key/value pairs to send as the request POST body
* @param {RedditApi~ApiRequestCallback} callback - Callback function
*/
post: function RedditApi__post(path, params, callback) {
var options = {
method: 'POST'
};
if (params) {
for (var key in params) {
if (params.hasOwnProperty(key)) {
options.form = params;
break;
}
}
}
this.request(path, options, callback);
},
/**
* Request a page of values from the specified listing endpoint. Use the additional 'next' callback argument to request the next page, repeatedly until 'next' equals null.
* @param {String} path
* @param {Object} params
* @param {RedditApi~ApiListingRequestCallback} callback - Invoke the next callback to retrieve the next page of the list
*/
getListing: function RedditApi__getListing(path, params, callback, after, count) {
if (!count) {
count = 0;
}
var fullPath = path;
if (after) {
fullPath += '?after=' + encodeURIComponent(after) + '&count=' + encodeURIComponent(count);
}
this.get(fullPath, params, (function (reddit) {
return function (error, response, body) {
if (error || response.statusCode !== 200) {
callback(error, response, body);
return;
}
var nextAfter = response.jsonData.data.after;
var nextCount = count + response.jsonData.data.children.length;
var next = nextAfter === null ? null : function () {
reddit.getListing(path, params, callback, nextAfter, nextCount);
};
if (callback) {
callback(error, response, body, next);
}
};
})(this));
}
};
module.exports = RedditApi;