/**
* @module http
* @author Flavio De Stefano <flavio.destefano@caffeina.com>
*/
/**
* @property config
* @property {String} config.base The base URL of the API
* @property {Number} [config.timeout=30000] Global timeout for the reques. after this value
* (express in milliseconds) the requests throw an error.
* @property {Object} [config.headers={}] Global headers for all requests.
* @property {Object} [config.useCache=true] Global cache flag.
* @property {Object} [config.offlineCache=false] Global offline cache.
* @property {Boolean} [config.log=false] Log the requests.
* @property {Boolean} [config.bodyEncodingInJSON=false] Force to encoding in JSON of body data is the input is a JS object.
* @property {Boolean} [config.sslPinning=false] If this value is an array, it must contain the domains on which to apply SSL pinning. If this value is true, SSL pinning is applied on base HTTP domain. Certificates must be located in /app/assets/certs and named as the the domain name without extension. (example: "/app/assets/certs/youtube.com")
*/
exports.config = _.extend({
base: '',
timeout: 30000,
headers: {},
useCache: true,
offlineCache: false,
log: false,
bodyEncodingInJSON: false,
sslPinning: false
}, Alloy.CFG.T ? Alloy.CFG.T.http : {});
var MODULE_NAME = 'http';
var Event = require('T/event');
var Util = require('T/util');
var Q = require('T/ext/q');
var PermissionsStorage = require('T/permissions/storage');
var securityManager = null;
function extractHTTPText(data, info) {
if (info != null && data != null) {
if (info.format === 'json') {
return Util.parseJSON(data);
}
}
return data;
}
function HTTPRequest(opt) {
var self = this;
if (opt.url == null) {
throw new Error(MODULE_NAME + '.Request: URL not set');
}
this.opt = opt;
// if the url is not matching a protocol, assign the base URL
if (/\:\/\//.test(opt.url)) {
this.url = opt.url;
} else {
this.url = exports.config.base.replace(/\/$/, '') + '/' + opt.url.replace(/^\//, '');
}
this.domain = Util.getDomainFromURL(this.url);
this.method = opt.method != null ? opt.method.toUpperCase() : 'GET';
// Construct headers: global + per-domain + local
this.headers = _.extend({}, exports.getHeaders(), exports.getHeaders(this.domain), opt.headers);
this.timeout = opt.timeout != null ? opt.timeout : exports.config.timeout;
this.configureSSLPinning();
this.securityManager = opt.securityManager || securityManager;
// Rebuild the URL if is a GET and there's data
if (opt.data != null) {
if (this.method === 'GET') {
if (typeof opt.data === 'object') {
var exQuery = /\?.*/.test(this.url);
this.url = this.url + Util.buildQuery(opt.data, exQuery ? '&' : '?');
}
} else {
if (exports.config.bodyEncodingInJSON == true || opt.bodyEncodingInJSON == true) {
this.headers['Content-Type'] = 'application/json';
this.data = JSON.stringify(opt.data);
} else {
this.data = opt.data;
}
}
}
this.hash = this._calculateHash();
this.uniqueId = exports.getUniqueId();
// Fill the defer, we will manage the callbacks through it
this.defer = Q.defer();
this.defer.promise.then(function() { self._onSuccess.apply(self, arguments); });
this.defer.promise.catch(function() { self._onError.apply(self, arguments); });
this.defer.promise.finally(function() { self._onFinally.apply(self, arguments); });
Ti.API.debug(MODULE_NAME + ': <' + this.uniqueId + '>', this.method, this.url, this.data);
}
HTTPRequest.prototype.configureSSLPinning = function() {
if (securityManager != null) return;
if (exports.config.sslPinning == false) return;
var AppcHttps = Util.requireOrNull('appcelerator.https');
if (AppcHttps == null) {
return Ti.API.error(MODULE_NAME + ': SSL pinning requires appcelerator.https module');
}
if (_.isArray(exports.config.sslPinning)) {
securityManager = AppcHttps.createX509CertificatePinningSecurityManager(_.map(exports.config.sslPinning, function(domain) {
var path = Util.getResourcesDirectory() + "/certs/" + domain;
if (Ti.Filesystem.getFile(path).exists() == false) {
throw new Error(MODULE_NAME + ': certificate for SSL pinning not found (' + domain + ')');
}
return {
url: "https://" + domain,
serverCertificate: path
};
}));
} else if (exports.config.sslPinning == true) {
var domain = Util.getDomainFromURL(config.base);
var path = Util.getResourcesDirectory() + "/certs/" + domain;
if (Ti.Filesystem.getFile(path).exists() == false) {
throw new Error(MODULE_NAME + ': certificate for base SSL pinning not found (' + domain + ')');
}
securityManager = AppcHttps.createX509CertificatePinningSecurityManager([{
url: "https://" + Util.getDomainFromURL(config.base),
serverCertificate: path
}]);
}
};
HTTPRequest.prototype.toString = function() {
return this.hash;
};
HTTPRequest.prototype._maybeCacheResponse = function(data) {
if (this.method !== 'GET') return;
if (exports.config.useCache === false) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> cache has been disabled globally');
return;
}
if (this.opt.cache === false) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> set cache has been disabled for this request');
return;
}
if (this.responseInfo.ttl <= 0) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> set cache is not applicable');
return;
}
require('T/cache').set(this.hash, data, this.responseInfo.ttl, this.responseInfo);
};
HTTPRequest.prototype.getCachedResponse = function() {
if (this.method !== 'GET') return null;
if (exports.config.useCache === false) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> cache has been disabled globally');
return null;
}
if (this.opt.cache === false || this.opt.refresh === true) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> get cache has been disabled for this request');
return null;
}
this.cachedData = require('T/cache').get(this.hash);
if (this.cachedData == null) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> cache is missing');
return null;
}
// We got cache
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> cache hit up to ' + (this.cachedData.expire - Util.now()) + 's');
if (this.cachedData.info.format === 'blob') {
return this.cachedData.value;
}
return extractHTTPText(this.cachedData.value.text, this.cachedData.info);
};
HTTPRequest.prototype._getResponseInfo = function() {
if (this.client == null || this.client.readyState <= 1) {
throw new Error(MODULE_NAME + ': Client is null or not ready');
}
var headers = {
Expires: this.client.getResponseHeader('Expires'),
ContentType: this.client.getResponseHeader('Content-Type'),
TTL: this.client.getResponseHeader('X-Cache-Ttl')
};
var info = {
format: 'blob',
ttl: 0
};
if (this.client.responseText != null) {
info.format = 'text';
if (/^application\/json/.test(headers.ContentType)) info.format = 'json';
}
// Always prefer X-Cache-Ttl over Expires
if (headers.TTL != null) {
info.ttl = headers.TTL;
} else if (headers.Expires != null) {
info.ttl = Util.timestamp(headers.Expires) - Util.now();
}
// Override
if (this.opt.format != null) info.format = this.opt.format;
if (this.opt.ttl != null) info.ttl = this.opt.ttl;
return info;
};
HTTPRequest.prototype._onSuccess = function() {
if (this.endTime != null) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> response success (in ' + (this.endTime - this.startTime) + 'ms)');
} else {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> response success');
}
if (exports.config.log === true) {
// Log response from server
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '>', arguments[0]);
}
if (OS_ANDROID && this.client != null) {
if ((this.client.status >= 300 && this.client.status < 400) && this.client.location != this.url) {
Ti.API.trace(MODULE_NAME + ': <' + this.uniqueId + '> following redirect to ' + this.client.location);
exports.send(_.extend(this.opt, { url: this.client.location }));
return;
}
}
if (_.isFunction(this.opt.success)) {
this.opt.success.apply(this, arguments);
}
};
HTTPRequest.prototype._onError = function(err) {
Ti.API.error(MODULE_NAME + ': <' + this.uniqueId + '>', err);
if (_.isFunction(this.opt.error)) {
this.opt.error.apply(this, arguments);
}
};
HTTPRequest.prototype._onFinally = function() {
if (_.isFunction(this.opt.complete)) {
this.opt.complete.apply(this, arguments);
}
};
HTTPRequest.prototype._whenComplete = function(e) {
this.endTime = Date.now();
exports.removeFromQueue(this);
// Fire the global event
if (this.opt.silent !== true) {
Event.trigger(MODULE_NAME + '.end', {
hash: this.hash,
eventName: this.opt.eventName
});
}
try {
this.responseInfo = this._getResponseInfo();
} catch (ex) {
this.defer.reject({
code: 0,
broken: true
});
return;
}
var data = null;
if (this.opt.format === 'blob') {
data = this.client.responseData;
} else {
data = extractHTTPText(this.client.responseText, this.responseInfo);
}
if (e.success) {
this._maybeCacheResponse(data);
this.defer.resolve(data);
} else {
this.defer.reject({
message: (this.opt.format === 'blob') ? null : Util.getErrorMessage(data),
error: e.error,
code: this.client.status,
response: data
});
}
};
HTTPRequest.prototype._calculateHash = function() {
var hash = this.url + Util.hashJavascriptObject(this.data) + Util.hashJavascriptObject(this.headers);
return 'http_' + Ti.Utils.md5HexDigest(hash);
};
HTTPRequest.prototype.send = function() {
var self = this;
var promise = Q();
_.each(filters, function(filter, name) {
if (self.opt.suppressFilters == true) return;
if (_.isArray(self.opt.suppressFilters) && self.opt.suppressFilters.indexOf(name) >= 0) return;
promise = promise.then( filter.bind(null, self) );
});
promise
.then(self._send.bind(self))
.fail(function(ex) {
Ti.API.error(MODULE_NAME + ': <' + self.uniqueId + '> filter rejection', ex);
self.defer.reject(ex);
});
};
HTTPRequest.prototype._send = function() {
var self = this;
var client = Ti.Network.createHTTPClient(_.extend({
timeout: this.timeout,
cache: false,
},
this.securityManager ? { securityManager: this.securityManager } : {}
));
client.onload = client.onerror = function(e) { self._whenComplete(e); };
// Add this request to the queue
exports.addToQueue(this);
if (this.opt.silent !== true) {
Event.trigger(MODULE_NAME + '.start', {
hash: this.hash,
eventName: this.opt.eventName
});
}
// Progress callbacks
if (_.isFunction(this.opt.ondatastream)) client.ondatastream = this.opt.ondatastream;
if (_.isFunction(this.opt.ondatasend)) client.ondatasend = this.opt.ondatasend;
client.open(this.method, this.url);
// Set file receiver
if (this.opt.file != null) {
client.file = this.opt.file;
}
// Set headers
_.each(this.headers, function(h, k) {
client.setRequestHeader(k, h);
});
// Send the request over Internet
this.startTime = Date.now();
if (this.data != null) {
client.send(this.data);
} else {
client.send();
}
this.client = client;
};
HTTPRequest.prototype.resolve = function() {
var cache = null;
if (Ti.Network.online) {
cache = this.getCachedResponse();
if (cache != null) {
this.defer.resolve(cache);
} else {
this.send();
}
} else {
Event.trigger(MODULE_NAME + '.offline');
if (exports.config.offlineCache === true || this.opt.offlineCache === true) {
cache = this.getCachedResponse();
if (cache != null) {
this.defer.resolve();
} else {
this.defer.reject({
offline: true,
code: 0,
message: L('network_offline', 'Check your connectivity.')
});
}
} else {
this.defer.reject({
offline: true,
code: 0,
message: L('network_offline', 'Check your connectivity.')
});
}
}
};
HTTPRequest.prototype.abort = function() {
if (this.client != null) {
this.client.abort();
Ti.API.debug(MODULE_NAME + ': <' + this.uniqueId + '> aborted!');
}
};
HTTPRequest.prototype.success = HTTPRequest.prototype.then = function(func) {
this.opt.success = func;
return this;
};
HTTPRequest.prototype.error = HTTPRequest.prototype.fail = HTTPRequest.prototype.catch = function(func) {
this.opt.error = func;
return this;
};
HTTPRequest.prototype.complete = HTTPRequest.prototype.fin = HTTPRequest.prototype.finally = function(func) {
this.opt.complete = func;
return this;
};
HTTPRequest.prototype.getPromise = function() {
return this.defer.promise;
};
var filters = {};
/**
* @param {String} name The name of the middleware
* @param {Function} func The function
*/
exports.addFilter = function(name, func) {
filters[name] = func;
};
/**
* @param {String} name The name of the middleware
*/
exports.removeFilter = function(name, func) {
delete filters[name];
};
/**
* Attach events to current module
*/
exports.event = function(name, cb) {
Event.on(MODULE_NAME + '.' + name, cb);
};
/**
* @return {String}
*/
var __uniqueId = 0;
exports.getUniqueId = function() {
return __uniqueId++;
};
var headers = _.clone(exports.config.headers);
var headersPerDomain = {};
/**
* @return {Object}
*/
exports.getHeaders = function(domain) {
if (domain == null) {
return headers;
} else {
return headersPerDomain[domain] || {};
}
};
/**
* Add a global header for all requests
* @param {String} key The header key
* @param {String} value The header value
* @param {String} [domain=null] Optional domain
*/
exports.addHeader = function(key, value, domain) {
if (domain == null) {
headers[key] = value;
} else {
headersPerDomain[domain] = headersPerDomain[domain] || {};
headersPerDomain[domain][key] = value;
}
};
/**
* Remove a global header
* @param {String} key The header key
* @param {String} [domain=null] Optional domain
*/
exports.removeHeader = function(key, domain) {
if (domain == null) {
delete headers[key];
} else {
if (headersPerDomain[domain] != null) {
delete headersPerDomain[domain][key];
}
}
};
/**
* Reset all globals headers
* @param {String} [domain=null] Optional domain
*/
exports.resetHeaders = function(domain) {
if (domain == null) {
headers = {};
headersPerDomain = {};
} else {
headersPerDomain[domain] = {};
}
};
var queue = [];
/**
* Check if the requests queue is empty
* @return {Boolean}
*/
exports.isQueueEmpty = function(){
return _.isEmpty(queue);
};
/**
* Get the current requests queue
* @return {Array}
*/
exports.getQueue = function(){
return queue;
};
/**
* Add a request to queue
* @param {HTTP.Request} request
*/
exports.addToQueue = function(request) {
queue[request.hash] = request;
};
/**
* Remove a request from queue
*/
exports.removeFromQueue = function(request) {
delete queue[request.hash];
};
/**
* Reset the cookies for all requests
*/
exports.resetCookies = function() {
Ti.Network.removeAllHTTPCookies();
};
/**
* The main function of the module.
*
* Create an HTTP.Request and resolve it
*
* @param {Object} opt The request dictionary
* @param {String} opt.url The endpoint URL
* @param {String} [opt.method="GET"] The HTTP method to use (GET|POST|PUT|PATCH|..)
* @param {Object} [opt.headers=null] An Object key-value of additional headers
* @param {Number} [opt.timeout=30000] Timeout after stopping the request and triggering an error
* @param {Boolean} [opt.cache=true] Set to false to disable the cache
* @param {Function} [opt.success] The success callback
* @param {Function} [opt.error] The error callback
* @param {String} [opt.format] Override the format for that request (like `json`)
* @param {Number} [opt.ttl] Override the TTL seconds for the cache
* @return {HTTP.Request}
*/
function send(opt) {
var request = new HTTPRequest(opt);
request.resolve();
return request;
}
exports.send = send;
/**
* Make a GET request to that URL
* @param {String} url The endpoint url
* @param {Function} success Success callback
* @param {Function} error Error callback
* @return {HTTP.Request}
*/
exports.get = function(url, success, error) {
return send({
url: url,
method: 'GET',
success: success,
error: error
});
};
/**
* Make a POST request to that URL
* @param {String} url The endpoint url
* @param {Object} data The data
* @param {Function} success Success callback
* @param {Function} error Error callback
* @return {HTTP.Request}
*/
exports.post = function(url, data, success, error) {
return send({
url: url,
method: 'POST',
data: data,
success: success,
error: error
});
};
/**
* Make a GET request to that url with that data and setting the format forced to JSON
* @param {String} url The endpoint url
* @param {Object} data The data
* @param {Function} success Success callback
* @param {Function} error Error callback
* @return {HTTP.Request}
*/
exports.getJSON = function(url, data, success, error) {
return send({
url: url,
data: data,
method: 'GET',
format: 'json',
success: success,
error: error
});
};
/**
* Make a POST request to that url with that data and setting the format forced to JSON
* @param {String} url The endpoint url
* @param {Object} data The data
* @param {Function} success Success callback
* @param {Function} error Error callback
* @return {HTTP.Request}
*/
exports.postJSON = function(url, data, success, error) {
return send({
url: url,
data: data,
method: 'POST',
format: 'json',
success: success,
error: error
});
};
/**
* @param {String} url The url
* @param {Object} filename File name or `Ti.File`
* @param {Function} success Success callback
* @param {Function} error Error callback
* @param {Function} ondatastream Progress callback
* @return {HTTP.Request}
*/
exports.download = function(url, file, success, error, ondatastream) {
var doDownload = function() {
var tiFile = null;
if (file == null) {
tiFile = Ti.Filesystem.getFile(Util.getAppDataDirectory(), _.uniqueId('http_'));
} else if (_.isString(file)) {
tiFile = Ti.Filesystem.getFile(Util.getAppDataDirectory(), file);
} else {
tiFile = file;
}
send({
url: url,
cache: false,
refresh: true,
format: 'blob',
file: tiFile.resolve(),
ondatastream: ondatastream,
error: error,
success: function() {
if (tiFile.exists()) {
success(tiFile);
} else {
error({
message: L('unable_to_write_file', 'Unable to write file')
});
}
}
});
};
PermissionsStorage.request(doDownload, error);
};
/**
* Export the HTTP cookies to the system to make them available to `WebViews`
* @param {String} domain The domain. Default is `HTTP.config.base`
*/
exports.exportCookiesToSystem = function(domain) {
if (!OS_ANDROID) return;
domain = domain || exports.config.base.replace('http://', '');
_.each(Ti.Network.getHTTPCookiesForDomain(domain), function(c) {
Ti.Network.addSystemCookie(c);
});
};
//////////
// Init //
//////////
headers['X-Ti-Version'] = Ti.version;
headers['X-Platform'] = Util.getPlatformFullName();
headers['X-App-Id'] = Ti.App.id;
headers['X-App-Version'] = Ti.App.version;
headers['X-App-DeployType'] = Ti.App.deployType;
if (OS_IOS) {
headers['X-App-InstallId'] = Ti.App.installId;
}