From 1395c88ea5749716f70e743e9c89ea49c4e22a6b Mon Sep 17 00:00:00 2001 From: Nic Date: Wed, 13 Dec 2017 00:50:02 +1100 Subject: [PATCH] feat: Add support for multiple route for same type of response --- src/routing.js | 20 +++++++------- src/webfunc.js | 58 +++++++++++++++++++++++++++------------- test/routing.js | 10 ++++--- test/webfunc.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 125 insertions(+), 34 deletions(-) diff --git a/src/routing.js b/src/routing.js index 00f5147..306c05e 100644 --- a/src/routing.js +++ b/src/routing.js @@ -7,6 +7,15 @@ */ const pathToRegexp = require('path-to-regexp') +/** + * Converts one or many route string templates to a route detail objects with specific info about that route. + * From 'users/{username}/account/{id}' to { "name": "/users/{username}/account/{id}/", "params": [ "username", "id"], "regex": {} } + * + * @param {String|[String]} route Uri paths + * @return {[Object]} e.g. [{ "name": "/users/{username}/account/{id}/", "params": [ "username", "id"], "regex": {} }] + */ +const getRouteDetails = (route='') => (typeof(route) == 'string' ? [route] : route).map(r => _getRouteDetails(r)) + /** * Converts a route string template to a route detail object with specific info about that route. * From 'users/{username}/account/{id}' to { "name": "/users/{username}/account/{id}/", "params": [ "username", "id"], "regex": {} } @@ -14,21 +23,14 @@ const pathToRegexp = require('path-to-regexp') * @param {String} route Uri path * @return {Object} e.g. { "name": "/users/{username}/account/{id}/", "params": [ "username", "id"], "regex": {} } */ -const getRouteDetails = route => { +const _getRouteDetails = route => { let wellFormattedRoute = (route.trim().match(/\/$/) ? route.trim() : route.trim() + '/') wellFormattedRoute = wellFormattedRoute.match(/^\//) ? wellFormattedRoute : '/' + wellFormattedRoute const keys = [] const rx = pathToRegexp(wellFormattedRoute, keys) const variableNames = keys.map(x => x.name) - - // const variables = wellFormattedRoute.match(/{(.*?)}/g) || [] - // variables.push(...(wellFormattedRoute.match(/:(.*?)\//g) || [])) // add support for standard express routing convention - // const variableNames = variables.map(x => x.replace(/^{|:/, '').replace(/}|\/$/, '')) - - // const routeRegex = variables.reduce((a, v) => a.replace(v, '(.*?)'), wellFormattedRoute).toLowerCase() - // const rx = new RegExp(routeRegex) - + return { name: wellFormattedRoute, params: variableNames, diff --git a/src/webfunc.js b/src/webfunc.js index fc24881..93155b4 100644 --- a/src/webfunc.js +++ b/src/webfunc.js @@ -126,9 +126,11 @@ const handleHttpRequest = (req, res, appconfig) => Promise.resolve(appconfig || * Returns a function (req, res) => ... that the Google Cloud Function expects. * * @param {String|Function|Array|Object} arg1 Here what it means based on its type: - * - String: Route path (e.g. '/users/{userId}/account') + * - String: Route path (e.g. '/users/:userId/account') * - Function: Callback function (req, res) => ... This gets executed after all the headers checks. - * - Array: Array of endpoints (e.g. [app.get('/users', (req, res, params) => ...), app.post('/stories', (req, res, params) => ...)]) + * - Array: + * - Array of endpoints (e.g. [app.get('/users', (req, res, params) => ...), app.post('/stories', (req, res, params) => ...)]) + * - Array of strings of routes (e.g. ['/users/:userId/account', '/users/:userId/portfolio']) * - Object: Endpoint (e.g. app.get('/users', (req, res, params) => ...)) * @param {Function|Object} arg2 Here what it means based on its type: * - Function: Callback function (req, res) => ... This gets executed after all the headers checks. @@ -140,40 +142,54 @@ const serveHttp = (arg1, arg2, arg3) => { const appConfigFile = getAppConfig() || {} const appConfigArg = arg3 || {} let _appconfig = null - let route = null + let routes = null let httpNextRequest = null const typeOfArg1 = typeof(arg1 || undefined) const typeOfArg2 = typeof(arg2 || undefined) if (arg1) { if (typeOfArg1 == 'string') { - route = getRouteDetails(arg1) + routes = getRouteDetails(arg1) _appconfig = Object.assign(appConfigFile, appConfigArg) if (typeOfArg2 == 'function') httpNextRequest = arg2 else - throw new Error('Wrong argument exception. If the first argument of the \'serveHttp\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') + throw new Error('Wrong argument exception. If the first argument of the \'serveHttp\' or \'serve\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') } else { _appconfig = Object.assign(appConfigFile, arg2 || {}) + // 1. arg1 is a function (req, res) => ... if (typeOfArg1 == 'function') httpNextRequest = arg1 - else if (arg1.length != undefined) - return serveHttpEndpoints(arg1, _appconfig) + // 2. arg1 is an Array + else if (arg1.length != undefined && arg1.length > 0) { + // 2.1. arg1 is an array of routes + if (typeof(arg1[0]) == 'string') { + routes = getRouteDetails(arg1) + if (typeOfArg2 == 'function') + httpNextRequest = arg2 + else + throw new Error('Wrong argument exception. If the first argument of the \'serveHttp\' or \'serve\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') + } + // 2.2. arg1 is an array of endpoints + else + return serveHttpEndpoints(arg1, _appconfig) + } + // 3. arg1 is an endpoint object else if (typeOfArg1 == 'object') return serveHttpEndpoints([arg1], _appconfig) else - throw new Error('Wrong argument exception. If the first argument of the \'serveHttp\' method is not a route, then it must either be a function similar to (req, res, params) => ... or an array of endpoints.') + throw new Error('Wrong argument exception. If the first argument of the \'serveHttp\' or \'serve\' method is not a route, then it must either be a function similar to (req, res, params) => ... or an array of endpoints.') } } else - throw new Error('Wrong argument exception. The first argument of the \'serveHttp\' method must either be a route, a function similar to (req, res, params) => ... or an array of endpoints.') + throw new Error('Wrong argument exception. The first argument of the \'serveHttp\' or \'serve\' method must either be a route, a function similar to (req, res, params) => ... or an array of endpoints.') const cloudFunction = (req, res) => { let parameters = {} - if (route) { + if (routes) { const httpEndpoint = ((req._parsedUrl || {}).pathname || '/').toLowerCase() - const r = matchRoute(httpEndpoint, route) + const r = (routes.map(route => matchRoute(httpEndpoint, route)).filter(route => route) || [])[0] if (!r) { return setResponseHeaders(res, _appconfig).then(res => { res.status(404).send(`Endpoint '${httpEndpoint}' not found.`) @@ -250,6 +266,8 @@ const getRequestParameters = req => { return parameters } +const getLongestRoute = (routes=[]) => routes.sort((a,b) => b.match.length - a.match.length)[0] + /** * Returns a function (req, res) => ... that the Google Cloud Function expects. * @@ -268,22 +286,26 @@ const serveHttpEndpoints = (endpoints, appconfig) => { const httpEndpoint = ((req._parsedUrl || {}).pathname || '/').toLowerCase() const httpMethod = (req.method || '').toUpperCase() const endpoint = httpEndpoint == '/' - ? endpoints.filter(e => e.route.name == '/' && (e.method == httpMethod || !e.method))[0] - : (endpoints.map(e => ({ endpoint: e, route: matchRoute(httpEndpoint, e.route) })) - .filter(e => e.route && (e.endpoint.method == httpMethod || !e.endpoint.method)) - .sort((a, b) => b.route.match.length - a.route.match.length)[0] || {}).endpoint + ? endpoints.filter(e => e.route.some(x => x.name == '/') && (e.method == httpMethod || !e.method))[0] + : (endpoints.map(e => ({ endpoint: e, route: e.route.map(r => matchRoute(httpEndpoint, r)).filter(r => r) })) + .filter(e => e.route.length > 0 && (e.endpoint.method == httpMethod || !e.endpoint.method)) + .map(e => { + const winningRoute = getLongestRoute(e.route) + e.endpoint.winningRoute = winningRoute + return { endpoint: e.endpoint, winningRoute: winningRoute } + }) + .sort((a, b) => b.winningRoute.match.length - a.winningRoute.match.length)[0] || {}).endpoint if (!endpoint) return res.status(404).send(`Endpoint '${httpEndpoint}' for method ${httpMethod} not found.`) - + const next = endpoint.next || (() => Promise.resolve(null)) if (typeof(next) != 'function') return res.status(500).send(`Wrong argument exception. Endpoint '${httpEndpoint}' for method ${httpMethod} defines a 'next' argument that is not a function similar to '(req, res, params) => ...'.`) const parameters = getRequestParameters(req) - const requestParameters = matchRoute(httpEndpoint, endpoint.route).parameters - return next(req, res, Object.assign(parameters, requestParameters)) + return next(req, res, Object.assign(parameters, endpoint.winningRoute.parameters)) }) : res) .then(() => ({ req, res })) diff --git a/test/routing.js b/test/routing.js index a2296aa..c766478 100644 --- a/test/routing.js +++ b/test/routing.js @@ -13,7 +13,9 @@ describe('routing', () => describe('#getRouteDetails', () => it(`Should support the express standard for routing variables, i.e. support for ':'.`, () => { /*eslint-enable */ - const route = getRouteDetails('users/:username/account/:id') + const routes = getRouteDetails('users/:username/account/:id') + assert.equal(routes.length, 1) + const route = routes[0] assert.equal(route.name, '/users/:username/account/:id/') assert.isOk(route.params) assert.equal(route.params.length, 2) @@ -26,12 +28,12 @@ describe('routing', () => describe('#matchRoute', () => it(`Should analyse a route and extract details from it.`, () => { /*eslint-enable */ - let routeDetails = getRouteDetails('users/:username/account/:id/(.*)') - let details = matchRoute('/users/nic/account/1/blabla', routeDetails) + const routes = getRouteDetails('users/:username/account/:id/(.*)') + let details = matchRoute('/users/nic/account/1/blabla', routes[0]) assert.isOk(details) assert.isOk(details.parameters) assert.equal(details.parameters.username, 'nic') assert.equal(details.parameters.id, '1') - details = matchRoute('/user/nic/account/1/blabla', routeDetails) + details = matchRoute('/user/nic/account/1/blabla', routes[0]) assert.isOk(!details) }))) \ No newline at end of file diff --git a/test/webfunc.js b/test/webfunc.js index 89da7c6..3d9441a 100644 --- a/test/webfunc.js +++ b/test/webfunc.js @@ -914,9 +914,9 @@ describe('webfunc', () => }) ] - assert.throws(() => serveHttp('/users/:username', endpoints, appconfig), Error, 'Wrong argument exception. If the first argument of the \'serveHttp\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') - assert.throws(() => serveHttp('/users/:username', appconfig), Error, 'Wrong argument exception. If the first argument of the \'serveHttp\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') - assert.throws(() => serveHttp(), Error, 'Wrong argument exception. The first argument of the \'serveHttp\' method must either be a route, a function similar to (req, res, params) => ... or an array of endpoints.') + assert.throws(() => serveHttp('/users/:username', endpoints, appconfig), Error, 'Wrong argument exception. If the first argument of the \'serveHttp\' or \'serve\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') + assert.throws(() => serveHttp('/users/:username', appconfig), Error, 'Wrong argument exception. If the first argument of the \'serveHttp\' or \'serve\' method is a route, then the second argument must be a function similar to (req, res, params) => ...') + assert.throws(() => serveHttp(), Error, 'Wrong argument exception. The first argument of the \'serveHttp\' or \'serve\' method must either be a route, a function similar to (req, res, params) => ... or an array of endpoints.') }))) /*eslint-disable */ @@ -1347,6 +1347,71 @@ describe('webfunc', () => return Promise.all([result_01, result_02]) }))) +/*eslint-disable */ +describe('webfunc', () => + describe('#serveHttp: 21', () => + it(`Should support collection of routes for a single response type.`, () => { + /*eslint-enable */ + const req_01 = httpMocks.createRequest({ + method: 'GET', + headers: { + origin: 'http://localhost:8080', + referer: 'http://localhost:8080' + }, + _parsedUrl: { + pathname: '/users/1' + } + }) + const res_01 = httpMocks.createResponse() + + const req_02 = httpMocks.createRequest({ + method: 'GET', + headers: { + origin: 'http://localhost:8080', + referer: 'http://localhost:8080' + }, + _parsedUrl: { + pathname: '/companies/2' + } + }) + const res_02 = httpMocks.createResponse() + + const appconfig = { + headers: { + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS, POST', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Origin', + 'Access-Control-Allow-Origin': 'http://boris.com, http://localhost:8080', + 'Access-Control-Max-Age': '1296000' + } + } + + const endpoints = [ + app.get(['/users/:userId', '/companies/:companyId'], (req, res, params) => { res.status(200).send(`Hello No. ${params.userId || params.companyId}`); return res }) + ] + + const fn = serveHttp(endpoints, appconfig) + + const result_01 = fn(req_01, res_01).then(() => { + assert.equal(res_01._getData(),'Hello No. 1') + const headers = res_01._getHeaders() + assert.isOk(headers) + assert.equal(headers['Access-Control-Allow-Methods'], 'GET, HEAD, OPTIONS, POST') + assert.equal(headers['Access-Control-Allow-Headers'], 'Authorization, Content-Type, Origin') + assert.equal(headers['Access-Control-Allow-Origin'], 'http://boris.com, http://localhost:8080') + assert.equal(headers['Access-Control-Max-Age'], '1296000') + }) + const result_02 = fn(req_02, res_02).then(() => { + assert.equal(res_02._getData(),'Hello No. 2') + const headers = res_02._getHeaders() + assert.isOk(headers) + assert.equal(headers['Access-Control-Allow-Methods'], 'GET, HEAD, OPTIONS, POST') + assert.equal(headers['Access-Control-Allow-Headers'], 'Authorization, Content-Type, Origin') + assert.equal(headers['Access-Control-Allow-Origin'], 'http://boris.com, http://localhost:8080') + assert.equal(headers['Access-Control-Max-Age'], '1296000') + }) + + return Promise.all([result_01, result_02]) + })))