diff --git a/.gitignore b/.gitignore index 68f49f6..d5c8b49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .idea/ node-xmlrpc.iml +/nbproject \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 90571e4..ba52325 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,12 @@ language: node_js node_js: - - "0.8" - "0.10" - "0.11" - "0.12" + - "4.0" + - "4.1" + - "4.2" + - "5.0" + - "5.1" + - "5.2" + - "5.3" diff --git a/README.md b/README.md index 07328d0..83b3495 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,14 @@ server.on('NotFound', function(method, params) { console.log('Method ' + method + ' does not exist'); }) // Handle method calls by listening for events with the method call name -server.on('anAction', function (err, params, callback) { +server.on('anAction', function (err, params, callback, request, response) { console.log('Method call params for \'anAction\': ' + params) // ...perform an action... + // Use request and response objects directly for any custom processing, e.g. + // set or forward cookies + // Send a method response with a value callback(null, 'aResult') }) @@ -177,6 +180,71 @@ var client = xmlrpc.createClient('YOUR_ENDPOINT'); client.methodCall('YOUR_METHOD', [new YourType(yourVariable)], yourCallback); ``` +### XML RPC Error +There is a special error type defined - `XmlRpcError`. And a helper function makeError to create errors easily. +Use it to create an error and pass it to the `callback`. + +```javascript +// Makes an error with only message and code defaults to zero (0) +xmlrpc.makeError("Error occured") +``` + +The resulting response would be: +```xml + + + + + + + code + + 0 + + + + message + + Error occured + + + + + + +``` + +The error with a code example: +```javascript +// Makes an error with message and code +xmlrpc.makeError("Error occured", 123) +``` + +The resulting response would be: +```xml + + + + + + + faultCode + + 123 + + + + faultString + + Error occured + + + + + + +``` + ### To Debug (client-side) Error callbacks on the client are enriched with request and response diff --git a/lib/client.js b/lib/client.js index b875461..20221d4 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,9 +1,9 @@ -var http = require('http') - , https = require('https') - , url = require('url') - , Serializer = require('./serializer') - , Deserializer = require('./deserializer') - , Cookies = require('./cookies') +var http = require('http') + , https = require('https') + , url = require('url') + , Serializer = require('./serializer') + , Deserializer = require('./deserializer') + , Cookies = require('./cookies') /** * Creates a Client object for making XML-RPC method calls. @@ -17,6 +17,7 @@ var http = require('http') * - {Number} port * - {String} url - (optional) - may be used instead of host/port pair * - {Boolean} cookies - (optional) - if true then cookies returned by server will be stored and sent back on the next calls. + * - {Promise} promiselib - (optional) - if set, the methodCall would return promise * Also it will be possible to access/manipulate cookies via #setCookie/#getCookie methods * @param {Boolean} isSecure - True if using https for making calls, * otherwise false. @@ -24,67 +25,79 @@ var http = require('http') */ function Client(options, isSecure) { - // Invokes with new if called without - if (false === (this instanceof Client)) { - return new Client(options, isSecure) - } - - // If a string URI is passed in, converts to URI fields - if (typeof options === 'string') { - options = url.parse(options) - options.host = options.hostname - options.path = options.pathname - } - - if (typeof options.url !== 'undefined') { - var parsedUrl = url.parse(options.url); - options.host = parsedUrl.hostname; - options.path = parsedUrl.pathname; - options.port = parsedUrl.port; - } - - // Set the HTTP request headers - var headers = { - 'User-Agent' : 'NodeJS XML-RPC Client' - , 'Content-Type' : 'text/xml' - , 'Accept' : 'text/xml' - , 'Accept-Charset' : 'UTF8' - , 'Connection' : 'Keep-Alive' - } - options.headers = options.headers || {} - - if (options.headers.Authorization == null && - options.basic_auth != null && - options.basic_auth.user != null && - options.basic_auth.pass != null) - { - var auth = options.basic_auth.user + ':' + options.basic_auth.pass - options.headers['Authorization'] = 'Basic ' + new Buffer(auth).toString('base64') - } - - for (var attribute in headers) { - if (options.headers[attribute] === undefined) { - options.headers[attribute] = headers[attribute] - } - } - - options.method = 'POST' - this.options = options - - this.isSecure = isSecure - this.headersProcessors = { - processors: [], - composeRequest: function(headers) { - this.processors.forEach(function(p) {p.composeRequest(headers);}) - }, - parseResponse: function(headers) { - this.processors.forEach(function(p) {p.parseResponse(headers);}) - } - }; - if (options.cookies) { - this.cookies = new Cookies(); - this.headersProcessors.processors.unshift(this.cookies); - } + // Invokes with new if called without + if (false === (this instanceof Client)) { + return new Client(options, isSecure) + } + + // If a string URI is passed in, converts to URI fields + if (typeof options === 'string') { + options = url.parse(options) + options.host = options.hostname + options.path = options.pathname + } + + if (typeof options.url !== 'undefined') { + var parsedUrl = url.parse(options.url); + options.host = parsedUrl.hostname; + options.path = parsedUrl.pathname; + options.port = parsedUrl.port; + } + + // Set the HTTP request headers + var headers = { + 'User-Agent': 'NodeJS XML-RPC Client' + , 'Content-Type': 'text/xml' + , 'Accept': 'text/xml' + , 'Accept-Charset': 'UTF8' + , 'Connection': 'Keep-Alive' + } + options.headers = options.headers || {} + + if (options.headers.Authorization == null && + options.basic_auth != null && + options.basic_auth.user != null && + options.basic_auth.pass != null) + { + var auth = options.basic_auth.user + ':' + options.basic_auth.pass + options.headers['Authorization'] = 'Basic ' + new Buffer(auth).toString('base64') + } + + for (var attribute in headers) { + if (options.headers[attribute] === undefined) { + options.headers[attribute] = headers[attribute] + } + } + + options.method = 'POST' + this.options = options + + this.isSecure = isSecure + this.headersProcessors = { + processors: [], + composeRequest: function (headers) { + this.processors.forEach(function (p) { + p.composeRequest(headers); + }) + }, + parseResponse: function (headers) { + this.processors.forEach(function (p) { + p.parseResponse(headers); + }) + } + }; + if (options.cookies) { + this.cookies = new Cookies(); + this.headersProcessors.processors.unshift(this.cookies); + } +} + +function __cb(resolve, reject, error, value) { + if (error) { + reject(error); + } else { + resolve(value); + } } /** @@ -92,49 +105,61 @@ function Client(options, isSecure) { * * @param {String} method - The method name. * @param {Array} params - Params to send in the call. - * @param {Function} callback - function(error, value) { ... } + * @param {Function} callback - (optional) function(error, value) { ... } * - {Object|null} error - Any errors when making the call, otherwise null. * - {mixed} value - The value returned in the method response. + * If not specified and promiselib was specified in construction options, + * .then(function(value){}).catch(function(error){}) can be used. */ Client.prototype.methodCall = function methodCall(method, params, callback) { - var options = this.options - var xml = Serializer.serializeMethodCall(method, params, options.encoding) - var transport = this.isSecure ? https : http - - options.headers['Content-Length'] = Buffer.byteLength(xml, 'utf8') - this.headersProcessors.composeRequest(options.headers) - var request = transport.request(options, function(response) { - - var body = [] - response.on('data', function (chunk) { body.push(chunk) }) - - function __enrichError (err) { - Object.defineProperty(err, 'req', { value: request }) - Object.defineProperty(err, 'res', { value: response }) - Object.defineProperty(err, 'body', { value: body.join('') }) - return err - } - - if (response.statusCode == 404) { - callback(__enrichError(new Error('Not Found'))) - } - else { - this.headersProcessors.parseResponse(response.headers) - - var deserializer = new Deserializer(options.responseEncoding) - - deserializer.deserializeMethodResponse(response, function(err, result) { - if (err) { - err = __enrichError(err) - } - callback(err, result) - }) - } - }.bind(this)) - - request.on('error', callback) - request.write(xml, 'utf8') - request.end() + var options = this.options + var xml = Serializer.serializeMethodCall(method, params, options.encoding) + var transport = this.isSecure ? https : http + + var promise = null; + if (!callback && options.promiselib) { + promise = new options.promiselib(function (resolve, reject) { + callback = __cb.bind(null, resolve, reject); + }); + } + + options.headers['Content-Length'] = Buffer.byteLength(xml, 'utf8') + this.headersProcessors.composeRequest(options.headers) + var request = transport.request(options, function (response) { + + var body = [] + response.on('data', function (chunk) { + body.push(chunk) + }) + + function __enrichError(err) { + Object.defineProperty(err, 'req', {value: request}) + Object.defineProperty(err, 'res', {value: response}) + Object.defineProperty(err, 'body', {value: body.join('')}) + return err + } + + if (response.statusCode == 404) { + callback(__enrichError(new Error('Not Found'))) + } else { + this.headersProcessors.parseResponse(response.headers) + + var deserializer = new Deserializer(options.responseEncoding) + + deserializer.deserializeMethodResponse(response, function (err, result) { + if (err) { + err = __enrichError(err) + } + callback(err, result) + }) + } + }.bind(this)) + + request.on('error', callback) + request.write(xml, 'utf8') + request.end() + + return promise; } /** @@ -145,10 +170,10 @@ Client.prototype.methodCall = function methodCall(method, params, callback) { * @return {*} cookie's value */ Client.prototype.getCookie = function getCookie(name) { - if (!this.cookies) { - throw 'Cookies support is not turned on for this client instance'; - } - return this.cookies.get(name); + if (!this.cookies) { + throw 'Cookies support is not turned on for this client instance'; + } + return this.cookies.get(name); } /** @@ -166,12 +191,11 @@ Client.prototype.getCookie = function getCookie(name) { * @return {*} client object itself */ Client.prototype.setCookie = function setCookie(name, value) { - if (!this.cookies) { - throw 'Cookies support is not turned on for this client instance'; - } - this.cookies.set(name, value); - return this; + if (!this.cookies) { + throw 'Cookies support is not turned on for this client instance'; + } + this.cookies.set(name, value); + return this; } module.exports = Client - diff --git a/lib/error.js b/lib/error.js new file mode 100644 index 0000000..9b24bad --- /dev/null +++ b/lib/error.js @@ -0,0 +1,60 @@ +var error = require('tea-error') + +var xmlrpc = exports + +/** + * The type to generate xml-rpc error + */ +xmlrpc.XmlRpcError = error('XmlRpcError'); + +/** + * The helper function to create xml-rpc error + * + * Examples: + * // Makes an error with only message and default (zero 0) code + * xmlrpc.makeError("Error occured") + * // Makes an error with message and code + * xmlrpc.makeError("Error occured", 123) + * + * @param {String} message The error message + * @param {Number} code The error code + * + * @returns {XmlRpcError} The error object + */ +xmlrpc.makeError = function (message, code) { + var msg = message; + if ('object' === typeof message) { + for (var i in message) { + msg = message[i]; + // we need only one field + break; + } + } + return new xmlrpc.XmlRpcError(message, {codeArg: code}); +} + +/** + * + * @param {XmlRpcError|Error|Object|String} error The error object + * @returns {Object|String} The error object or string to send as a response + */ +xmlrpc.makeResponseObjectFromError = function(error) { + var message = ''; + var code = 0; + if (error instanceof xmlrpc.XmlRpcError) { + message = error.message; + if ('number' === typeof error.codeArg) { + code = error.codeArg; + } + } else if (error instanceof Error) { + message = (('' !== error.message) ? error.message : error.name); + } else if (error instanceof Object) { + message = error.toString(); + } else { + message = error + ''; + } + var fault = {}; + fault['faultString'] = message; + fault['faultCode'] = code; + return fault; +} diff --git a/lib/server.js b/lib/server.js index baef3b6..f6687c7 100644 --- a/lib/server.js +++ b/lib/server.js @@ -4,9 +4,10 @@ var http = require('http') , EventEmitter = require('events').EventEmitter , Serializer = require('./serializer') , Deserializer = require('./deserializer') + , xmlrpcError = require('./error') /** - * Creates a new Server object. Also creates an HTTP server to start listening + * Creates a new Server object. Also optionally creates a HTTP server to start listening * for XML-RPC method calls. Will emit an event with the XML-RPC call's method * name when receiving a method call. * @@ -14,61 +15,98 @@ var http = require('http') * @param {Object|String} options - The HTTP server options. Either a URI string * (e.g. 'http://localhost:9090') or an object * with fields: - * - {String} host - (optional) - * - {Number} port + * - {http.Server} httpServer - (optional) The external http server object + * If supplied, the request handler is supposed to be registered + * by the user himself: server.requestHandler + * - {String} anyMethodName - (optional) The special method name. + * If supplied, all incoming request would be routed to it if not found in specific events. + * The actual method name requested is given in a first element of the params array in a callback. + * - {String} host - (optional) + * - {Number} port * @param {Boolean} isSecure - True if using https for making calls, * otherwise false. * @return {Server} */ function Server(options, isSecure, onListening) { - if (false === (this instanceof Server)) { - return new Server(options, isSecure) - } - onListening = onListening || function() {} - var that = this + if (false === (this instanceof Server)) { + return new Server(options, isSecure) + } + onListening = onListening || function() {} + var that = this - // If a string URI is passed in, converts to URI fields - if (typeof options === 'string') { - options = url.parse(options) - options.host = options.hostname - options.path = options.pathname - } + // If a string URI is passed in, converts to URI fields + if (typeof options === 'string') { + options = url.parse(options) + options.host = options.hostname + options.path = options.pathname + } - function handleMethodCall(request, response) { - var deserializer = new Deserializer() - deserializer.deserializeMethodCall(request, function(error, methodName, params) { - if (that._events.hasOwnProperty(methodName)) { - that.emit(methodName, null, params, function(error, value) { - var xml = null - if (error !== null) { - xml = Serializer.serializeFault(error) - } - else { - xml = Serializer.serializeMethodResponse(value) - } - response.writeHead(200, {'Content-Type': 'text/xml'}) - response.end(xml) - }) - } - else { - that.emit('NotFound', methodName, params) - response.writeHead(404) - response.end() - } - }) - } + function _callback(response, error, value) { + var xml = null; + if (error !== null) { + var fault = xmlrpcError.makeResponseObjectFromError(error); + xml = Serializer.serializeFault(fault); + } + else { + xml = Serializer.serializeMethodResponse(value); + } + response.writeHead(200, {'Content-Type': 'text/xml'}); + response.end(xml); + } + + function _deserializeMethodCallback(request, response, error, methodName, params) { + if (that._events.hasOwnProperty(methodName)) { + that.emit(methodName, null/*error*/, params, _callback.bind(null, response), request, response); + } + else if (options.anyMethodName && that._events.hasOwnProperty(options.anyMethodName)) { + // Add the methodName as a first element to params + params.splice(0, 0, methodName); + that.emit(options.anyMethodName, null/*error*/, params, _callback.bind(null, response), request, response); + } + else { + that.emit('NotFound', methodName/*error*/, params); + response.writeHead(404); + response.end(); + } + } + + function _socketDestroy(socket) { + socket.destroy(); + } + + function _finishCallback(socket) { + socket.removeAllListeners('timeout'); + socket.setTimeout(5000, _socketDestroy.bind(null, socket)); + } - this.httpServer = isSecure ? https.createServer(options, handleMethodCall) - : http.createServer(handleMethodCall) + function handleMethodCall(request, response) { + // node.js bug with slow socket release workaround + // @see http://habrahabr.ru/post/264851/ + var socket = request.socket; + response.on('finish', _finishCallback.bind(null, socket)); - process.nextTick(function() { - this.httpServer.listen(options.port, options.host, onListening) - }.bind(this)) - this.close = function(callback) { - this.httpServer.once('close', callback) - this.httpServer.close() - }.bind(this) + var deserializer = new Deserializer(); + deserializer.deserializeMethodCall(request, _deserializeMethodCallback.bind(null, request, response)); + } + this.requestHandler = handleMethodCall; + + if (options.httpServer) { + this.httpServer = options.httpServer; + } else { + if (isSecure) { + this.httpServer = https.createServer(options, handleMethodCall); + } else { + this.httpServer = http.createServer(handleMethodCall); + } + process.nextTick(function() { + this.httpServer.listen(options.port, options.host, onListening) + }.bind(this)) + this.close = function(callback) { + this.httpServer.once('close', callback) + this.httpServer.close() + }.bind(this) + } } // Inherit from EventEmitter to emit and listen diff --git a/lib/xmlrpc.js b/lib/xmlrpc.js index 5355e22..25667a7 100644 --- a/lib/xmlrpc.js +++ b/lib/xmlrpc.js @@ -2,6 +2,7 @@ var Client = require('./client') , Server = require('./server') , CustomType = require('./customtype') , dateFormatter = require('./date_formatter') + , error = require('./error') var xmlrpc = exports @@ -63,3 +64,6 @@ xmlrpc.createSecureServer = function(options, callback) { xmlrpc.CustomType = CustomType xmlrpc.dateFormatter = dateFormatter + +xmlrpc.XmlRpcError = error.XmlRpcError; +xmlrpc.makeError = error.makeError; diff --git a/package.json b/package.json index bea1d64..4147b3d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ , "main" : "./lib/xmlrpc.js" , "dependencies" : { "sax" : "0.6.x" + , "tea-error": "^0.2.0" , "xmlbuilder" : "2.6.x" } , "devDependencies" : { diff --git a/test/fixtures/good_food/encoded_fault.xml b/test/fixtures/good_food/encoded_fault.xml new file mode 100644 index 0000000..33c243f --- /dev/null +++ b/test/fixtures/good_food/encoded_fault.xml @@ -0,0 +1 @@ +Foo \ No newline at end of file diff --git a/test/fixtures/good_food/string_cdata_fault.xml b/test/fixtures/good_food/string_cdata_fault.xml new file mode 100644 index 0000000..627acbc --- /dev/null +++ b/test/fixtures/good_food/string_cdata_fault.xml @@ -0,0 +1 @@ +faultStringCongrats]]>faultCode0 diff --git a/test/fixtures/good_food/string_code_fault.xml b/test/fixtures/good_food/string_code_fault.xml new file mode 100644 index 0000000..0dda9c9 --- /dev/null +++ b/test/fixtures/good_food/string_code_fault.xml @@ -0,0 +1 @@ +faultStringtestStringfaultCode123 diff --git a/test/fixtures/good_food/string_empty_fault.xml b/test/fixtures/good_food/string_empty_fault.xml new file mode 100644 index 0000000..b3ca59e --- /dev/null +++ b/test/fixtures/good_food/string_empty_fault.xml @@ -0,0 +1 @@ +faultStringfaultCode0 diff --git a/test/fixtures/good_food/string_fault.xml b/test/fixtures/good_food/string_fault.xml new file mode 100644 index 0000000..006752a --- /dev/null +++ b/test/fixtures/good_food/string_fault.xml @@ -0,0 +1 @@ +faultStringtestStringfaultCode0 diff --git a/test/fixtures/good_food/string_multiline_cdata_fault.xml b/test/fixtures/good_food/string_multiline_cdata_fault.xml new file mode 100644 index 0000000..fc29cca --- /dev/null +++ b/test/fixtures/good_food/string_multiline_cdata_fault.xml @@ -0,0 +1,4 @@ +faultString +Go testing! +Congrats +]]>faultCode0 diff --git a/test/serializer_test.js b/test/serializer_test.js index dbb6f61..a95453a 100644 --- a/test/serializer_test.js +++ b/test/serializer_test.js @@ -4,7 +4,8 @@ var vows = require('vows') , assert = require('assert') , Serializer = require('../lib/serializer') , CustomType = require('../lib/customtype') - , util = require('util') + , xmlrpcError= require('../lib/error') + , util = require('util') vows.describe('Serializer').addBatch({ @@ -221,6 +222,100 @@ vows.describe('Serializer').addBatch({ , 'contains the encoding attribute': assertXml('good_food/encoded_call.xml') } } + + ,'serializeFault() called with': { + + 'type': { + + 'string' : { + 'with a regular string param' : { + topic: function () { + var error = 'testString' + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the string': assertXml('good_food/string_fault.xml') + } + , 'with a string param that requires CDATA' : { + topic: function () { + var error = 'Congrats' + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the CDATA-wrapped string': assertXml('good_food/string_cdata_fault.xml') + } + , 'with a multiline string param that requires CDATA' : { + topic: function () { + var error = '\nGo testing!\nCongrats\n' + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the CDATA-wrapped string': assertXml('good_food/string_multiline_cdata_fault.xml') + } + , 'with an empty string' : { + topic: function () { + var error = '' + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the empty string': assertXml('good_food/string_empty_fault.xml') + } + } + + , 'XmlRpcError' : { + 'with a code field' : { + topic: function () { + var error = xmlrpcError.makeError('testString', 123) + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the string': assertXml('good_food/string_code_fault.xml') + } + , 'without a code field' : { + topic: function () { + // PHP XML-RPC style + var error = xmlrpcError.makeError('testString') + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the string': assertXml('good_food/string_fault.xml') + } + } + + , 'Error' : { + 'with message' : { + topic: function () { + // PHP XML-RPC style + var error = new Error('testString'); + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the string': assertXml('good_food/string_fault.xml') + } + } + + , 'Object' : { + 'with message' : { + topic: function () { + // PHP XML-RPC style + var error = new Object('testString'); + var value = xmlrpcError.makeResponseObjectFromError(error); + return Serializer.serializeFault(value) + } + , 'contains the string': assertXml('good_food/string_fault.xml') + } + } + + } + + , 'utf-8 encoding': { + topic: function () { + var value = "\x46\x6F\x6F" + return Serializer.serializeFault(value) + } + , 'contains the encoding attribute': assertXml('good_food/encoded_fault.xml') + } + } , 'serializeMethodResponse() called with': {