UNPKG

odataserver

Version:

OData server with support for BLOBs

1,004 lines (843 loc) 29.8 kB
// odataserver.js //--------------- // // 2014-11-15, Jonas Colmsjö //------------------------------ // // Simple OData server on top of a RDBMS and bucket server. Currently are MySQL // and Leveldb used as RDBMS and buckets servers. Some experiments have been // done with MS SQL (and it seams to work fine). // // Overview // ------- // // The HTTP Request Readble stream contains both read and write requests. // And the HTTP Writable Result stream is used to write the result of both read // and write requests (GET/PUT/POST/DELETE). ODataserver writes the ouput from // mysql operations into a bucket. The reason is that a deciding step is // performed. // // mysql.js ODataServer HTTP Client // (using node-mysql) // -----| // read/write & events read w. callbacks Read <-- Req stream // .on('result') sqlRead.fetchAll(resF, errF) -----| // .on(’error') admin ops. w. stream // .on('end') sqlAdmin.pipe -----| // write w. stream Write --> Res stream // jsonStream.pipe(writeStream) -----| // // NOTE: NodeJS Streams2 objects are typically not used by node-mysql. These are // custom 'stream', actually events. It is possible to use Streams2 // object also (with `.streams`). // // NOTE2: The error callback should have been placed first and not last to // work as nodejs API:s. // // PLAN: Let all read operations in mysql.js support promises in addition to // callbacks. // // // The main() function // ------------------- // // 1. Parse the URI into ODataRequest object. This will also generate the SQL // 2. Process the HTTP request (using on(data) / on(error) / on(end) ) // 3. Parse the JSON request data // 4. Handle operations that requires admin privileges. // 5. Handle operations the use the supplied credentials // We let the sql classes write into a bucket here and in step 4. We then // write this into the HTTP stream. // // // Using // [Google JavaScript Style Guide](http://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml) // var moduleSelf = this; var u = require('underscore'); var h = require('./helpers.js'); var CONSTANTS = require('./constants.js'); var log = new h.log0(CONSTANTS.odataServerLoggerOptions); var StringDecoder = require('string_decoder').StringDecoder; var nodemailer = require('nodemailer'); var Rdbms = require(CONSTANTS.ODATA.RDBMS_BACKEND); // check for admin operations, where the url start with /s/... var urlAdminOps = ['create_account', 'reset_password', 'delete_account', 'create_table', 'service_def', 'grant', 'revoke', 'delete_table' ]; // These operations require admin/root privs in the db var adminCredentialOps = ['create_account', 'reset_password', 'delete_account', 'service_def' ]; // Check if operation is a valid admin operation exports.isAdminOp = function(op) { return urlAdminOps.indexOf(op) !== -1; }; // ODataUri2Sql class // =================== // // Translate OData filter to a SQL where expression // ------------------------------------------------ // // These OData filters are supported, see // http://www.odata.org/documentation/odata-version-2-0/uri-conventions // for more information. // // |Operator |Description |Example // |------------|----------------|------------------------------------------------| // |Logical Operators| | | // |Eq |Equal |/Suppliers?$filter=Address/City eq ‘Redmond’ | // |Ne |Not equal |/Suppliers?$filter=Address/City ne ‘London’ | // |Gt |Greater than |/Products?$filter=Price gt 20 | // |Ge |Greater than or equal|/Products?$filter=Price ge 10 | // |Lt |Less than |/Products?$filter=Price lt 20 | // |Le |Less than or equal|/Products?$filter=Price le 100 | // |And |Logical and |/Products?$filter=Price le 200 and Price gt 3.5 | // |Or |Logical or |/Products?$filter=Price le 3.5 or Price gt 200 | // |Not |Logical negation|/Products?$filter=not endswith(Description,’milk’)| // |Arithmetic Operators| | | // |Add |Addition |/Products?$filter=Price add 5 gt 10 | // |Sub |Subtraction |/Products?$filter=Price sub 5 gt 10 | // |Mul |Multiplication |/Products?$filter=Price mul 2 gt 2000 | // |Div |Division |/Products?$filter=Price div 2 gt 4 | // |Mod |Modulo |/Products?$filter=Price mod 2 eq 0 | // |Grouping Operators| | | // |( ) |Precedence grouping|/Products?$filter=(Price sub 5) gt 10 | // // // A BNF for SQL where clauses (just for reference), see // http://dev.mysql.com/doc/refman/5.7/en/expressions.html for more information. // // expr: // expr OR expr // | expr || expr // | expr XOR expr // | expr AND expr // | expr && expr // | NOT expr // | ! expr // | boolean_primary IS [NOT] {TRUE | FALSE | UNKNOWN} // | boolean_primary // // boolean_primary: // boolean_primary IS [NOT] NULL // | boolean_primary <=> predicate // | boolean_primary comparison_operator predicate // | boolean_primary comparison_operator {ALL | ANY} (subquery) // | predicate // // comparison_operator: = | >= | > | <= | < | <> | != // Translate Odata filter operators to sql operators. // Strings not matched are just returned as is var translateOp = function(s) { // translation from OData filter to SQL where clause for the supported operators var op = []; op['eq'] = '='; op['ne'] = '<>'; op['gt'] = '>'; op['ge'] = '>='; op['lt'] = '<'; op['le'] = '<='; op['and'] = 'and'; op['or'] = 'or'; op['not'] = 'not'; op['add'] = '+'; op['sub'] = '-'; op['mul'] = '*'; op['div'] = '/'; op['mod'] = 'mod'; return (op[s.toLowerCase()] !== undefined) ? op[s.toLowerCase()] : s; }; // take a string with a filter expresssion and translate into a SQL expression var filter2where = function(expr) { // check for functions and groupings. These are not supported. if (expr.indexOf('(') > -1) { throw new Error('Functions and groupings are not supported: ' + expr); } // remove multiple spaces expr = expr.replace(/\s{2,}/g, ' '); // create array of tokens expr = expr.split(' '); // translate operators and create a string return u.map(expr, translateOp).join(' '); }; // Build the SQL statement // ------------------------ // // Supported OData Query strings: // // * `$orderby=col[ asc|desc]` - SQL: `order by` // * `$filter=...` - SQL: `where` clause // * `$skip=N` - `$orderby` must supplied for the result to be reliable // * `$select=col,col` - columns in the `select` // // Not supported: // // * `$top` // * `$inlinecount` // * `$expand` // * `$format` - only json is supported // // // **SQL BNF (for reference)** // // See http://dev.mysql.com/doc/refman/5.7/en/select.html for more details // // 1.SELECT // [ALL | DISTINCT | DISTINCTROW ] // [HIGH_PRIORITY] // [MAX_STATEMENT_TIME] // [STRAIGHT_JOIN] // [SQL_SMALL_RESULT] [SQL_BIG_RESULT] [SQL_BUFFER_RESULT] // [SQL_CACHE | SQL_NO_CACHE] [SQL_CALC_FOUND_ROWS] // select_expr [, select_expr ...] // 2. [FROM table_references // [PARTITION partition_list] // 3. [WHERE where_condition] // 4. [GROUP BY {col_name | expr | position} // [ASC | DESC], ... [WITH ROLLUP]] // 5. [HAVING where_condition] // 6. [ORDER BY {col_name | expr | position} // [ASC | DESC], ...] // 7. [LIMIT {[offset,] row_count | row_count OFFSET offset}] // 8. [PROCEDURE procedure_name(argument_list)] // 9. [INTO OUTFILE 'file_name' // [CHARACTER SET charset_name] // export_options // | INTO DUMPFILE 'file_name' // | INTO var_name [, var_name]] // [FOR UPDATE | LOCK IN SHARE MODE]] // var odata2sql = function(param, key) { // Default number of rows to return var defaultRowCount = global.CONFIG.ODATA.DEFAULT_ROW_COUNT; // `id` is used to sort the statements in the right order switch (key) { case '$orderby': return { id: 6, q: ' order by ' + param }; case '$filter': return { id: 3, q: ' where ' + filter2where(param) }; case '$skip': return { id: 7, q: ' limit ' + param + ',' + defaultRowCount }; case '$select': return { id: 1, q: 'select ' + param }; case '$top': throw Error('Unsupported query: ' + key); case '$inlinecount': throw Error('Unsupported query: ' + key); case '$expand': throw Error('Unsupported query: ' + key); case '$format': throw Error('Only JSON is supported!'); default: throw Error('Invalid query: ' + key); } }; // // Take the json object created by `odata2sql`, sort them in the correct order // create a string with the SQL var reduce = function(sqlObjects) { // create a string from the objects return u.reduce( sqlObjects, function(memo, o) { return memo + o.q; }, ""); }; // Just an empty constructor exports.ODataUri2Sql = function() { var self = this; }; // // parse the URI using a simple BNF grammar // ------------------------------------ // // This BNF describes the operations that the OData server support. // // ``` // slash ('/'') is the delimiter for tokens // // URI like: /help // <method,uri> ::= basic_uri // | system_uri // | table_uri // | metadata_uri // // <method,basic_uri> ::= <GET,'help'> // | <POST,'create_account'> // | <POST,'delete_account'> // // URI like /account/s/system_operation // <method,system_uri> ::= <GET,variable 's' reset_password variable> -> [reset_password,account,resetToken] // | <POST,variable 's' system_operation> -> [system_operation,account] // system_operation ::= 'create_table' // | 'create_bucket' // | 'delete_table' // | 'drop_bucket' // | 'grant' // | 'revoke' // | 'reset_password' // // URI like /account/table/$metadata // <method, metadata_uri> ::= <GET, variable variable '$metdata'> // // URI like /account/table // <method,table_uri> ::= <GET,variable> -> [service_def,account] // | <GET,variable variable> -> [select,account,table] // | <POST,variable variable> -> [insert,account,table] // | <PUT,variable variable> -> [update,account,table] // | <DELETE,variable variable> -> [delete,account,table] // // variable ::= SQL schema or table name // ``` var parseUri = function(method, tokens) { var res = parseBasicUri(method, tokens) || parseSystemUri(method, tokens) || parseMetadataUri(method, tokens) || parseTableUri(method, tokens); log.debug('parseUri: ' + JSON.stringify(res)); // indexing with `table(x)` is not supported if (res.table !== undefined && res.table.indexOf('(') > -1) { throw new Error('The form /schema/entity(key) is not supported.' + ' Use $filter instead.'); } return res; }; // URI:s like `/help` and `/create_account` var parseBasicUri = function(method, tokens) { log.debug('parseBasicUri method: ' + method + ' tokens: ' + tokens); if (method === 'GET' && tokens[0] === 'help' && tokens.length === 1) { return { queryType: 'help' }; } if (method === 'POST' && tokens[0] === 'create_account' && tokens.length === 1) { return { queryType: 'create_account' }; } return false; }; // URI:s like `/account/s/create_table` var parseSystemUri = function(method, tokens) { if (method === 'POST' && tokens.length === 3 && tokens[1] === global.CONFIG.ODATA.SYS_PATH && ['reset_password', 'delete_account', 'create_bucket', 'drop_bucket', 'create_table', 'grant', 'revoke', 'delete_table' ].indexOf(tokens[2]) !== -1) { return { queryType: tokens[2], schema: tokens[0] }; } if (method === 'GET' && tokens.length === 4 && tokens[1] === global.CONFIG.ODATA.SYS_PATH && tokens[2] === 'reset_password') { return { queryType: tokens[2], schema: tokens[0], resetToken: tokens[3] }; } return false; }; // URI:s like `/account/table/$metdata` var parseMetadataUri = function(method, tokens) { if (method === 'GET' && tokens.length === 3 && tokens[2] === '$metadata') { return { queryType: 'metadata', schema: tokens[0], table: tokens[1] }; } return false; }; // URI:s like `/account` or `/account/table`, `tokens = [account,table]` var parseTableUri = function(method, tokens) { if (method === 'GET' && tokens.length === 1) { return { queryType: 'service_def', schema: tokens[0] }; } if (method === 'GET' && tokens.length === 2) { return { queryType: 'select', schema: tokens[0], table: tokens[1] }; } if (method === 'POST' && tokens.length === 2) { return { queryType: 'insert', schema: tokens[0], table: tokens[1] }; } if (method === 'PUT' && tokens.length === 2) { return { queryType: 'update', schema: tokens[0], table: tokens[1] }; } if (method === 'DELETE' && tokens.length === 2) { return { queryType: 'delete', schema: tokens[0], table: tokens[1] }; } return false; }; exports.ODataUri2Sql.prototype.parseUri2 = function(method, inputUri) { var url = require('url'); var parsedUri_ = url.parse(inputUri, true, false); // get the schema and table name var a_ = parsedUri_.pathname.split("/"); // drop the first element which is an empty string var tokens_ = a_.splice(1, a_.length); var result = parseUri(method, tokens_); // translate odata queries in URI to sql var sqlObjects = u.map(parsedUri_.query, odata2sql); // Build the select statement if (result.queryType === 'select') { sqlObjects.push({ id: 2, q: ' from ' + result.schema + '.' + result.table }); // sort the query objects according to the sql specification sqlObjects = u.sortBy(sqlObjects, function(o) { return o.id; }); // add `select *` if there is no `$select` if (sqlObjects[0].id != 1) { sqlObjects.push({ id: 1, q: 'select *' }); } } // Check that there is no `where` statement in `insert` if (result.queryType === 'insert') { // check that there are no parameters if (!u.isEmpty(parsedUri_.query)) { throw new Error('Parameters are not supported in POST: ' + JSON.stringify(parsedURL.query)); } } // sort the query objects according to the sql specification sqlObjects = u.sortBy(sqlObjects, function(o) { return o.id; }); // create a string from the objects var sql = u.reduce( sqlObjects, function(memo, o) { return memo + o.q; }, ""); if (sql !== '') { result.sql = sql; } return result; }; // calculate the MD5 etag for a JSON object var etag = function(obj) { var crypto = require('crypto'); var md5 = crypto.createHash('md5'); for (var key in obj) { md5.update('' + obj[key]); } return md5.digest('hex'); }; // Add an etag property to an object exports.ODataUri2Sql.prototype.addEtag = function(obj) { // return a clone var o = u.clone(obj); var e = etag(obj); o['@odata.etag'] = e; return o; }; // // Implement the ODataServer class // ================================ // // These operations requires root credentials: // // * create user (account) (POST /createAccount data={email=...}) // * reset password for user (POST /resetPassword data={email=...}) // * delete user (DELETE /deleteAccount data={accountID=...} ) // * get service definition (table definition) // // These operations use account credentials: // // * grant privs to user (POST /privileges data={accountID=..., entity=...} ) // * revoke privs from user (DELETE /privileges data={accountID=..., entity=...} )) // * create table // * drop table // * CRUD operations // // Some helpers used for resetting passwords // ----------------------------------------- // var generateUUID = function() { var d = new Date().getTime(); var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = (d + Math.random() * 16) % 16 | 0; d = Math.floor(d / 16); return (c == 'x' ? r : (r & 0x7 | 0x8)).toString(16); }); return uuid; }; var mailResetLink = function(email, accountId) { // generate random token var token = generateUUID(); // create the resetTokens object if it doesn't exist moduleSelf.resetTokens = moduleSelf.resetTokens || {}; // save the Token moduleSelf.resetTokens[token] = { accountId: accountId, timeStamp: Date.now() }; log.debug('Saved reset token: ' + token + ' - ' + JSON.stringify(moduleSelf.resetTokens[token])); // The link that is used to reset a password var resetLink = 'http://' + global.CONFIG.ODATA.HOST + ':' + global.CONFIG.ODATA.PORT + '/' + accountId + '/' + global.CONFIG.ODATA.SYS_PATH + '/reset_password/' + token; // create transporter object using SMTP transport var transporter = nodemailer.createTransport(global.CONFIG.nodeMailerOptions); // email data var mailOptions = u.clone(global.CONFIG.MAIL_OPTIONS); mailOptions.to = email; mailOptions.text += resetLink; mailOptions.html += resetLink; // send mail with defined transport object transporter.sendMail(mailOptions, function(error, info) { if (error) { log.log(error); } else { log.debug('Mail reset link to ' + email + '. SMTP Server reply: ' + info.response); } }); }; var getAccountIdFromToken = function(token) { if (moduleSelf.resetTokens[token] === undefined || moduleSelf.resetTokens[token] === null) { throw new Error('Invalid reset password token.'); } if (Date.now() - moduleSelf.resetTokens[token].timeStamp > global.CONFIG.ODATA.EXPIRE_RESET_PASSWORD) { throw new Error('Reset password token has expired.'); } log.debug('Retrieving accountId from token: ' + token + ' - ' + JSON.stringify(moduleSelf.resetTokens[token])); var accountId = moduleSelf.resetTokens[token].accountId; // The token can only be used once moduleSelf.resetTokens[token] = null; return accountId; }; // ODataServer object // ------------------- // // empty constructor exports.ODataServer = function() { log.debug('new ODataServer'); }; // HTTP REST Server that exports.ODataServer.prototype.main = function(request, response, next) { log.debug('In main ...'); if (response.finished) { next(); } var uriParser = new exports.ODataUri2Sql(); var odataRequest; // Parse the URI and write any errors back to the client try { odataRequest = uriParser.parseUri2(request.method, request.url); if (!odataRequest) { h.writeError(response, 'Could not parse URI: ' + request.url + ' with HTTP method: ' + request.method); } log.debug('odataRequest: ' + JSON.stringify(odataRequest)); } catch (e) { h.writeError(response, e); next(); return; } // Check that the MySQL credentials have been supplied, not required when // creating a new account or resetting password if (odataRequest.queryType != 'create_account' && odataRequest.queryType != 'reset_password' && !h.checkCredentials(request, response)) { h.writeError(response, "Invalid credentials, user or password missing. " + "URL: " + request.url + ", headers: " + JSON.stringify(request.headers) + " TYPE:" + odataRequest.queryType); next(); return; } // save input from POST and PUT here var data = ''; request // read the data in the stream, if there is any .on('error', function(err) { var str = "Error in http input stream: " + err + ", URL: " + request.url + ", headers: " + JSON.stringify(request.headers) + " TYPE:" + odataRequest.queryType; log.log(str); h.writeError(response, str); }) // read the data in the stream, if there is any .on('data', function(chunk) { log.debug('Receiving data'); data += chunk; }) // request closed, .on('close', function() { log.debug('http request closed.'); }) // request closed, process it .on('end', function() { log.debug('End of data'); try { // parse odata payload into JSON object var jsonData = null; if (data !== '') { jsonData = h.jsonParse(data); log.debug('Data received: ' + JSON.stringify(jsonData)); } var mysqlAdmin; var accountId = request.headers.user; var password = request.headers.password; var options = { credentials: { database: accountId, user: accountId, password: password }, closeStream: true }; var adminOptions = { credentials: { user: global.CONFIG.RDBMS.ADMIN_USER, password: global.CONFIG.RDBMS.ADMIN_PASSWORD }, closeStream: true }; log.debug('Processing request - credentials: ' + JSON.stringify(options) + ', odataRequest: ' + JSON.stringify(odataRequest) + ', JSON: ' + JSON.stringify(jsonData)); // queryType: create_user | set_password | delete_user // queryType: service_def | create_table | delete_table | select | insert | delete, var decoder = new StringDecoder('utf8'); var bucket = new h.arrayBucketStream(); var odataResult = {}; // operations performed with admin/root credentials if (adminCredentialOps.indexOf(odataRequest.queryType) !== -1) { if (odataRequest.queryType === 'create_account' && !global.CONFIG.ODATA.CREATE_ACCOUNTS_WITHOUT_CREDENTIALS) { // do not allow account creation without credentials // must use the credentials supplied in the http header sqlAdmin = new Rdbms.sqlAdmin(options); log.debug('Performing operation ' + odataRequest.queryType + ' with credentials ' + options.credentials.user); } else { // Use the admin/root credentials sqlAdmin = new Rdbms.sqlAdmin(adminOptions); log.debug('Performing operation ' + odataRequest.queryType + ' with admin/root credentials'); } log.debug('odataRequest: ' + JSON.stringify(odataRequest)); var email = ''; if (odataRequest.queryType === 'create_account') { // calculate accountId from email accountId = h.email2accountId(jsonData.email); email = jsonData.email; sqlAdmin.new(accountId); } var password; if (odataRequest.queryType === 'reset_password') { // mail a reset password link if (odataRequest.resetToken === undefined) { // Make sure the mail is sent to the right address if (h.email2accountId(jsonData.email) !== jsonData.accountId) { h.writeError(response, 'Incorrect reset_password request'); } log.debug('Mail reset password link.'); mailResetLink(jsonData.email, jsonData.accountId); // NOTE: allow to reset password without link, used for testing if (!CONSTANTS.TEST.RESET_PASSWORD_WITHOUT_LINK) { h.writeResponse(response, { message: 'Check your mail!' }); return; } } else { jsonData = jsonData || {}; jsonData.accountId = getAccountIdFromToken(odataRequest.resetToken); } password = sqlAdmin.resetPassword(jsonData.accountId); } if (odataRequest.queryType === 'delete_account') { sqlAdmin.delete(accountId); } if (odataRequest.queryType === 'service_def') { sqlAdmin.serviceDef(accountId); } sqlAdmin.pipe2(bucket) .then(function() { odataResult.email = email; odataResult.accountId = accountId; if (odataRequest.queryType === 'reset_password') { odataResult.password = password; } // Make sure that correct credentials have been supplied if (odataRequest.queryType === 'service_def') { var bucket2 = new h.arrayBucketStream(); var userInfo = new Rdbms.userInfo(options); return userInfo.pipe2(bucket2) .then(function() { var userOk = (bucket2.get().toString() .indexOf('ER_ACCESS_DENIED_ERROR') === -1); if (!userOk) { throw bucket2.get().toString(); } }); } }) .then(function() { // The RDBMS response is JSON but it is not parsed since that // sometimes fails (reason unknown) odataResult.rdbmsResponse = JSON.parse(decoder.write(bucket.get())); h.writeResponse(response, odataResult); next(); }) .catch(function(err) { h.writeError(response, err); next(); }); return; } log.debug('Performing operation ' + odataRequest.queryType + ' with ' + accountId + ' credentials'); odataResult.accountId = accountId; // operations performed with objects inheriting from the the rdbms base object if (['grant', 'revoke', 'create_table', 'delete_table', 'delete', 'update', 'metadata' ].indexOf(odataRequest.queryType) !== -1) { var rdbms; if (odataRequest.queryType === 'grant') { rdbms = new Rdbms.sqlAdmin(options); rdbms.grant(jsonData.tableName, jsonData.accountId); } if (odataRequest.queryType === 'revoke') { rdbms = new Rdbms.sqlAdmin(options); rdbms.revoke(jsonData.tableName, jsonData.accountId); } if (odataRequest.queryType === 'create_table') { options.tableDef = jsonData.tableDef; rdbms = new Rdbms.sqlCreate(options); } if (odataRequest.queryType === 'delete_table') { options.tableName = jsonData.tableName; rdbms = new Rdbms.sqlDrop(options); } if (odataRequest.queryType === 'delete') { options.tableName = odataRequest.table; options.sql = odataRequest.sql; rdbms = new Rdbms.sqlDelete(options); } if (odataRequest.queryType === 'update') { options.tableName = odataRequest.table; options.jsonData = jsonData; options.sql = odataRequest.sql; rdbms = new Rdbms.sqlUpdate(options); } if (odataRequest.queryType === 'metadata') { rdbms = new Rdbms.sqlAdmin(options); rdbms.metadata(odataRequest.table, odataRequest.schema); } rdbms.pipe(bucket, function() { odataResult.rdbmsResponse = JSON.parse('[' + decoder.write(bucket.get()) + ']'); h.writeResponse(response, odataResult); next(); }, function(err) { h.writeError(response, err); next(); } ); return; } // Handle select, insert and unknow query types switch (odataRequest.queryType) { case 'select': options.sql = odataRequest.sql; options.processRowFunc = h.addEtag; log.debug('Pipe values of the mysql stream to the response ' + '- options: ' + JSON.stringify(options)); var mysqlRead = new Rdbms.sqlRead(options); mysqlRead.fetchAll(function(res) { odataResult.value = res; h.writeResponse(response, odataResult); next(); }); break; // NOTE: Could move this out of end event and pipe request into mysql case 'insert': options.tableName = odataRequest.table; options.resultStream = bucket; var writeStream = new Rdbms.sqlWriteStream(options, function() { odataResult.rdbmsResponse = JSON.parse(decoder.write(bucket.get())); h.writeResponse(response, odataResult); next(); } ); // create stream that writes json into rdbms var jsonStream = new require('stream'); jsonStream.pipe = function(dest) { dest.write(JSON.stringify(jsonData)); next(); }; jsonStream.pipe(writeStream); break; default: h.writeError(response, 'Error, unknown queryType: ' + odataRequest.queryType); next(); } } catch (e) { h.writeError(response, e); } }); };