mangopay2-nodejs-sdk
Version:
Mangopay Node.js SDK
468 lines (394 loc) • 18.1 kB
JavaScript
var _ = require('underscore');
var Promise = require('promise');
var querystring = require('querystring');
var apiMethods = require('./apiMethods');
var apiModels = require('./models');
var apiServices = require('./services');
var axios = require('axios');
var FormData = require('form-data');
/**
*
* @param {string} username Mangopay username
* @param {string} apiKey Mangopay api key
* @returns {string} The header value base64 encoded for requesting the authentication key
* @private
*/
function _getBasicAuthHash(username, apiKey) {
return 'Basic ' + Buffer.from(username + ':' + apiKey).toString('base64');
}
var Api = function (config) {
var defaultConfig = require('./config');
config = this.config = _.extend({}, defaultConfig, config);
axios.defaults.baseURL = config.baseUrl;
// response timeout
axios.defaults.timeout = config.responseTimeout;
this.errorHandler = config.errorHandler;
this.rateLimits = [];
// Add default request configuration options
_.extend(this.requestOptions, {
// Path options are replacing the ${placeholders} from apiMethods
path: {
clientId: config.clientId,
apiVersion: config.apiVersion
},
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mangopay-SDK/' + require('../package.json').version + ` (Nodejs/${process.version})`
}
});
// Adds the services to API object
this._servicesLoader();
return this;
};
Api.prototype = {
config: require('./config'),
requestOptions: {
headers: {}
},
/**
* Checks if callback is a function or not, and passes the options
* @param {Object, Function} callback
* @param {Object} options
* @param {Object} params Additional params that extend options
*/
_getOptions: function (callback, options, params) {
var finalOptions = options || ((_.isObject(callback) && !_.isFunction(callback)) ? callback : {});
if (params) {
finalOptions = _.extend({}, finalOptions, params);
}
return finalOptions;
},
/**
* Main API resource request method
* @param {string} method Mangopay API method to be called
* @param {function} callback Callback function
* @param {object} options Hash of configuration to be passed to request
* @returns {object} request promise
*/
method: function (method, callback, options) {
options = this._getOptions(callback, options);
if (this.config.debugMode) {
this.config.logClass(method, options);
}
// If data has parse method, call it before passing data
if (options.data && options.data instanceof this.models.EntityBase) {
options.data = this.buildRequestData(options.data);
} else if (options.data && options.data.toJSON) {
options.data = options.data.toJSON();
}
var self = this;
// If there's no OAuthKey, request one
if (!this.requestOptions.headers.Authorization || this.isExpired()) {
return new Promise(function (resolve, reject) {
self.authorize()
.then(function () {
self.method.call(self, method, function (data, response) {
// Check if we have to wrap data into a model
if (_.isFunction(callback)) {
callback(data, response);
}
}, options)
.then(resolve)
.catch(reject);
})
.catch(reject);
});
}
// Extend default request options with custom ones, if present
var requestOptions = _.extend({}, this.requestOptions, options);
// If we have custom headers and they don't contain Content-Type, we have to prevent them to override Authentication header
if (options && options.headers && options.headers['Content-Type'] === undefined) {
_.extend(requestOptions.headers, this.requestOptions.headers, options.headers);
}
// Append the path placeholders in order to build the proper url for the request
if (options && options.path) {
_.extend(requestOptions.path, this.requestOptions.path, options.path);
}
if (this.config.ukHeaderFlag) {
requestOptions.headers['x-tentant-id'] = 'uk';
}
return this._requestApi(requestOptions, method, callback);
},
/**
* Request method for POST or PUT with multipart file. The file is expected to be in options.data.File, as a Buffer
* @param {string} method Mangopay API method to be called
* @param {function} callback Callback function
* @param {object} options Hash of configuration to be passed to request
* @returns {object} request promise
*/
multipartFormMethod: function (method, callback, options) {
options = this._getOptions(callback, options);
if (options.data === undefined || options.data.file === undefined || !Buffer.isBuffer(options.data.file)) {
throw new Error('options.data.file needs to be a Buffer');
}
if (options.data === undefined || options.data.fileName === undefined) {
throw new Error('options.data.fileName needs to be present');
}
if (this.config.debugMode) {
this.config.logClass(method, options);
}
var self = this;
// If there's no OAuthKey, request one
if (!this.requestOptions.headers.Authorization || this.isExpired()) {
return new Promise(function (resolve, reject) {
self.authorize()
.then(function () {
self.multipartFormMethod.call(self, method, function (data, response) {
// Check if we have to wrap data into a model
if (_.isFunction(callback)) {
callback(data, response);
}
}, options)
.then(resolve)
.catch(reject);
})
.catch(reject);
});
}
const form = new FormData();
form.append('file', options.data.file, {
filename: options.data.fileName
});
// Extend default request options with custom ones, if present
var requestOptions = _.extend({}, this.requestOptions, options);
requestOptions.data = form;
// Append the path placeholders in order to build the proper url for the request
if (options && options.path) {
_.extend(requestOptions.path, this.requestOptions.path, options.path);
}
if (this.config.ukHeaderFlag) {
requestOptions.headers['x-tentant-id'] = 'uk';
}
// keep only the multipart content-type header
if (requestOptions.headers['Content-Type'] === 'application/json') {
delete requestOptions.headers['Content-Type'];
}
requestOptions.headers = Object.assign(
{},
requestOptions.headers,
form.getHeaders()
);
return this._requestApi(requestOptions, method, callback);
},
_requestApi: function (requestOptions, method, callback) {
var self = this;
return new Promise(function (resolve, reject) {
if (!method) {
throw new Error('Method is required in order to perform a API server request');
}
var url;
var methodType;
// Allow direct requests: api.method('post', function(){ ... });
if (['post', 'get', 'put', 'delete'].indexOf(method) === -1) {
// predefined requests
url = apiMethods[method][0];
url = self.replaceUrl(url, requestOptions.path);
methodType = apiMethods[method][1];
} else {
// manual requests
if (!requestOptions.url) {
throw new Error('Url must be specified when doing a manual request');
}
url = requestOptions.url;
methodType = method;
}
if (!url) {
throw new Error('Url must be specified when doing a manual request');
}
var resolveWithFullResponse = requestOptions.resolveWithFullResponse || false;
var abortSignal = AbortSignal.timeout(self.config.connectionTimeout)
axios({
method: methodType,
url: url,
data: requestOptions.data,
headers: requestOptions.headers,
params: requestOptions.parameters,
signal: abortSignal
})
.then(function (response) {
var resolveArgument = (resolveWithFullResponse) ?
_.extend(response, {body: response.data}) : response.data;
setRateLimits(self, response.headers);
// For predefined requests:
// Add raw response data under 'data' property of the object and at the root of the object
if (['post', 'get', 'put'].indexOf(method) === -1) {
_.extend(requestOptions.data, response.data, {data: response.data});
// Check if we have to instantiate returned data
if (requestOptions.dataClass && !resolveWithFullResponse) {
if (_.isArray(response.data)) {
resolveArgument = _.map(response.data, function (dataItem) {
return new requestOptions.dataClass(dataItem);
});
} else {
resolveArgument = new requestOptions.dataClass(response.data);
}
}
}
if (_.isFunction(callback)) {
callback(resolveArgument, response);
}
resolve(resolveArgument);
})
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
.catch(function (error) {
if (error.code === "ERR_CANCELED" && abortSignal.aborted) {
var err = {
Message: "Request timed out",
Code: 408
};
self.errorHandler(err.Message, err);
reject(err);
} else {
var resolveArgument = (resolveWithFullResponse) ?
_.extend(error.response, {body: error.response.data}) : error.response.data;
if (_.isFunction(callback)) {
callback(resolveArgument, error.response);
}
self.errorHandler(error.message, error.response.data);
reject(resolveArgument);
}
});
});
},
buildRequestData: function (entity) {
var self = this;
var blackList = entity.getReadOnlyProperties();
var requestDataKeys = _.difference(_.keys(entity), blackList);
var requestData = _.pick(entity, requestDataKeys);
_.each(_.keys(entity), function (key) {
if (self.canReadSubRequestData(entity, key)) {
_.extendOwn(requestData, entity[key].toJSON());
}
});
return requestData;
},
canReadSubRequestData: function (entity, propertyName) {
if (entity instanceof this.models.PayIn && (propertyName === 'PaymentDetails' || propertyName === 'ExecutionDetails')) {
return true;
}
if (entity instanceof this.models.PayOut && propertyName === 'MeanOfPaymentDetails') {
return true;
}
if (entity instanceof this.models.BankAccount && propertyName === 'Details') {
return true;
}
return false;
},
/**
* OAuth2 authorization mechanism. After authorization request, calls the callback with returned authorization data
* @param {function} callback
* @returns {object} request promise
*/
authorize: function (callback) {
var self = this;
var auth_post_data = querystring.stringify({
'grant_type': 'client_credentials'
});
//'Content-Length' doesn't need to be set, it gets set automatically (node-rest-client v3.1.0)
return new Promise(function (resolve, reject) {
var url = self.buildOauthUrl();
var methodType = apiMethods.authentication_oauth[1];
axios({
method: methodType,
url: url,
data: auth_post_data,
headers: _.extend({}, self.requestOptions.headers, {
'Authorization': _getBasicAuthHash(self.config.clientId, self.config.clientApiKey),
'Content-Type': 'application/x-www-form-urlencoded',
})
})
.then(function (response) {
// Authorization succeeded
if (response.data.token_type && response.data.access_token) {
_.extend(self.requestOptions.headers, {
'Authorization': response.data.token_type + ' ' + response.data.access_token
});
// Multiplying expires_in (seconds) by 1000 since JS getTime() is expressed in ms
self.authorizationExpireTime = new Date().getTime() + (response.data.expires_in * 1000);
resolve(response.data);
if (_.isFunction(callback)) {
callback(response.data);
}
} else {
reject(response.data);
}
})
.catch(function (error) {
reject(error.response ? error.response.data : error.message);
});
});
},
isExpired: function () {
// Expressed in ms ( 10 seconds )
var THRESHOLD = 60000;
return new Date().getTime() > (this.authorizationExpireTime - THRESHOLD);
},
replaceUrl: function (url, data) {
// Create regex using the keys of the replacement object.
const regex = new RegExp('\\${(' + Object.keys(data).join('|') + ')}', 'g');
// Replace the string by the value in object
return url.replace(regex, (m, p1) => data[p1] || m);
},
buildOauthUrl: function() {
var url = apiMethods.authentication_oauth[0];
url = this.replaceUrl(url, this.requestOptions.path);
return url;
},
/**
* Populates the SDK object with the services
*/
_servicesLoader: function () {
var self = this;
// Retrieve all services and add them to this object
// ex: services.User becomes mangopay.Users
Object.keys(apiServices).forEach(function (serviceName) {
var ServiceClass = apiServices[serviceName];
self[serviceName] = new ServiceClass();
self[serviceName]._api = self;
});
// Retrieve all models and add them to this object
// ex: models.User becomes mangopay.models.User
self.models = {};
Object.keys(apiModels).forEach(function (modelName) {
self.models[modelName] = apiModels[modelName];
});
}
};
function setRateLimits(self, headers) {
self.rateLimits = [];
if (headers !== undefined) {
let rateLimitReset = headers['x-ratelimit-reset'];
let rateLimitRemaining = headers['x-ratelimit-remaining'];
let rateLimitMade = headers['x-ratelimit'];
if (rateLimitReset !== undefined && rateLimitRemaining !== undefined && rateLimitMade !== undefined) {
rateLimitReset = rateLimitReset.split(",");
rateLimitRemaining = rateLimitRemaining.split(",");
rateLimitMade = rateLimitMade.split(",");
if (rateLimitReset.length === rateLimitRemaining.length && rateLimitReset.length === rateLimitMade.length) {
const currentTime = Math.floor(Date.now() / 1000);
for (let i = 0; i < rateLimitReset.length; i++) {
const numberOfMinutes = (parseInt(rateLimitReset[i]) - currentTime) / 60;
const rateLimit = {
resetTimeMillis: parseInt(rateLimitReset[i]),
callsRemaining: parseInt(rateLimitRemaining[i]),
callsMade: parseInt(rateLimitMade[i])
};
if (numberOfMinutes <= 15) {
rateLimit.minutesInterval = 15;
} else if (numberOfMinutes <= 30) {
rateLimit.minutesInterval = 30;
} else if (numberOfMinutes <= 60) {
rateLimit.minutesInterval = 60;
} else if (numberOfMinutes <= 60 * 24) {
rateLimit.minutesInterval = 60 * 24;
}
self.rateLimits.push(rateLimit);
}
} else {
console.log("Could not set rate limits: headers length should be the same");
}
}
}
}
module.exports = Api;