var url = require('url')
, https = require('https')
, http = require('http')
, querystring = require('querystring')
, sax = require('sax')
, MemoryCache = require('./cache/memory')
module.exports = Client
/**
* EVE API client.
*
* The following list of options are recognized:
*
* - url: Fully qualified HTTP(s) URL to EVE API server
* - cache: Cache object that handles persisting and retrieving results from cache
*
*
* @exports Client as hamster.Client
* @param {Object} options Client options
* @see hamster.cache.FileCache
* @constructor
*/
function Client(options) {
options = options || {}
this._params = {}
this.setUrl(options.url || 'https://api.eveonline.com')
this.setParams(options.params || {})
this.setCache(options.cache || new MemoryCache())
}
/**
* Set server URL.
*
* @param {String|Object} strOrObj URL string
*/
Client.prototype.setUrl = function (urlStr) {
this._url = url.parse(urlStr)
}
/**
* Get server URL.
*
* @param {Boolean} returnUrlObj Pass true to return URL object instead of string
* @return {String|Object} URL string or object
*/
Client.prototype.getUrl = function (returnUrlObj) {
return returnUrlObj ? this._url : url.format(this._url)
}
/**
* Set default parameter.
*
* @param {String} param Parameter name
* @param {String} value Parameter value
*/
Client.prototype.setParam = function (param, value) {
this._params[param] = value
}
/**
* Set default parameters.
*
* @param {object} params Parameters object
*/
Client.prototype.setParams = function (params) {
for (var param in params) {
if (params.hasOwnProperty(param)) {
this.setParam(param, params[param])
}
}
}
/**
* Get default parameter.
*
* @param {String} param Parameter name
* @return {String} Parameter value
*/
Client.prototype.getParam = function (param) {
if (typeof this._params[param] !== 'undefined') {
return this._params[param]
}
}
/**
* Get default parameters.
*
* @return {Object} Parameters object
*/
Client.prototype.getParams = function () {
return this._params
}
/**
* Clear default parameters.
*/
Client.prototype.clearParams = function () {
for (var param in this._params) {
if (this._params.hasOwnProperty(param)) {
delete params[param]
}
}
}
/**
* Set the cache object for this client.
*
* The cache object is responsible for storing and retrieving cached responses.
*
* Any cache object implementing set(key, value, duration) and
* get(key) methods can be used as a cache backend.
*
* @param {Object} cache Cache object
*/
Client.prototype.setCache = function (cache) {
this._cache = cache
}
/**
* Get cache object.
*
* @return {Object} Cache object
*/
Client.prototype.getCache = function () {
return this._cache
}
/**
* Gets a path name relative to the current path set with Client#setUrl().
*
* This method also supports a short hand syntax for path names, e.g.,
* 'server:ServerStatus' would translate to /server/ServerStatus.xml.aspx.
*
* @param {String} path Relative path
* @return {String} Full path name
*/
Client.prototype.getPathName = function (path) {
var basePath = this.getUrl(true).pathname.replace(/^\/*|\/*$/g, '')
if (path[0] !== '/') {
path = path.replace(':', '/') + '.xml.aspx'
}
if (basePath) {
basePath = '/' + basePath
}
return basePath + '/' + path.replace(/^\/*|\/*$/g, '')
}
/**
* Get request URL with specified path and params as a URL object.
*
* @param {String} path Request path
* @param {Object} params Query string parameters
* @return {Object} URL object
*/
Client.prototype.getRequestUrl = function (path, params) {
params = params || {}
var hasParams = false
, baseUrl = this.getUrl(true)
, requestUrl = {}
, defaultParams = this.getParams()
for (var param in defaultParams) {
if (typeof params[param] === 'undefined') {
params[param] = defaultParams[param]
}
}
for (var param in params) {
hasParams = true
break
}
for (var key in baseUrl) {
requestUrl[key] = baseUrl[key]
}
requestUrl.pathname = this.getPathName(path)
requestUrl.path = requestUrl.pathname
if (hasParams) {
requestUrl.search = '?' + querystring.stringify(params)
requestUrl.path += requestUrl.search
}
return requestUrl
}
/**
* Takes a URL object and returns a string that will be used as the cache key
* for the resource located at the URL.
*
* Currently this just returns a URL string with query string parameters sorted
* alphabetically.
*
* @param {Object} urlObj URL object
* @return {String} Cache key
*/
Client.prototype.getCacheKey = function (urlObj) {
var keys = []
, newUrlObj = {}
, newQuery = {}
, oldQuery
for (var key in urlObj) {
newUrlObj[key] = urlObj[key]
}
if (urlObj.search) {
oldQuery = querystring.parse(urlObj.search.substr(1))
for (var key in oldQuery) {
keys.push(key)
}
// Reconstruct query with alphabetical key ordering
keys.sort().forEach(function (key) {
newQuery[key] = oldQuery[key]
})
// Insertion order should be guaranteed.
newUrlObj.search = '?' + querystring.stringify(newQuery)
newUrlObj.path = newUrlObj.pathname + newUrlObj.search
}
return url.format(newUrlObj)
}
/**
* Parses an EVE API response from either an XML string or a readable stream.
* A callback will be invoked and passed either an error or result object.
*
* @param {String|Stream} xml API response
* @param {Function} cb Result callback
*/
Client.prototype.parse = function (xml, cb) {
var parser = sax.createStream(true, {trim: true})
, result = {}
, current = result
, parents = []
, currentTag
, keys
parser.on('error', function (err) {
cb(err)
})
parser.on('end', function () {
var err = null
, res = undefined
if (result && result.eveapi && result.eveapi.error) {
err = new Error(result.eveapi.error)
err.code = result.eveapi.errorCode
} else if (!result || !result.eveapi || !result.eveapi.result) {
err = new Error('Invalid API response structure.')
} else {
if (result.eveapi.currentTime) result.eveapi.result.currentTime = result.eveapi.currentTime
if (result.eveapi.cachedUntil) result.eveapi.result.cachedUntil = result.eveapi.cachedUntil
res = result.eveapi.result
}
cb(err, res)
})
parser.on('opentag', function (tag) {
currentTag = tag
tag.alias = tag.name
tag.result = current
parents.push(tag)
if (tag.name === 'row') {
var key = keys.map(function (key) { return tag.attributes[key] }).join(':')
current[key] = {}
current = current[key]
for (var attr in tag.attributes) {
current[attr] = tag.attributes[attr]
}
} else {
if (tag.name === 'rowset') {
keys = tag.attributes.key.split(',')
tag.alias = tag.attributes.name
} else if (tag.name === 'error') {
current.errorCode = tag.attributes.code ? tag.attributes.code : null
}
current[tag.alias] = {}
current = current[tag.alias]
}
})
parser.on('closetag', function (tagName) {
current = parents.pop().result
var parentTag = parents[parents.length - 1]
if (parentTag && parentTag.name === 'rowset') {
keys = parentTag.attributes.key.split(',')
}
})
parser.on('text', function (text) {
parents[parents.length - 1].result[currentTag.name] = text
})
if (xml.pipe) {
xml.pipe(parser)
} else {
parser.write(xml)
parser.end()
}
}
/**
* Send HTTP request to API server and parse response.
*
* @param {String} path Request path
* @param {Object} params Query string parameters
* @param {Function} cb Result callback
*/
Client.prototype.fetch = function (path, params, cb) {
if (!cb) {
cb = params
params = {}
}
var options = this.getRequestUrl(path, params)
, cacheKey = this.getCacheKey(options)
, cache = this.getCache()
, self = this
cache.read(cacheKey, function (err, value) {
if (err) return cb(err)
if (typeof value === 'string') return cb(null, JSON.parse(value))
var httpObj = options.protocol === 'https:' ? https : http
, request = httpObj.get(options)
options.headers = {'User-Agent': 'hamster.js'}
request.on('error', function (err) {
cb(err)
})
request.on('response', function (response) {
if (response.statusCode !== 200) {
var err = new Error('Unsupported HTTP response: ' + response.statusCode)
err.response = response
cb(err)
} else {
self.parse(response, function (err, result) {
if (err) return cb(err)
var currentTime = Date.parse(result.currentTime)
, cachedUntil = Date.parse(result.cachedUntil)
, duration = cachedUntil - currentTime
cache.write(cacheKey, JSON.stringify(result), duration, function (err) {
if (err) return cb(err)
cb(null, result)
})
})
}
})
})
}