src/node-promised-redmine/redmine.js
/*jshint strict:false, node:true, indent:false, forin:false, bitwise:false*/
/*global JSON*/
var http = require('http')
, https = require('https')
, querystring = require('querystring')
, D = require('d.js')
, packageJson = require('../../package.json')
, fs = require('fs')
, splitca = require('split-ca')
, keyPassphrases = []
;
function escapeJSONString(key, value) {
if (typeof value === 'string') {
return value.replace(/[^ -~\b\t\n\f\r"\\]/g, function (a) {
return '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
});
}
return value;
}
function JSONStringify(data) {
return JSON.stringify(data, escapeJSONString).replace(/\\\\u([\da-f]{4}?)/g, '\\u$1');
}
/**
* Redmine
*/
function Redmine(config) {
if (!config.apiKey || !config.host) {
throw new Error('Error: apiKey and host must be configured.');
}
config || (config = {});
this.keyPassId = keyPassphrases.length;
keyPassphrases.push({key:null, passphrase:null});
this.verbose = false;
this.setApiKey(config.apiKey)
.setHost(config.host)
.setProtocol(config.protocol || 'http')
.setPort(config.port || (config.protocol === 'https' ? 443 : 80))
.setBasicAuth(config.basicAuth || '')
.setPathPrefix(config.pathPrefix || '/')
.setSslV3Disabled(config.sslV3Disabled || true)
.setSslCaCert(config.sslCaCert || null)
.setSslClientCert(config.sslClientCert || null)
.setSslClientKey(config.sslClientKey || null, config.sslClientPassphrase || null)
.setMaxTry(config.maxTry||1)
.setMaxDelay(config.maxDelay)
;
}
var proto = {
version: packageJson.version
, JSONStringify: JSONStringify
, setApiKey: function(k){ this.apiKey = k; return this; }
, getApiKey: function(){ return this.apiKey; }
, setHost: function(h){ this.host = h; return this; }
, getHost: function(){ return this.host;}
, setPort: function(p){ this.port = p; return this; }
, getPort: function(){ return this.port;}
, setBasicAuth: function(b){ this.basicAuth = b; return this; }
, getBasicAuth: function(){ return this.basicAuth || '';}
, setProtocol: function(p){
if (! p.match(/^https?/)) {
throw new Error('Protocol must be one of http, https');
}
this.protocol = p;
return this;
}
, getProtocol: function(){ return this.protocol; }
, setPathPrefix: function(p){
if (p.slice(0, 1) !== '/') {
p = '/' + p;
}
this.pathPrefix = p;
return this;
}
, getPathPrefix: function(){ return this.pathPrefix; }
, setSslV3Disabled: function(b){
this.sslV3Disabled = b ? 'SSL_OP_NO_SSLv3' : null;
return this;
}
, getSslV3Disabled: function(){ return this.sslV3Disabled;}
, setSslCaCert: function(c){ this.sslCaCert = c ? splitca(c) : null; return this; }
, getSslCaCert: function(){ return this.sslCaCert; }
, setSslClientCert: function(c){ this.sslClientCert = c ? fs.readFileSync(c) : null; return this; }
, getSslClientCert: function(){ return this.sslClientCert; }
, setSslClientKey: function(k, p){
keyPassphrases[this.keyPassId].key = k ? fs.readFileSync(k) : null;
keyPassphrases[this.keyPassId].passphrase = k && p || null;
return this;
}
, setMaxTry: function(maxTry){ this.maxTry = maxTry < 1 ? 1 : maxTry; return this; }
, getMaxTry: function(){ return this.maxTry; }
, setMaxDelay: function(maxDelay){ this.maxDelay = maxDelay ? maxDelay : 2000; return this; }
, getMaxDelay: function() { return this.maxDelay; }
, setVerbose: function(v) { this.verbose = v!==undefined ? !!v : true; }
, getVerbose: function() { return this.verbose; }
, generatePath: function(path, params) {
return path + (params && Object.keys(params).length ? ('?' + querystring.stringify(params||{})) : '');
}
, request: function(method, path, params){
params || (params = {});
var self = this
, d = D()
, keyPass = keyPassphrases[this.keyPassId]
, retry = params.retry || {}
, verbose = this.verbose
, impersonation
, options
, req
;
if (params.retry) {
delete params.retry;
}
retry.count || (retry.count = 1);
retry.maxTry || (retry.maxTry = self.maxTry);
retry.maxDelay || (retry.maxDelay = self.maxDelay);
if (params.impersonate) {
impersonation = params.impersonate;
delete params.impersonate;
}
if (! (this.apiKey && this.host)) {
d.reject('Error: apiKey and host must be configured.');
return d.promise;
}
path.slice(0, 1) !== '/' && (path = '/' + path);
if (retry.count === 1) {
self.pathPrefix && (path = self.pathPrefix + path);
}
options = {
host: self.host
, port: self.port
, path: method === 'GET' ? self.generatePath(path, params) : path
, method: method
, headers: {
'X-Redmine-API-Key': self.apiKey
}
, agent: false // required for ssl options to work
};
impersonation && (options.headers['X-Redmine-Switch-User'] = impersonation);
self.basicAuth && (options.auth = self.basicAuth);
self.sslCaCert && (options.ca = self.sslCaCert);
self.sslClientCert && (options.cert = self.sslClientCert);
self.sslV3Disabled && (options.secureOptions = self.sslV3Disabled);
keyPass.key && (options.key = keyPass.key);
keyPass.passphrase && (options.passphrase = keyPass.passphrase);
req = (self.protocol==='https' ? https : http).request(options, function(res){
var body='';
res.setEncoding('utf8');
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
if( !~([200,201]).indexOf(res.statusCode) ){
verbose && console.log('STATUSCODE REJECTION:', res.statusCode, '\n' + body);
d.reject('STATUSCODE_REJECTION ' + res.statusCode);
return;
}
try {
var data = JSON.parse(body || res.statusCode);
d.resolve(data);
}catch(e){
verbose && console.log("Error: Broken json in response:\n", body);
d.reject("Error: Broken json in response");
}
});
});
req.on('error', function(e){
verbose && console.log('Error: request error', e);
d.reject('ERROR_REQUEST');
});
req.setHeader('Content-Type', 'application/json');
if (method !== 'GET') {
var body = JSONStringify(params);
req.setHeader('Content-Length', body.length);
req.setHeader('Content-Type', 'application/json');
req.write(body);
}
req.end();
return d.promise.error(function(e){
if ( e === 'ERROR_REQUEST' && retry.count < retry.maxTry ) {
verbose && console.log('retry previous request');
return D.wait(Math.min(1 << retry.count, retry.maxDelay)).then(function(){
retry.count++;
params.retry = retry;
impersonation && (params.impersonate = impersonation);
return self.request(method, path, params);
});
}
verbose && console.log('ERROR', e);//, req);
throw e;
});
}
/// GENERIC CRUD METHODS ///
/**
* @param {string} path without .json extension and leading / e.g. issues or issues/5
* @return {promise}
*/
, get: function(path, params){ return this.request('GET', '/' + path + '.json', params); }
/**
* @param {string} what e.g. issues, projects ...
* @param {date} since date object or iso string
* @param {*} params
* @return {promise}
*/
, getAllSince: function(what, since, params){
since.toISOString && (since = since.toISOString());
params || (params = {} );
params.sort = 'updated_on:desc';
params.limit || (params.limit=100);
var self = this
, d = D()
, res = []
, getNext = function getNext(data){
var fulfilled=false;
data[what].forEach(function(v){
if( since >= v.updated_on ){
fulfilled=true;
}
fulfilled || res.push(v);
});
if( (!fulfilled) && (data.total_count > (data.limit+data.offset)) ){
params.offset = data.limit+data.offset;
params.limit = data.limit;
return self.get(what, params).success(getNext).rethrow(d.reject);
}
return d.resolve(res);
}
;
self.get(what, params).success(getNext).rethrow(d.reject);
return d.promise;
}
/**
* @param {string} path without .json extension and leading / e.g. issues
* @param {*} params
* @return {promise}
*/
, post: function(path, params){
return this.request('POST', '/' + path + '.json', params);
}
/**
* @param {string} path without .json extension and leading / e.g. issues/id
* @param {*} params
* @return {promise}
*/
, put: function(path, params){
return this.request('PUT', '/' + path + '.json', params);
}
/**
* @param {string} path without .json extension and leading / e.g. issues/id
* @param {*} params
* @return {promise}
*/
, del: function(path, params){
return this.request('DELETE', '/' + path + '.json', params);
}
/// CRUD ISSUES APIS ///
, getIssues: function(params){ return this.get('issues', params); }
, getIssue: function(id, params){
return this.get('issues/' + id, params)
.success(function(issueContainer){ return issueContainer.issue || issueContainer;})
;
}
, getAllIssuesSince:function(since, params){
return this.getAllSince('issues', since, params);
}
, postIssue: function(issue, params) {
params || (params = {});
params.issue = issue;
return this.post('issues', params);
}
, updateIssue: function(id, issue, params) {
params || (params = {});
params.issue = issue;
return this.put('issues/' + id, params);
}
, deleteIssue: function(id, params) { this.del('issues/' + id, params); }
///----- USERS METHODS -----///
, getUsers: function(params){ return this.get('users', params); }
, getUser: function(id, params){ return this.get('users/' + id, params); }
, getUserCurrent:function(params){ return this.get('users/current', params); }
///----- PROJECTS METHODS -----///
,getProjects: function(params){ return this.get('projects', params); }
,getProject: function(id, params){ return this.get('projects/' + id, params); }
,getAllProjectsSince: function(since, params) {
return this.getAllSince('projects', since, params);
}
///----- TIME_ENTRIES METHOD -----///
,getTimeEntries: function(params){return this.get('time_entries', params); }
,getTimeEntry: function(id, params){ return this.get('time_entries/' + id, params); }
,postTimeEntry: function(timeEntry, params){
params || (params = {});
params.time_entry = timeEntry;
return this.post('time_entries', params);
}
,updateTimeEntry: function(id, timeEntry, params){
params || (params = {});
params.time_entry = timeEntry;
return this.put('time_entries/' + id, params);
}
,deleteTimeEntry: function(id, params) { return this.del('time_entries/' + id, params); }
};
for( var prop in proto){ Redmine.prototype[prop] = proto[prop]; }
/*
* Exports
*/
module.exports = Redmine;