]> arthur.barton.de Git - netdata.git/commitdiff
bind collector can now also accept XML input
authorCosta Tsaousis (ktsaou) <costa@tsaousis.gr>
Wed, 3 Feb 2016 00:01:02 +0000 (02:01 +0200)
committerCosta Tsaousis (ktsaou) <costa@tsaousis.gr>
Wed, 3 Feb 2016 00:01:02 +0000 (02:01 +0200)
node.d/Makefile.am
node.d/named.node.js
node.d/node_modules/pixl-xml.js [new file with mode: 0644]

index 77e20b032faaa7e8904a221764c27f8b33c1f4e0..5e8ec6ad7f4336ccc10bf8626a272a229a1491b2 100644 (file)
@@ -10,4 +10,5 @@ nodemodulesdir=$(nodedir)/node_modules
 dist_nodemodules_DATA = \
        node_modules/netdata.js \
        node_modules/extend.js \
+       node_modules/pixl-xml.js \
        $(NULL)
index 77c6f1a5021dec678a09f175df2202de32d1962b..ad59f69bb76fcb0a1b9988d44f320d7a26df5cab 100755 (executable)
@@ -21,7 +21,7 @@
                },\r
                {\r
                        "name": "bind2",\r
-                       "url": "http://10.1.2.3:8888/json/v1/server",\r
+                       "url": "http://10.0.0.1:8888/xml/v3/server",\r
                        "update_every": 2\r
                }\r
        ]\r
@@ -38,6 +38,7 @@ statistics-channels {
 \r
 var url = require('url');\r
 var http = require('http');\r
+var XML = require('pixl-xml');\r
 var netdata = require('netdata');\r
 \r
 if(netdata.options.DEBUG === true) netdata.debug('loaded ' + __filename + ' plugin');\r
@@ -126,9 +127,69 @@ var named = {
                numfetch: {}\r
        },\r
 \r
+       // transform the XML response of bind\r
+       // to the JSON response of bind\r
+       xml2js: function(service, data_xml) {\r
+               var d = XML.parse(data_xml);\r
+               if(d === null) return null;\r
+\r
+               var data = {};\r
+               var len = d.server.counters.length;\r
+               while(len--) {\r
+                       var a = d.server.counters[len];\r
+                       if(typeof a.counter === 'undefined') continue;\r
+                       if(a.type === 'opcode') a.type = 'opcodes';\r
+                       else if(a.type === 'qtype') a.type = 'qtypes';\r
+                       else if(a.type === 'nsstat') a.type = 'nsstats';\r
+                       var aa = data[a.type] = {};\r
+                       var alen = 0\r
+                       var alen2 = a.counter.length;\r
+                       while(alen < alen2) {\r
+                               aa[a.counter[alen].name] = parseInt(a.counter[alen]._Data);\r
+                               alen++;\r
+                       }\r
+               }\r
+\r
+               data.views = {};\r
+               var vlen = d.views.view.length;\r
+               while(vlen--) {\r
+                       var vname = d.views.view[vlen].name;\r
+                       data.views[vname] = { resolver: {} };\r
+                       var len = d.views.view[vlen].counters.length;\r
+                       while(len--) {\r
+                               var a = d.views.view[vlen].counters[len];\r
+                               if(typeof a.counter === 'undefined') continue;\r
+                               if(a.type === 'resstats') a.type = 'stats';\r
+                               else if(a.type === 'resqtype') a.type = 'qtypes';\r
+                               else if(a.type === 'adbstat') a.type = 'adb';\r
+                               var aa = data.views[vname].resolver[a.type] = {};\r
+                               var alen = 0;\r
+                               var alen2 = a.counter.length;\r
+                               while(alen < alen2) {\r
+                                       aa[a.counter[alen].name] = parseInt(a.counter[alen]._Data);\r
+                                       alen++;\r
+                               }\r
+                       }\r
+               }\r
+\r
+               return data;\r
+       },\r
+\r
        processResponse: function(service, data) {\r
                if(data !== null) {\r
-                       var r = JSON.parse(data);\r
+                       var r;\r
+\r
+                       // parse XML or JSON\r
+                       // pepending on the URL given\r
+                       if(service.request.path.match(/^\/xml/) !== null)\r
+                               r = named.xml2js(service, data);\r
+                       else\r
+                               r = JSON.parse(data);\r
+\r
+                       if(typeof r === 'undefined' || r === null) {\r
+                               netdata.serviceError(service, "Cannot parse these data: " + data);\r
+                               return;\r
+                       }\r
 \r
                        if(service.added !== true)\r
                                netdata.serviceAdd(service);\r
diff --git a/node.d/node_modules/pixl-xml.js b/node.d/node_modules/pixl-xml.js
new file mode 100644 (file)
index 0000000..481acba
--- /dev/null
@@ -0,0 +1,606 @@
+/*
+       JavaScript XML Library
+       Plus a bunch of object utility functions
+       
+       Usage:
+               var XML = require('pixl-xml');
+               var myxmlstring = '<?xml version="1.0"?><Document>' + 
+                       '<Simple>Hello</Simple>' + 
+                       '<Node Key="Value">Content</Node>' + 
+                       '</Document>';
+               
+               var tree = XML.parse( myxmlstring, { preserveAttributes: true });
+               console.log( tree );
+               
+               tree.Simple = "Hello2";
+               tree.Node._Attribs.Key = "Value2";
+               tree.Node._Data = "Content2";
+               tree.New = "I added this";
+               
+               console.log( XML.stringify( tree, 'Document' ) );
+       
+       Copyright (c) 2004 - 2015 Joseph Huckaby
+       Released under the MIT License
+       This version is for Node.JS, converted in 2012.
+*/
+
+var fs = require('fs');
+
+var indent_string = "\t";
+var xml_header = '<?xml version="1.0"?>';
+var sort_args = null;
+var re_valid_tag_name  = /^\w[\w\-\:]*$/;
+
+var XML = exports.XML = function XML(args) {
+       // class constructor for XML parser class
+       // pass in args hash or text to parse
+       if (!args) args = '';
+       if (isa_hash(args)) {
+               for (var key in args) this[key] = args[key];
+       }
+       else this.text = args || '';
+       
+       // stringify buffers
+       if (this.text instanceof Buffer) {
+               this.text = this.text.toString();
+       }
+       
+       if (!this.text.match(/^\s*</)) {
+               // try as file path
+               var file = this.text;
+               this.text = fs.readFileSync(file, { encoding: 'utf8' });
+               if (!this.text) throw new Error("File not found: " + file);
+       }
+       
+       this.tree = {};
+       this.errors = [];
+       this.piNodeList = [];
+       this.dtdNodeList = [];
+       this.documentNodeName = '';
+       
+       if (this.lowerCase) {
+               this.attribsKey = this.attribsKey.toLowerCase();
+               this.dataKey = this.dataKey.toLowerCase();
+       }
+       
+       this.patTag.lastIndex = 0;
+       if (this.text) this.parse();
+}
+
+XML.prototype.preserveAttributes = false;
+XML.prototype.lowerCase = false;
+
+XML.prototype.patTag = /([^<]*?)<([^>]+)>/g;
+XML.prototype.patSpecialTag = /^\s*([\!\?])/;
+XML.prototype.patPITag = /^\s*\?/;
+XML.prototype.patCommentTag = /^\s*\!--/;
+XML.prototype.patDTDTag = /^\s*\!DOCTYPE/;
+XML.prototype.patCDATATag = /^\s*\!\s*\[\s*CDATA/;
+XML.prototype.patStandardTag = /^\s*(\/?)([\w\-\:\.]+)\s*(.*)$/;
+XML.prototype.patSelfClosing = /\/\s*$/;
+XML.prototype.patAttrib = new RegExp("([\\w\\-\\:\\.]+)\\s*=\\s*([\\\"\\'])([^\\2]*?)\\2", "g");
+XML.prototype.patPINode = /^\s*\?\s*([\w\-\:]+)\s*(.*)$/;
+XML.prototype.patEndComment = /--$/;
+XML.prototype.patNextClose = /([^>]*?)>/g;
+XML.prototype.patExternalDTDNode = new RegExp("^\\s*\\!DOCTYPE\\s+([\\w\\-\\:]+)\\s+(SYSTEM|PUBLIC)\\s+\\\"([^\\\"]+)\\\"");
+XML.prototype.patInlineDTDNode = /^\s*\!DOCTYPE\s+([\w\-\:]+)\s+\[/;
+XML.prototype.patEndDTD = /\]$/;
+XML.prototype.patDTDNode = /^\s*\!DOCTYPE\s+([\w\-\:]+)\s+\[(.*)\]/;
+XML.prototype.patEndCDATA = /\]\]$/;
+XML.prototype.patCDATANode = /^\s*\!\s*\[\s*CDATA\s*\[([^]*)\]\]/;
+
+XML.prototype.attribsKey = '_Attribs';
+XML.prototype.dataKey = '_Data';
+
+XML.prototype.parse = function(branch, name) {
+       // parse text into XML tree, recurse for nested nodes
+       if (!branch) branch = this.tree;
+       if (!name) name = null;
+       var foundClosing = false;
+       var matches = null;
+       
+       // match each tag, plus preceding text
+       while ( matches = this.patTag.exec(this.text) ) {
+               var before = matches[1];
+               var tag = matches[2];
+               
+               // text leading up to tag = content of parent node
+               if (before.match(/\S/)) {
+                       if (typeof(branch[this.dataKey]) != 'undefined') branch[this.dataKey] += ' '; else branch[this.dataKey] = '';
+                       branch[this.dataKey] += trim(decode_entities(before));
+               }
+               
+               // parse based on tag type
+               if (tag.match(this.patSpecialTag)) {
+                       // special tag
+                       if (tag.match(this.patPITag)) tag = this.parsePINode(tag);
+                       else if (tag.match(this.patCommentTag)) tag = this.parseCommentNode(tag);
+                       else if (tag.match(this.patDTDTag)) tag = this.parseDTDNode(tag);
+                       else if (tag.match(this.patCDATATag)) {
+                               tag = this.parseCDATANode(tag);
+                               if (typeof(branch[this.dataKey]) != 'undefined') branch[this.dataKey] += ' '; else branch[this.dataKey] = '';
+                               branch[this.dataKey] += trim(decode_entities(tag));
+                       } // cdata
+                       else {
+                               this.throwParseError( "Malformed special tag", tag );
+                               break;
+                       } // error
+                       
+                       if (tag == null) break;
+                       continue;
+               } // special tag
+               else {
+                       // Tag is standard, so parse name and attributes (if any)
+                       var matches = tag.match(this.patStandardTag);
+                       if (!matches) {
+                               this.throwParseError( "Malformed tag", tag );
+                               break;
+                       }
+                       
+                       var closing = matches[1];
+                       var nodeName = this.lowerCase ? matches[2].toLowerCase() : matches[2];
+                       var attribsRaw = matches[3];
+                       
+                       // If this is a closing tag, make sure it matches its opening tag
+                       if (closing) {
+                               if (nodeName == (name || '')) {
+                                       foundClosing = 1;
+                                       break;
+                               }
+                               else {
+                                       this.throwParseError( "Mismatched closing tag (expected </" + name + ">)", tag );
+                                       break;
+                               }
+                       } // closing tag
+                       else {
+                               // Not a closing tag, so parse attributes into hash.  If tag
+                               // is self-closing, no recursive parsing is needed.
+                               var selfClosing = !!attribsRaw.match(this.patSelfClosing);
+                               var leaf = {};
+                               var attribs = leaf;
+                               
+                               // preserve attributes means they go into a sub-hash named "_Attribs"
+                               // the XML composer honors this for restoring the tree back into XML
+                               if (this.preserveAttributes) {
+                                       leaf[this.attribsKey] = {};
+                                       attribs = leaf[this.attribsKey];
+                               }
+                               
+                               // parse attributes
+                               this.patAttrib.lastIndex = 0;
+                               while ( matches = this.patAttrib.exec(attribsRaw) ) {
+                                       var key = this.lowerCase ? matches[1].toLowerCase() : matches[1];
+                                       attribs[ key ] = decode_entities( matches[3] );
+                               } // foreach attrib
+                               
+                               // if no attribs found, but we created the _Attribs subhash, clean it up now
+                               if (this.preserveAttributes && !num_keys(attribs)) {
+                                       delete leaf[this.attribsKey];
+                               }
+                               
+                               // Recurse for nested nodes
+                               if (!selfClosing) {
+                                       this.parse( leaf, nodeName );
+                                       if (this.error()) break;
+                               }
+                               
+                               // Compress into simple node if text only
+                               var num_leaf_keys = num_keys(leaf);
+                               if ((typeof(leaf[this.dataKey]) != 'undefined') && (num_leaf_keys == 1)) {
+                                       leaf = leaf[this.dataKey];
+                               }
+                               else if (!num_leaf_keys) {
+                                       leaf = '';
+                               }
+                               
+                               // Add leaf to parent branch
+                               if (typeof(branch[nodeName]) != 'undefined') {
+                                       if (isa_array(branch[nodeName])) {
+                                               branch[nodeName].push( leaf );
+                                       }
+                                       else {
+                                               var temp = branch[nodeName];
+                                               branch[nodeName] = [ temp, leaf ];
+                                       }
+                               }
+                               else {
+                                       branch[nodeName] = leaf;
+                               }
+                               
+                               if (this.error() || (branch == this.tree)) break;
+                       } // not closing
+               } // standard tag
+       } // main reg exp
+       
+       // Make sure we found the closing tag
+       if (name && !foundClosing) {
+               this.throwParseError( "Missing closing tag (expected </" + name + ">)", name );
+       }
+       
+       // If we are the master node, finish parsing and setup our doc node
+       if (branch == this.tree) {
+               if (typeof(this.tree[this.dataKey]) != 'undefined') delete this.tree[this.dataKey];
+               
+               if (num_keys(this.tree) > 1) {
+                       this.throwParseError( 'Only one top-level node is allowed in document', first_key(this.tree) );
+                       return;
+               }
+
+               this.documentNodeName = first_key(this.tree);
+               if (this.documentNodeName) {
+                       this.tree = this.tree[this.documentNodeName];
+               }
+       }
+};
+
+XML.prototype.throwParseError = function(key, tag) {
+       // log error and locate current line number in source XML document
+       var parsedSource = this.text.substring(0, this.patTag.lastIndex);
+       var eolMatch = parsedSource.match(/\n/g);
+       var lineNum = (eolMatch ? eolMatch.length : 0) + 1;
+       lineNum -= tag.match(/\n/) ? tag.match(/\n/g).length : 0;
+       
+       this.errors.push({ 
+               type: 'Parse',
+               key: key,
+               text: '<' + tag + '>',
+               line: lineNum
+       });
+       
+       // Throw actual error (must wrap parse in try/catch)
+       throw new Error( this.getLastError() );
+};
+
+XML.prototype.error = function() {
+       // return number of errors
+       return this.errors.length;
+};
+
+XML.prototype.getError = function(error) {
+       // get formatted error
+       var text = '';
+       if (!error) return '';
+
+       text = (error.type || 'General') + ' Error';
+       if (error.code) text += ' ' + error.code;
+       text += ': ' + error.key;
+       
+       if (error.line) text += ' on line ' + error.line;
+       if (error.text) text += ': ' + error.text;
+
+       return text;
+};
+
+XML.prototype.getLastError = function() {
+       // Get most recently thrown error in plain text format
+       if (!this.error()) return '';
+       return this.getError( this.errors[this.errors.length - 1] );
+};
+
+XML.prototype.parsePINode = function(tag) {
+       // Parse Processor Instruction Node, e.g. <?xml version="1.0"?>
+       if (!tag.match(this.patPINode)) {
+               this.throwParseError( "Malformed processor instruction", tag );
+               return null;
+       }
+       
+       this.piNodeList.push( tag );
+       return tag;
+};
+
+XML.prototype.parseCommentNode = function(tag) {
+       // Parse Comment Node, e.g. <!-- hello -->
+       var matches = null;
+       this.patNextClose.lastIndex = this.patTag.lastIndex;
+       
+       while (!tag.match(this.patEndComment)) {
+               if (matches = this.patNextClose.exec(this.text)) {
+                       tag += '>' + matches[1];
+               }
+               else {
+                       this.throwParseError( "Unclosed comment tag", tag );
+                       return null;
+               }
+       }
+       
+       this.patTag.lastIndex = this.patNextClose.lastIndex;
+       return tag;
+};
+
+XML.prototype.parseDTDNode = function(tag) {
+       // Parse Document Type Descriptor Node, e.g. <!DOCTYPE ... >
+       var matches = null;
+       
+       if (tag.match(this.patExternalDTDNode)) {
+               // tag is external, and thus self-closing
+               this.dtdNodeList.push( tag );
+       }
+       else if (tag.match(this.patInlineDTDNode)) {
+               // Tag is inline, so check for nested nodes.
+               this.patNextClose.lastIndex = this.patTag.lastIndex;
+               
+               while (!tag.match(this.patEndDTD)) {
+                       if (matches = this.patNextClose.exec(this.text)) {
+                               tag += '>' + matches[1];
+                       }
+                       else {
+                               this.throwParseError( "Unclosed DTD tag", tag );
+                               return null;
+                       }
+               }
+               
+               this.patTag.lastIndex = this.patNextClose.lastIndex;
+               
+               // Make sure complete tag is well-formed, and push onto DTD stack.
+               if (tag.match(this.patDTDNode)) {
+                       this.dtdNodeList.push( tag );
+               }
+               else {
+                       this.throwParseError( "Malformed DTD tag", tag );
+                       return null;
+               }
+       }
+       else {
+               this.throwParseError( "Malformed DTD tag", tag );
+               return null;
+       }
+       
+       return tag;
+};
+
+XML.prototype.parseCDATANode = function(tag) {
+       // Parse CDATA Node, e.g. <![CDATA[Brooks & Shields]]>
+       var matches = null;
+       this.patNextClose.lastIndex = this.patTag.lastIndex;
+       
+       while (!tag.match(this.patEndCDATA)) {
+               if (matches = this.patNextClose.exec(this.text)) {
+                       tag += '>' + matches[1];
+               }
+               else {
+                       this.throwParseError( "Unclosed CDATA tag", tag );
+                       return null;
+               }
+       }
+       
+       this.patTag.lastIndex = this.patNextClose.lastIndex;
+       
+       if (matches = tag.match(this.patCDATANode)) {
+               return matches[1];
+       }
+       else {
+               this.throwParseError( "Malformed CDATA tag", tag );
+               return null;
+       }
+};
+
+XML.prototype.getTree = function() {
+       // get reference to parsed XML tree
+       return this.tree;
+};
+
+XML.prototype.compose = function() {
+       // compose tree back into XML
+       var raw = compose_xml( this.tree, this.documentNodeName );
+       var body = raw.substring( raw.indexOf("\n") + 1, raw.length );
+       var xml = '';
+       
+       if (this.piNodeList.length) {
+               for (var idx = 0, len = this.piNodeList.length; idx < len; idx++) {
+                       xml += '<' + this.piNodeList[idx] + '>' + "\n";
+               }
+       }
+       else {
+               xml += xml_header + "\n";
+       }
+       
+       if (this.dtdNodeList.length) {
+               for (var idx = 0, len = this.dtdNodeList.length; idx < len; idx++) {
+                       xml += '<' + this.dtdNodeList[idx] + '>' + "\n";
+               }
+       }
+       
+       xml += body;
+       return xml;
+};
+
+//
+// Static Utility Functions:
+//
+
+var parse_xml = exports.parse = function parse_xml(text, opts) {
+       // turn text into XML tree quickly
+       if (!opts) opts = {};
+       opts.text = text;
+       var parser = new XML(opts);
+       return parser.error() ? parser.getLastError() : parser.getTree();
+};
+
+var trim = exports.trim = function trim(text) {
+       // strip whitespace from beginning and end of string
+       if (text == null) return '';
+       
+       if (text && text.replace) {
+               text = text.replace(/^\s+/, "");
+               text = text.replace(/\s+$/, "");
+       }
+       
+       return text;
+};
+
+var encode_entities = exports.encodeEntities = function encode_entities(text) {
+       // Simple entitize exports.for = function for composing XML
+       if (text == null) return '';
+       
+       if (text && text.replace) {
+               text = text.replace(/\&/g, "&amp;"); // MUST BE FIRST
+               text = text.replace(/</g, "&lt;");
+               text = text.replace(/>/g, "&gt;");
+       }
+       
+       return text;
+};
+
+var encode_attrib_entities = exports.encodeAttribEntities = function encode_attrib_entities(text) {
+       // Simple entitize exports.for = function for composing XML attributes
+       if (text == null) return '';
+       
+       if (text && text.replace) {
+               text = text.replace(/\&/g, "&amp;"); // MUST BE FIRST
+               text = text.replace(/</g, "&lt;");
+               text = text.replace(/>/g, "&gt;");
+               text = text.replace(/\"/g, "&quot;");
+               text = text.replace(/\'/g, "&apos;");
+       }
+       
+       return text;
+};
+
+var decode_entities = exports.decodeEntities = function decode_entities(text) {
+       // Decode XML entities into raw ASCII
+       if (text == null) return '';
+       
+       if (text && text.replace && text.match(/\&/)) {
+               text = text.replace(/\&lt\;/g, "<");
+               text = text.replace(/\&gt\;/g, ">");
+               text = text.replace(/\&quot\;/g, '"');
+               text = text.replace(/\&apos\;/g, "'");
+               text = text.replace(/\&amp\;/g, "&"); // MUST BE LAST
+       }
+       
+       return text;
+};
+
+var compose_xml = exports.stringify = function compose_xml(node, name, indent) {
+       // Compose node into XML including attributes
+       // Recurse for child nodes
+       var xml = "";
+       
+       // If this is the root node, set the indent to 0
+       // and setup the XML header (PI node)
+       if (!indent) {
+               indent = 0;
+               xml = xml_header + "\n";
+               
+               if (!name) {
+                       // no name provided, assume content is wrapped in it
+                       name = first_key(node);
+                       node = node[name];
+               }
+       }
+       
+       // Setup the indent text
+       var indent_text = "";
+       for (var k = 0; k < indent; k++) indent_text += indent_string;
+
+       if ((typeof(node) == 'object') && (node != null)) {
+               // node is object -- now see if it is an array or hash
+               if (!node.length) { // what about zero-length array?
+                       // node is hash
+                       xml += indent_text + "<" + name;
+
+                       var num_keys = 0;
+                       var has_attribs = 0;
+                       for (var key in node) num_keys++; // there must be a better way...
+
+                       if (node["_Attribs"]) {
+                               has_attribs = 1;
+                               var sorted_keys = hash_keys_to_array(node["_Attribs"]).sort();
+                               for (var idx = 0, len = sorted_keys.length; idx < len; idx++) {
+                                       var key = sorted_keys[idx];
+                                       xml += " " + key + "=\"" + encode_attrib_entities(node["_Attribs"][key]) + "\"";
+                               }
+                       } // has attribs
+
+                       if (num_keys > has_attribs) {
+                               // has child elements
+                               xml += ">";
+
+                               if (node["_Data"]) {
+                                       // simple text child node
+                                       xml += encode_entities(node["_Data"]) + "</" + name + ">\n";
+                               } // just text
+                               else {
+                                       xml += "\n";
+                                       
+                                       var sorted_keys = hash_keys_to_array(node).sort();
+                                       for (var idx = 0, len = sorted_keys.length; idx < len; idx++) {
+                                               var key = sorted_keys[idx];                                     
+                                               if ((key != "_Attribs") && key.match(re_valid_tag_name)) {
+                                                       // recurse for node, with incremented indent value
+                                                       xml += compose_xml( node[key], key, indent + 1 );
+                                               } // not _Attribs key
+                                       } // foreach key
+
+                                       xml += indent_text + "</" + name + ">\n";
+                               } // real children
+                       }
+                       else {
+                               // no child elements, so self-close
+                               xml += "/>\n";
+                       }
+               } // standard node
+               else {
+                       // node is array
+                       for (var idx = 0; idx < node.length; idx++) {
+                               // recurse for node in array with same indent
+                               xml += compose_xml( node[idx], name, indent );
+                       }
+               } // array of nodes
+       } // complex node
+       else {
+               // node is simple string
+               xml += indent_text + "<" + name + ">" + encode_entities(node) + "</" + name + ">\n";
+       } // simple text node
+
+       return xml;
+};
+
+var always_array = exports.alwaysArray = function always_array(obj, key) {
+       // if object is not array, return array containing object
+       // if key is passed, work like XMLalwaysarray() instead
+       if (key) {
+               if ((typeof(obj[key]) != 'object') || (typeof(obj[key].length) == 'undefined')) {
+                       var temp = obj[key];
+                       delete obj[key];
+                       obj[key] = new Array();
+                       obj[key][0] = temp;
+               }
+               return null;
+       }
+       else {
+               if ((typeof(obj) != 'object') || (typeof(obj.length) == 'undefined')) { return [ obj ]; }
+               else return obj;
+       }
+};
+
+var hash_keys_to_array = exports.hashKeysToArray = function hash_keys_to_array(hash) {
+       // convert hash keys to array (discard values)
+       var array = [];
+       for (var key in hash) array.push(key);
+       return array;
+};
+
+var isa_hash = exports.isaHash = function isa_hash(arg) {
+       // determine if arg is a hash
+       return( !!arg && (typeof(arg) == 'object') && (typeof(arg.length) == 'undefined') );
+};
+
+var isa_array = exports.isaArray = function isa_array(arg) {
+       // determine if arg is an array or is array-like
+       if (typeof(arg) == 'array') return true;
+       return( !!arg && (typeof(arg) == 'object') && (typeof(arg.length) != 'undefined') );
+};
+
+var first_key = exports.firstKey = function first_key(hash) {
+       // return first key from hash (unordered)
+       for (var key in hash) return key;
+       return null; // no keys in hash
+};
+
+var num_keys = exports.numKeys = function num_keys(hash) {
+       // count the number of keys in a hash
+       var count = 0;
+       for (var a in hash) count++;
+       return count;
+};