From 35c75a392239201acf8491b0bbeb10172e45d1de Mon Sep 17 00:00:00 2001 From: Ravi Gairola Date: Sun, 16 Oct 2016 16:35:09 -0700 Subject: [PATCH] Combined api router with version router into one --- index.js | 81 ++- lib/responder.js | 142 +++++ lib/verifier.js | 297 +++++++++++ test/api-router.test.js | 299 +++++++++++ test/responder.test.js | 164 ++++++ test/verifier.test.js | 485 ++++++++++++++++++ ...{router.test.js => version-router.test.js} | 2 +- 7 files changed, 1454 insertions(+), 16 deletions(-) create mode 100644 lib/responder.js create mode 100644 lib/verifier.js create mode 100644 test/api-router.test.js create mode 100644 test/responder.test.js create mode 100644 test/verifier.test.js rename test/{router.test.js => version-router.test.js} (99%) diff --git a/index.js b/index.js index 1818775..3e8488c 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,9 @@ +var apiVerifier = require('./lib/verifier'); var express = require('express'); +var path = require('path'); +var responder = require('./lib/responder'); var semver = require('semver'); -var versionHandler = require('./lib/version'); +var versionVerifier = require('./lib/version'); /** @@ -42,7 +45,7 @@ var versionHandler = require('./lib/version'); * @type {string[]} The method names npm */ var methods = [ - 'all', 'param', 'get', 'post', 'put', 'head', 'delete', 'options', 'trace', 'copy', 'lock', 'mkcol', 'move', + 'all', 'get', 'post', 'put', 'head', 'delete', 'options', 'trace', 'copy', 'lock', 'mkcol', 'move', 'purge', 'propfind', 'proppatch', 'unlock', 'report', 'mkactivity', 'checkout', 'merge', 'm-search', 'notify', 'subscribe', 'unsubscribe', 'patch', 'search', 'connect' ]; @@ -64,35 +67,42 @@ const defaultConfig = { */ function Router(configuration = {}) { configuration = Object.assign({}, defaultConfig, configuration); + configuration.prefix = normalizePrefix(configuration.prefix); let router = express.Router(configuration); let getRouter = generateRouter.bind({routers: [], configuration}); + let context = { + endpoints: {}, + router: null, + globalConfiguration: configuration + }; for (let method of methods) { let original = router[method]; router[method] = (path, ...args) => { if (typeof path != 'string' && !(path instanceof RegExp)) { throw new Error('First parameter needs to be a path (string or RegExp)') } - let epc = parseParams(original, method, path, args); - if (epc.path.toString().startsWith('/v:'+ configuration.param)) { + if (path.toString().startsWith('/v:'+ configuration.param)) { throw new Error('Versioned paths will be generated automatically, please avoid prefixing paths'); } + let epc = parseParams(original, method, path, args); let methodRouter = getRouter(epc.path, epc.method); - if (!(epc.path instanceof RegExp)) { - methodRouter[epc.method]('/v:' + configuration.param + epc.path, ...epc.handlers); - epc.original.call(router, '/v:' + configuration.param + epc.path, versionHandler.parseVersion.bind({ - configuration, - acceptVersion: epc.version, - router: methodRouter - })); - } - methodRouter[method](path, ...epc.handlers); - original.call(router, epc.path, versionHandler.parseVersion.bind({ + context.router = methodRouter; + let apiHandler = apiVerifier.configure(context, epc).bind(context); + let versionHandler = versionVerifier.parseVersion.bind({ configuration, acceptVersion: epc.version, router: methodRouter - })); + }); + if (!(epc.path instanceof RegExp)) { + methodRouter[epc.method]('/v:' + configuration.param + epc.path, apiHandler, ...epc.handlers); + epc.original.call(router, '/v:' + configuration.param + epc.path, versionHandler); + } + methodRouter[method](path, apiHandler, ...epc.handlers); + original.call(router, epc.path, versionHandler); } } + router.__defineGetter__('endpoints', prefixEndpoints.bind(context)); + router.api = api.bind(context); return router; } @@ -115,7 +125,9 @@ function parseParams(original, method, path, args, config = {original, method, p case 'object': if (arg instanceof RegExp) { config.version.push(arg); + break; } + config.api = arg; break; case 'number': case 'string': @@ -160,4 +172,43 @@ function generateRouter(endpoint, method) { return router; } +/** + * Returns either an empty string or a normalized path of the prefix + * @param {string} prefix + * @returns {string} + */ +function normalizePrefix(prefix) { + if (!prefix || typeof prefix !== 'string' || !prefix.trim().length) { + return ''; + } + return path.normalize(prefix); +} + +/** + * Getter implementation that will return the currently configured enpoints. + * @returns {Object.>} Api map with endpoint config nested in path and method. + * @this Context + */ +function prefixEndpoints(prefix = this.globalConfiguration.prefix) { + var map = {}; + for (let prop in this.endpoints) { + map[path.join(prefix, prop)] = Object.assign({}, this.endpoints[prop]); + } + return map; +} + +/** + * A standard request handler implementation that will respond with the currently configured api for this router. Can be used to make + * it easier for developers to work with your API. + * @param {ClientRequest} req An express client request object + * @param {ServerResponse} res An express server response object + * @this Context + */ +function api(req, res) { + var url = req.originalUrl; + var prefix = url.substr(0, url.lastIndexOf(req.route.path)); + prefix = prefix.substr(0, prefix.lastIndexOf(this.globalConfiguration.prefix)); + responder.respond(req, res, prefixEndpoints.call(this, prefix.length ? prefix : this.globalConfiguration.prefix)); +} + module.exports = Router; diff --git a/lib/responder.js b/lib/responder.js new file mode 100644 index 0000000..c41ac83 --- /dev/null +++ b/lib/responder.js @@ -0,0 +1,142 @@ +'use strict'; + +/** + * + * @param {ClientRequest} req The client request object + * @param {ServerResponse} res The server response object + * @param {Object} payload The data to be send to the client + * @param {number} [status] A status code that will be set if you don't want express to set one for you + */ +exports.respond = function(req, res, payload, status) { + status && res.status(status); + if (!payload || !Object.keys(payload).length) { + status || res.status(204); + return res.end(); + } + var format = req.params && req.params['format'] || req.query && req.query['format'] || 'json'; + switch(format.toLowerCase()) { + case 'json': + return res.jsonp ? res.jsonp(payload).end() : res.json(payload).end(); + case 'tree': + return res.send('' + JSON.stringify(payload, null, 4) + '').end(); + case 'table': + return res.send(exports.formatTable(payload)).end(); + case 'csv': + return res.send(exports.formatCSV(payload)).end(); + case 'xml': + return res.send('' + exports.formatXML(payload)).end() + ''; + default: + return res.status(422).send('Invalid format requested').end(); + } +}; + +/** + * Creates an HTML table of the json data passed in. + * @param {Object} payload + * @returns {string} + */ +exports.formatTable = function (payload) { + var table = exports.flatten(payload); + var response = '

' + table.title + '

'; + for (let header of table.headers) { + response += ''; + } + response += ''; + for (let row of table.rows) { + response += ''; + for (let column of row) { + response += ''; + } + response += ''; + } + response += '
' + header + '
' + column + '
'; + return response; +}; + +/** + * Creates a CSV table of the json data passed in. + * @param {Object} payload + * @returns {string} + */ +exports.formatCSV = function (payload) { + var table = exports.flatten(payload); + var response = table.headers.join(',') + '\n'; + for (let row of table.rows) { + for(let i = 0; i < row.length; i++) { + row[i].replace(/"/g, '\\"'); + if (row[i].indexOf(',') !== -1) { + row[i] = '"' + row[i] + '"'; + } + } + response += row.join(',') + '\n'; + } + return response; +}; + +/** + * A response that can be used by the different formatters to construct a table response out of the JSON tree structure. + * @typedef {Object} TableResponse + * @property {string} title A title that should be printed at the head of a page + * @property {string[]} headers A list of headers to be printed before the actual data + * @property {Array.} rows A list of rows represented by lists of strings to be printed in order + */ + +/** + * Converts the known response formats into a list of map like structure to represent a table + * @param {Object} payload + * @returns {TableResponse} The response to be printed + */ +exports.flatten = function(payload) { + var response = { + rows: [] + }; + if (payload.error) { + response.title = payload.error; + response.headers = [ 'param', 'type', 'error', 'max', 'min']; + for (let param in payload.params) { + response.rows.push([ + param, + payload.params[param].type, + payload.params[param].error, + isNaN(payload.params[param].max) ? '' : payload.params[param].max, + isNaN(payload.params[param].min) ? '' : payload.params[param].min + ]) + } + } else { + response.title = 'Api Map'; + response.headers = [ 'path', 'method', 'description', 'param', 'type', 'paramDescription']; + for (let path in payload) { + for (let method in payload[path]) { + for (let param in payload[path][method].params) { + response.rows.push([ + path, + method, + payload[path][method].description, + param, + payload[path][method].params[param].type, + payload[path][method].params[param].description + ]); + } + } + } + } + return response; +}; + + + +/** + * Creates an XML response of the json data passed in. + * @param {Object|string} payload + * @returns {string} + */ +exports.formatXML = function (payload) { + var response = ''; + if (typeof payload == 'object') { + for (let prop in payload) { + response += '<' + prop + '>' + exports.formatXML(payload[prop]) + ''; + } + return response; + } + return payload; +}; diff --git a/lib/verifier.js b/lib/verifier.js new file mode 100644 index 0000000..13c611c --- /dev/null +++ b/lib/verifier.js @@ -0,0 +1,297 @@ +'use strict'; + + +var responder = require('./responder'); + +/** + * @typedef {object} EndpointConfig + * @private + * @property {function} original The original function (get, post, ...) on the router + * @property {string} method The name of that original function + * @property {string|RegExp} path The path definition, same as on the original router + * @property {EndpointConfig} config The configuration for this endpoint + * @property {function[]} handlers Any handlers that should be passed on to the original router + */ + +/** + * @typedef {Object} ParamDef + * @property {string} type The data type that this parameter is expected to be + * @property {*} [default] The default value to set the parameter to if it's not coming in through the request + * @property {boolean} [required=false] Whether the request should be rejected if this parameter is missing + * @property {boolean} [array=false] Whether the incoming parameter is expected to be treated like an array + * @property {string} [description] A description of the parameter that will be printed with the endpoints api info + * @property {number} [min] min characters for string, min value for number, ignored for boolean + * @property {number} [max] max characters for string, min value for number, ignored for boolean + * @property {validateCb} [validate] A validator the overrides the default behavior for this parameter + */ + +/** + * @callback validateCb + * @param {string} value The value received from the request + * @param {string} name The name of the parameter that we're checking + * @param {ParamDef} config The configuration for this parameter + * @returns {string} An error message or any falsy value if the parameter is valid + */ + +/** + * @typedef {Object} Context + * @property {function} router The original express router + * @property {RouterConfig} globalConfiguration The router global configuration + * @property {Object.} endpoints A map of endpoints to store configurations in + */ + +/** + * A path configuration function that will prepare the verifier with the given configuration and then call the same + * method on the original router. + * @param {Context} context + * @param {string} method The name of that original function + * @param {string|RegExp} path The path definition, same as on the original router + * @param {EndpointConfig} api The configuration for this endpoint + * @this Context + * @returns {*} Whatever the original function returns. + */ +exports.configure = function(context, { path, method, api}) { + if (!api || typeof api === 'function' || method == 'param') { + return (req, res, next) => next(); + } + api.paramOrder = api.paramOrder || context.globalConfiguration.paramOrder; + api.paramMap = api.paramMap || context.globalConfiguration.paramMap || 'args'; + if (api.params) { + for (let param in api.params) { + var parsed = exports.parseParam(api.params[param]); + parsed.error = parsed.error | api.error || context.globalConfiguration.error; + parsed.validate = parsed.validate || api.validate || context.globalConfiguration.validate; + parsed.success = parsed.success || api.success || context.globalConfiguration.success; + api.params[param] = parsed; + } + } + context.endpoints[path] = context.endpoints[path] || {}; + context.endpoints[path][method.toUpperCase()] = api; + return exports.verify; +}; + +/** + * Converts a string parameter into a parameter definition. + * @param {string|ParamDef} str + * @returns {ParamDef} + */ +exports.parseParam = function(str) { + if (typeof str == 'string') { + var match = str.match(/(\w+)(\[])?(\(([^)]*)\))?/); + var type = match[1].trim().toLowerCase(); + var array = !!match[2]; + var required = !match[3]; + var def = exports.parseValue(type, match[4], array); + return { type, default: def, required, array } + } + if (typeof str == 'object') { + switch(str.required) { + case 0: case 'FALSE': case 'false': case 'F': case 'f': case 'no': case 'n': case false: + str.required = false; + break; + default: + str.required = !str.default; + } + str.default = str.default || undefined; + str.array = !!str.array + return str; + } + throw new Error('Given parameter is incompatible'); +}; + +/** + * + * @param {string} type The type of value to be parsed from the given string + * @param {string} value The string from which to parse the value from + * @param {boolean} array Whether the value is expected to be an array + * @returns {*} + */ +exports.parseValue = function(type, value, array) { + if (Array.isArray(value)) { + return value.map(entry => exports.parseValue(type, entry, false)); + } else if (value && array) { + return value.split(',').map(entry => exports.parseValue(type, entry, false)); + } + switch (type) { + case 'string': + value && value.length || (value = undefined); + return value; + case 'bool': case 'boolean': + switch (value && value.trim().toLowerCase()) { + case 'true': case 't': case 'yes': case 'y': case '1': + return true; + case 'false': case 'f': case 'no': case 'n': case '0': + return false; + default: + return undefined; + } + case 'number': case 'float': case 'double': + value = parseFloat(value); + isNaN(value) && (value = undefined); + return value; + case 'integer': case 'short': + value = parseInt(value); + isNaN(value) && (value = undefined); + return value; + default: + throw new Error('Invalid type defined for parameter: ' + type); + } +}; + +/** + * This function is called with every request on this router and verifies incoming parameters and auto populates default + * values on missing parameters. + * @param {ClientRequest} req The incoming http request + * @param {ServerResponse} res The outgoing http response + * @param {function} next The chaining function used to call the next handler for this endpoint + * @this Context + */ +exports.verify = function(req, res, next) { + var config = this.endpoints[req.route.path][req.method]; + try { + var params = exports.getParams(config, req); + } catch (e) { + if (config.error) { + return config.error(e, req, res, next); + } + return responder.respond(req, res, { + error: e.message, + params: {} + }, 422); + } + var missing = exports.checkParams(config, params); + if (Object.keys(missing).length) { + if (config.error) { + return config.error(missing, req, res, next) + } + if (process.env.NODE_ENV == 'development') { + return responder.respond(req, res, { + error: 'Required parameters are missing', + params: missing + }, 422); + } + return responder.respond(req, res, null, 422); + } + req[config.paramMap] = exports.fillParams(config, params); + if (config.success) { + return config.success(null, req, res, next); + } + next(); +}; + +/** + * Retrieves any parameters that are on the client request. + * @param {EndpointConfig} config The configuration of this endpoint + * @param {ClientRequest} req The express client request + * @returns {Object.} A map with all the passed in parameters that were found on the request object + */ +exports.getParams = function(config, req) { + var params = {}; + for (let prop of config.paramOrder) { + if (req[prop]) { + for (let param in req[prop]) { + if (params[param] === undefined) { + params[param] = exports.parseValue(config.params[param].type, req[prop][param], config.params[param].array); + } + } + } + } + return params; +}; + +/** + * @typedef {Object} MissingInfo + * @property {string} type The parameter type such as boolean, number or string + * @property {string} error A short description of the error that occurred + * @property {number} [min] If the parameter is out of range, information about the range settings + * @property {number} [max] If the parameter is out of range, information about the range settings + */ + +/** + * Verifies that all required parameters are set and fulfill all requirements. + * @param {EndpointConfig} config The configuration for this endpoint + * @param {Object.} params The parameters that have been found on the client request + * @returns {Object.} A map of parameter names and any errors that have been found with them + */ +exports.checkParams = function(config, params) { + var errors = {}; + for (let param in config.params) { + var value = params[param]; + var paramConfig = config.params[param]; + if (paramConfig.validate) { + var error = paramConfig.validate(value, param, paramConfig); + if (error) { + errors[param] = { type: paramConfig.type, error } + } + continue; + } + if (paramConfig.required && (!value || Array.isArray(value) && !value.length)) { + errors[param] = { + type: paramConfig.type, + error: 'not set' + } + } + if (value) { + if (!isNaN(paramConfig.max)) { + switch (paramConfig.type) { + case 'string': + if (value.length > paramConfig.max) { + errors[param] = { + type: paramConfig.type, + error: 'value exceeds max value', + max: paramConfig.max + } + } + break; + case 'number': + if (value > paramConfig.max) { + errors[param] = { + type: paramConfig.type, + error: 'value exceeds max value', + max: paramConfig.max + } + } + break; + } + } + if (!isNaN(paramConfig.min)) { + switch (paramConfig.type) { + case 'string': + if (value.length < paramConfig.min) { + errors[param] = { + type: paramConfig.type, + error: 'value below min value', + min: paramConfig.min + } + } + break; + case 'number': + if (value < paramConfig.min) { + errors[param] = { + type: paramConfig.type, + error: 'value below min value', + min: paramConfig.min + } + } + break; + } + } + } + } + return errors; +}; + +/** + * Sets the default value for any parameter that hasn't been set by the client request. + * @param {EndpointConfig} config The configuration for this endpoint + * @param {Object.} params The parameters that have been found on the client request + * @returns {Object.} The parameter map with filled default values. + */ +exports.fillParams = function(config, params) { + for (let param in config.params) { + if (params[param] === undefined) { + params[param] = config.params[param].default; + } + } + return params; +}; diff --git a/test/api-router.test.js b/test/api-router.test.js new file mode 100644 index 0000000..4a0f043 --- /dev/null +++ b/test/api-router.test.js @@ -0,0 +1,299 @@ +/* global describe, it, beforeEach, afterEach */ +'use strict'; + +var stream = require('stream'); + +var expect = require('chai').expect; +var express = require('express'); +var request = require('supertest'); + +var Router = require('..'); + + +describe('Api Router', () => { + it('should process normal requests same as the default router', done => { + var router = Router(); + router.get('/test', (req, res) => res.end('success')); + + var app = express(); + app.use(router); + request(app).get('/test').expect(200, 'success').end(done); + }); + + it('should make all incoming and default parameters available on the request handler', done => { + var router = Router(); + var config = { + params: { + var1: 'number', + var2: 'string(foo)' + } + }; + + router.get('/test', config, (req, res) => { + expect(req.args.var1).to.equal(25); + expect(req.args.var2).to.equal('foo'); + res.end('success'); + }); + + var app = express(); + app.use(router); + request(app).get('/test?var1=25').expect(200).end(done); + }); + + it('should make all be able to use parameters from multiple sources', done => { + var router = Router(); + var config = { + params: { + var1: 'number', + var2: 'string(foo)', + var3: 'bool' + } + }; + + router.get('/test/:var3', config, (req, res) => { + expect(req.args.var1).to.equal(25); + expect(req.args.var2).to.equal('foo'); + expect(req.args.var3).to.equal(true); + res.end('success'); + }); + + var app = express(); + app.use(router); + request(app).get('/test/true?var1=25').expect(200).end(done); + }); + + it('should verify all incoming parameters', done => { + process.env.NODE_ENV = ''; + + var router = Router(); + var config = { + params: { + var1: 'number' + } + }; + router.get('/test', config, (req, res) => res.end('success')); + + var app = express(); + app.use(router); + request(app).get('/test').expect(422).end(done); + }); + + it('should verify all incoming parameters and complain about missing ones in development mode', done => { + process.env.NODE_ENV = 'development'; + + var router = Router(); + var config = { + params: { + var1: 'number', + var2: 'number(1)', + var3: 'string', + var4: 'string(test)', + var5: 'boolean', + var6: 'boolean(true)' + } + }; + router.get('/test', config, (req, res) => res.end('success')); + + var app = express(); + app.use(router); + request(app).get('/test').expect(422, { + error: 'Required parameters are missing', + params: { + var1: { error: 'not set', type: 'number' }, + var3: { error: 'not set', type: 'string' }, + var5: { error: 'not set', type: 'boolean' } + } + }).end(done); + }); + + it('should support arrays in get parameters', done => { + var router = Router(); + var config = { + params: { + var1: 'number[]' + } + }; + + router.get('/test', config, (req, res) => { + expect(req.args.var1).to.deep.equal([25, 30]); + res.end('success'); + }); + + var app = express(); + app.use(router); + request(app).get('/test?var1=25&var1=30').expect(200).end(done); + }); + + it('should support arrays in query parameters', done => { + var router = Router(); + var config = { + params: { + var1: 'number[]' + } + }; + + router.get('/test/:var1', config, (req, res) => { + expect(req.args.var1).to.deep.equal([25, 30]); + res.end('success'); + }); + + var app = express(); + app.use(router); + request(app).get('/test/25,30').expect(200).end(done); + }); + + it('should return an api map', done => { + var router = Router(); + var config = { + description: 'An express endpoint', + params: { + var1: 'number' + } + }; + + router.get('/test', config, (req, res) => {}); + + var app = express(); + app.get('/api', router.api); + request(app).get('/api').expect(200, { + '/test': { + GET: { + description: 'An express endpoint', + paramMap: 'args', + paramOrder: [ 'params', 'query', 'cookie', 'body', 'header' ], + params: { + var1: { + array: false, + required: true, + type: 'number' + } + } + } + } + }).end(done); + }); + + it('should return a prefixed api path', done => { + var router = Router({ prefix: '/prefix' }); + var config = { + description: 'An express endpoint', + params: { + var1: 'number' + } + }; + + router.get('/test', config, (req, res) => {}); + + expect(router.endpoints).to.deep.equal({ + '/prefix/test': { + GET: { + description: 'An express endpoint', + paramMap: 'args', + paramOrder: [ 'params', 'query', 'cookie', 'body', 'header' ], + params: { + var1: { + array: false, + required: true, + type: 'number', + default: undefined, + error: undefined, + success: undefined, + validate: undefined + } + } + } + } + }); + + var app = express(); + app.get('/api', router.api); + request(app).get('/api').expect(200, { + '/prefix/test': { + GET: { + description: 'An express endpoint', + paramMap: 'args', + paramOrder: [ 'params', 'query', 'cookie', 'body', 'header' ], + params: { + var1: { + array: false, + required: true, + type: 'number' + } + } + } + } + }).end(done); + }); + + it('should return a nested api path', done => { + var app = express(); + + var parentRouter = Router(); + app.use('/parent', parentRouter); + + var router = Router(); + parentRouter.use('/nested', router); + + var config = { + description: 'An express endpoint', + params: { + var1: 'number' + } + }; + router.get('/test', config, (req, res) => {}); + router.get('/api', router.api); + + request(app).get('/parent/nested/api').expect(200, { + '/parent/nested/test': { + GET: { + description: 'An express endpoint', + paramMap: 'args', + paramOrder: [ 'params', 'query', 'cookie', 'body', 'header' ], + params: { + var1: { + array: false, + required: true, + type: 'number' + } + } + } + } + }).end(done); + }); + + it('should allow the user to overwrite the api prefix', done => { + var app = express(); + + var parentRouter = Router(); + app.use('/parent', parentRouter); + + var router = Router({ prefix: '/custom'}); + parentRouter.use('/nested', router); + + var config = { + description: 'An express endpoint', + params: { + var1: 'number' + } + }; + router.get('/test', config, (req, res) => {}); + router.get('/api', router.api); + + request(app).get('/parent/nested/api').expect(200, { + '/custom/test': { + GET: { + description: 'An express endpoint', + paramMap: 'args', + paramOrder: [ 'params', 'query', 'cookie', 'body', 'header' ], + params: { + var1: { + array: false, + required: true, + type: 'number' + } + } + } + } + }).end(done); + }); +}); diff --git a/test/responder.test.js b/test/responder.test.js new file mode 100644 index 0000000..8e9f842 --- /dev/null +++ b/test/responder.test.js @@ -0,0 +1,164 @@ +/* global describe, it, beforeEach, afterEach */ +'use strict'; + +var expect = require('chai').expect; +var express = require('express'); +var request = require('supertest'); + +var responder = require('../lib/responder'); + + +describe('responder', () => { + describe('#flatten()', () => { + it('should convert an api response to a flattened table', () => { + var response = responder.flatten({ + '/test': { + 'GET': { + description: 'This is a test', + params: { + name: { + type: 'string', + description: 'The user name' + }, + age: { + type: 'number', + description: 'The users age' + } + } + } + } + }); + + expect(response).to.deep.equal({ + title: 'Api Map', + headers: [ 'path', 'method', 'description', 'param', 'type', 'paramDescription'], + rows: [ + [ '/test', 'GET', 'This is a test', 'name', 'string', 'The user name' ], + [ '/test', 'GET', 'This is a test', 'age', 'number', 'The users age' ] + ] + }); + }); + + it('should convert an error response to a flattened table', () => { + var response = responder.flatten({ + error: 'Required parameters are missing', + params: { + name: { + type: 'string', + error: 'not set' + }, + age: { + type: 'number', + error: 'value exceeds max value', + max: 100 + } + } + }); + + expect(response).to.deep.equal({ + title: 'Required parameters are missing', + headers: [ 'param', 'type', 'error', 'max', 'min'], + rows: [ + [ 'name', 'string', 'not set', '', '' ], + [ 'age', 'number', 'value exceeds max value', 100, ''] + ] + }); + }); + }); + + describe('#formatTable()', () => { + it('should convert a json object to HTML table', () => { + var response = responder.formatTable({ + error: 'This is a test', + params: { + name: { + type: 'string', + error: 'The user name' + }, + age: { + type: 'number', + error: 'The users age' + } + } + }); + + expect(response).to.equal('

This is a test

' + + '' + + '' + + '' + + '
paramtypeerrormaxmin
namestringThe user name
agenumberThe users age
'); + }); + }); + + describe('#formatCSV()', () => { + it('should convert a json object to CSV', () => { + var response = responder.formatCSV({ + error: 'This is a test', + params: { + name: { + type: 'string', + error: 'The "user" name' + }, + age: { + type: 'number', + error: 'The users, age' + } + } + }); + + expect(response).to.equal('param,type,error,max,min\nname,string,The "user" name,,\nage,number,"The users, age",,\n'); + }); + }); + + describe('#formatXML()', () => { + it('should convert a json object to XML', () => { + var response = responder.formatXML({ + error: 'This is a test', + params: { + name: { + type: 'string', + error: 'The user name' + }, + age: { + type: 'number', + error: 'The users age' + } + } + }); + + expect(response).to.equal('This is a teststring' + + 'The user namenumber' + + 'The users age'); + }); + }); + + describe('#respond()', () => { + it('should respond with an error if an undefined format is selected', done => { + var app = express(); + app.get('/:format', (req, res) => responder.respond(req, res, { params: {} })); + + request(app).get('/unknownFormat').expect(422, 'Invalid format requested').end(done) + }); + + it('should return a 204 status if the response is empty', done => { + var app = express(); + app.get('/:format', (req, res) => responder.respond(req, res, {})); + + request(app).get('/any?format=xml').expect(204).end(done) + }); + + it('should respond with the api as a textual json tree', done => { + var app = express(); + app.get('/:format', (req, res) => responder.respond(req, res, { + description: 'This is a test', + params: { + age: 'number' + } + })); + + request(app).get('/tree').expect(200, '{\n ' + + '"description": "This is a test",\n "params": {\n "age": "number"\n }\n}' + + '').end(done) + }); + }); +}); diff --git a/test/verifier.test.js b/test/verifier.test.js new file mode 100644 index 0000000..6fbbb87 --- /dev/null +++ b/test/verifier.test.js @@ -0,0 +1,485 @@ +/* global describe, it, beforeEach, afterEach */ +'use strict'; + +var expect = require('chai').expect; + + +var verifier = require('../lib/verifier'); + + +describe('verifier', () => { + /** + * @returns {ParamDef} + */ + function mkParam(type, def, required = true, min, max) { + var param = { + array: type.endsWith('[]'), + type: type.replace('[]', ''), + default: def, + required + }; + min && (param.min = min); + max && (param.max = max); + return param; + } + + describe('#configure()', () => { + it('should normalize the configuration parameters', () => { + var context = { + endpoints: {}, + globalConfiguration: { + paramOrder: [ 'query' ] + } + }; + var api = { + description: 'This is a test', + params: { + name: 'string()', + age: 'number(30)', + married: 'boolean' + } + }; + verifier.configure(context, { + method: 'get', + path: '/test', + api + }); + expect(context.endpoints).to.deep.equal({ + "/test": { + GET: { + description: 'This is a test', + paramMap: 'args', + paramOrder: ['query'], + params: { + age: { + default: 30, + required: false, + array: false, + type: 'number', + error: undefined, + validate: undefined, + success: undefined + }, + married: { + required: true, + array: false, + type: 'boolean', + default: undefined, + error: undefined, + validate: undefined, + success: undefined + }, + name: { + required: false, + array: false, + type: 'string', + default: undefined, + error: undefined, + validate: undefined, + success: undefined + } + } + } + } + }); + }); + + it('should apply global configuration options to individual endpoints', () => { + var context = { + endpoints: {}, + globalConfiguration: { + paramOrder: [ 'query' ], + error: 'error method', + validate: 'validate method', + success: 'success method' + } + }; + var api = { + description: 'This is a test', + params: { + name: 'string()', + age: 'number(30)', + married: 'boolean' + } + }; + verifier.configure(context, { + method: 'get', + path: '/test', + api + }); + expect(context.endpoints).to.deep.equal({ + "/test": { + GET: { + description: 'This is a test', + paramMap: 'args', + paramOrder: ['query'], + params: { + age: { + default: 30, + array: false, + required: false, + type: 'number', + error: 'error method', + validate: 'validate method', + success: 'success method' + }, + married: { + required: true, + array: false, + type: 'boolean', + default: undefined, + error: 'error method', + validate: 'validate method', + success: 'success method' + }, + name: { + required: false, + array: false, + type: 'string', + default: undefined, + error: 'error method', + validate: 'validate method', + success: 'success method' + } + } + } + } + }); + }); + }); + + describe('#parseParam()', () => { + it('should parse all simple types', () => { + expect(verifier.parseParam('string')).to.deep.equal(mkParam('string')); + expect(verifier.parseParam('number')).to.deep.equal(mkParam('number')); + expect(verifier.parseParam('float')).to.deep.equal(mkParam('float')); + expect(verifier.parseParam('double')).to.deep.equal(mkParam('double')); + expect(verifier.parseParam('integer')).to.deep.equal(mkParam('integer')); + expect(verifier.parseParam('short')).to.deep.equal(mkParam('short')); + expect(verifier.parseParam('bool')).to.deep.equal(mkParam('bool')); + expect(verifier.parseParam('boolean')).to.deep.equal(mkParam('boolean')); + expect(verifier.parseParam.bind(null, 'something')).to.throw(Error); + }); + + it('should allow for optional params', () => { + expect(verifier.parseParam('string()')).to.deep.equal(mkParam('string', undefined, false)); + expect(verifier.parseParam('number()')).to.deep.equal(mkParam('number', undefined, false)); + expect(verifier.parseParam('bool()')).to.deep.equal(mkParam('bool', undefined, false)); + expect(verifier.parseParam.bind(null, 'something()')).to.throw(Error); + }); + + it('should allow to set default params', () => { + expect(verifier.parseParam('string(hello)')).to.deep.equal(mkParam('string', 'hello', false)); + expect(verifier.parseParam('number(20)')).to.deep.equal(mkParam('number', 20, false)); + expect(verifier.parseParam('bool(false)')).to.deep.equal(mkParam('bool', false, false)); + expect(verifier.parseParam.bind(null, 'something(someval)')).to.throw(Error); + }); + + it('should check that passed in params have all required fields', () => { + expect(verifier.parseParam({ type: 'string' })).to.deep.equal(mkParam('string')); + expect(verifier.parseParam({ type: 'string', required: false })).to.deep.equal(mkParam('string', undefined, false)); + expect(verifier.parseParam({ type: 'string', required: true })).to.deep.equal(mkParam('string', undefined, true)); + expect(verifier.parseParam({ type: 'string', default: 'test'})).to.deep.equal(mkParam('string', 'test', false)); + }); + + it('should parse array version of all types', () => { + expect(verifier.parseParam('string[]')).to.deep.equal(mkParam('string[]')); + expect(verifier.parseParam('number[]()')).to.deep.equal(mkParam('number[]', undefined, false)); + expect(verifier.parseParam('bool[](false, true,true)')).to.deep.equal(mkParam('bool[]', [false, true, true], false)); + }); + }); + + describe('#getParams()', () => { + it('should return all parameters parsed in the right order', () => { + var config = { + paramOrder: ['params', 'query', 'body'], + params: { + name: { + type: 'string' + }, + age: { + type: 'number' + } + } + }; + var request = { + params: { + name: 'Dough' + }, + query: { + age: 30, + name: 'Doe' + }, + body: { + age: 25, + name: 'Doh' + } + }; + var response = verifier.getParams(config, request); + expect(response).to.deep.equal({ + name: 'Dough', + age: 30 + }); + }); + }); + + describe('#checkParams()', () => { + it('should not reject an empty parameter list', () => { + var config = { + params: { + age: mkParam('number') + } + }; + var errors = verifier.checkParams(config, {}); + expect(errors).to.deep.equal({ + age: { + error: "not set", + type: "number" + } + }); + }); + + it('should return an error for each missing parameters', () => { + var config = { + params: { + age: mkParam('number'), + name: mkParam('string') + } + }; + var errors = verifier.checkParams(config, {}); + expect(errors).to.deep.equal({ + age: { + error: "not set", + type: "number" + }, + name: { + error: "not set", + type: "string" + } + }); + }); + + it('should allow a request with all parameters set to pass', () => { + var config = { + params: { + age: mkParam('number'), + name: mkParam('string') + } + }; + var params = { + age: 25, + name: 'Jauhn Dough' + }; + var errors = verifier.checkParams(config, params); + expect(errors).to.deep.equal({}); + }); + + it('should ignore params that have an empty or non empty default setting', () => { + var config = { + params: { + age: mkParam('number', 30, false), + name: mkParam('string', 'Jauhn Dough', false) + } + }; + var errors = verifier.checkParams(config, {}); + expect(errors).to.deep.equal({}); + }); + + it('should check that minimum limits are respected', () => { + var config = { + params: { + age: mkParam('number', undefined, true, 10), + name: mkParam('string', undefined, true, 5) + } + }; + var params = { + age: 9, + name: '1234' + }; + var errors = verifier.checkParams(config, params); + expect(errors).to.deep.equal({ + age: { + error: "value below min value", + min: 10, + type: "number" + }, + name: { + error: "value below min value", + min: 5, + type: "string" + } + }); + }); + + it('should check that maximum limits are respected', () => { + var config = { + params: { + age: mkParam('number', undefined, true, undefined, 10), + name: mkParam('string', undefined, true, undefined, 5) + } + }; + var params = { + age: 11, + name: '123456' + }; + var errors = verifier.checkParams(config, params); + expect(errors).to.deep.equal({ + age: { + error: 'value exceeds max value', + max: 10, + type: 'number' + }, + name: { + error: 'value exceeds max value', + max: 5, + type: 'string' + } + }); + }); + + it('should be able to use a custom validator', () => { + var ageDef = mkParam('number'); + ageDef.validate = (value, name, config) => { + expect(value).to.equal(11); + expect(name).to.equal('age'); + expect(config).to.deep.equal(ageDef); + return 'Test error'; + }; + var config = { + params: { + age: ageDef + } + }; + var params = { + age: 11 + }; + var errors = verifier.checkParams(config, params); + expect(errors).to.deep.equal({ + age: { + error: 'Test error', + type: 'number' + } + }); + }); + }); + + describe('#fillParams()', () => { + it('should fill parameters with the right primitive types', () => { + var config = { + params: { + age: mkParam('number', 25), + name: mkParam('string', 'Jauhn Dough') + } + }; + var result = verifier.fillParams(config, {}); + expect(result.age).to.equal(25); + expect(result.age).to.not.equal('25'); + expect(result.name).to.equal('Jauhn Dough'); + }); + + it('should keep empty defaults as undefined', () => { + var config = { + params: { + age: mkParam('number') + } + }; + var result = verifier.fillParams(config, {}); + expect(result).to.deep.equal({ + age: undefined + }) + }); + + it('should only fill parameters that haven\'t been set yet', () => { + var config = { + params: { + age: mkParam('number', 25), + name: mkParam('string', 'Jauhn Dough') + } + }; + var params = { + name: 'The Narrator', + origin: 'unknown' + }; + var result = verifier.fillParams(config, params); + expect(result).to.deep.equal({ + name: 'The Narrator', + age: 25, + origin: 'unknown' + }) + }); + }); + + describe('#verify()', () => { + it('should be able to use a custom error handler', done => { + var chain = () => {}; + var request = { + route: { + path: '/test' + }, + method: 'GET', + params: {} + }; + var response = {}; + var context = { + globalConfiguration: { + paramOrder: [ 'params' ] + }, + endpoints: { + '/test': { + GET: { + error: (error, req, res, next) => { + expect(error).to.be.instanceOf(Error); + expect(req).to.deep.equal(request); + expect(res).to.deep.equal(response); + expect(next).to.deep.equal(chain); + done(); + }, + params: { + age: 'number' + } + } + } + } + }; + + verifier.verify.call(context, request, response, chain); + }); + + it('should be able to use a custom success handler', done => { + var chain = () => {}; + var request = { + route: { + path: '/test' + }, + method: 'GET', + params: {} + }; + var response = { + status: () => response, + json: () => response, + end: () => response + }; + var context = { + endpoints: { + '/test': { + GET: { + success: (error, req, res, next) => { + expect(error).to.be.not.ok; + expect(req).to.deep.equal(request); + expect(res).to.deep.equal(response); + expect(next).to.deep.equal(chain); + done(); + }, + paramOrder: [ 'params' ], + params: { + age: 'number' + } + } + } + } + }; + + verifier.verify.call(context, request, response, chain); + }); + }); +}); diff --git a/test/router.test.js b/test/version-router.test.js similarity index 99% rename from test/router.test.js rename to test/version-router.test.js index 9db548c..340f353 100644 --- a/test/router.test.js +++ b/test/version-router.test.js @@ -8,7 +8,7 @@ var request = require('supertest'); var Router = require('..'); -describe('Router', () => { +describe('Version Router', () => { it('should process normal requests same as the default router as this defaults as catch all', done => { var router = Router(); router.get('/test', (req, res) => res.end('success'));