Skip to content

Commit

Permalink
feat: Add support for Http Handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolasdao committed Aug 27, 2017
1 parent 11d7b20 commit c4ac75f
Show file tree
Hide file tree
Showing 4 changed files with 509 additions and 67 deletions.
104 changes: 104 additions & 0 deletions src/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2017, Neap Pty Ltd.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/
const { getRouteDetails } = require('./routing')

let _options = new WeakMap()
let _httpNext = new WeakMap()
let _optionsObjectFlag = new WeakMap()
class HttpHandler {
constructor(options, httpNext) {
if (httpNext && typeof(httpNext) != 'function')
throw new Error('Wrong argument exception. Argument \'httpNext\' must be a function.')

_options.set(this, options)
_optionsObjectFlag.set(this, typeof(options) == 'object')
_httpNext.set(this, httpNext)
}

getOptions() {
return _optionsObjectFlag.get(this) ? Object.assign({}, _options.get(this)) : _options.get(this)
}

getHttpNext() {
return _httpNext.get(this)
}

httpNext(req, res, params) {
const httpHandle = this.getHttpNext() || (() => null)
const err = null
return Promise.resolve(httpHandle(req, res, params, err))
.then(() => ({ req, res, params }))
}
}

const app = () => {
let httpHandlers = []
return {
use: httpHandler => {
if (!httpHandler)
throw new Error('Missing required argument. The \'httpHandler\' argument must be specified in the \'use\' method.')
if (!(httpHandler instanceof HttpHandler))
throw new Error('Wrong argument exception. Object \'httpHandler\' must be an instance of \'HttpHandler\'.')

httpHandlers.push(httpHandler)
},
reset: () => { httpHandlers = [] },
get: (path, httpNext) => ({ route: getRouteDetails(path), method: 'GET', httpNext: httpNext }),
post: (path, httpNext) => ({ route: getRouteDetails(path), method: 'POST', httpNext: httpNext }),
put: (path, httpNext) => ({ route: getRouteDetails(path), method: 'PUT', httpNext: httpNext }),
delete: (path, httpNext) => ({ route: getRouteDetails(path), method: 'DELETE', httpNext: httpNext }),
head: (path, httpNext) => ({ route: getRouteDetails(path), method: 'HEAD', httpNext: httpNext }),
options: (path, httpNext) => ({ route: getRouteDetails(path), method: 'OPTIONS', httpNext: httpNext }),
any: (path, httpNext) => ({ route: getRouteDetails(path), httpNext: httpNext }),
/**
* Creates a new object { route: ..., method: ..., httpNext: ... } based on some more detailed route information
*
* @param {Object} routeDetails
* @param {String} routeDetails.path Optional (e.g. '/users/{username}/byebye/{accountId}')
* @param {String} routeDetails.method Optional. If not set any HTTP method is allowed for that path. Values: GET, POST, PUT, DELETE, HEAD, OPTIONS
* @param {String} routeDetails.handlerId Optional. If you've added HttpHandlers (using app.use), then the request will use that handler specifically.
* @param {Function} routeDetails.httpNext Optional. What to do with the request after it has potentially gone through the HttpHandler.
* @return {Object}
*/
route: (routeDetails) => {
if (!routeDetails)
throw new Error('Missing arg. \'routeDetails\' must be defined in function \'route\'.')
if (!routeDetails.httpNext)
throw new Error('Missing \'httpNext\' function. \'routeDetails.httpNext\' must be defined in function \'route\'.')
if (typeof(routeDetails.httpNext) != 'function')
throw new Error('\'httpNext\' must be a function.')

const method = routeDetails.method ? routeDetails.method.trim().toUpperCase() : null
const handlerId = routeDetails.handlerId ? routeDetails.handlerId.trim().toLowerCase() : null
const path = routeDetails.path || '/'
const httpNext = routeDetails.httpNext || (() => Promise.resolve(null))
const route = getRouteDetails(path)

if (!handlerId)
return { route: route, method: method, httpNext: httpNext }
else {
const httpHandler = httpHandlers.filter(s => s.id.trim().toLowerCase() == handlerId)[0]
if (!httpHandler)
throw new Error(`Cannot found routing method. Routing with http handler id '${handlerId}' cannot be found. Use 'app.use(new SomeHttpHandler())' to set up your http handler, or double-check there is no typos in the http handler id.`)

return {
route: route,
method: method,
httpNext: (req, res, params) =>
Promise.resolve(httpHandler.httpNext(req, res, params))
.then((req, res, params) => httpNext(req, res, params))
}
}
}
}
}

module.exports = {
app,
HttpHandler
}
57 changes: 57 additions & 0 deletions src/routing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2017, Neap Pty Ltd.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

const getRouteDetails = route => {
let wellFormattedRoute = (route.trim().match(/\/$/) ? route.trim() : route.trim() + '/')
wellFormattedRoute = wellFormattedRoute.match(/^\//) ? wellFormattedRoute : '/' + wellFormattedRoute

const variables = wellFormattedRoute.match(/{(.*?)}/g) || []
const variableNames = variables.map(x => x.replace(/^{/, '').replace(/}$/, ''))
const routeRegex = variables.reduce((a, v) => a.replace(v, '(.*?)'), wellFormattedRoute)
const rx = new RegExp(routeRegex)

return {
name: wellFormattedRoute,
params: variableNames,
regex: rx
}
}

const matchRoute = (reqPath, { params, regex }) => {
if (!reqPath)
return null

let wellFormattedReqPath = (reqPath.trim().match(/\/$/) ? reqPath.trim() : reqPath.trim() + '/').toLowerCase()
wellFormattedReqPath = wellFormattedReqPath.match(/^\//) ? wellFormattedReqPath : '/' + wellFormattedReqPath

const match = wellFormattedReqPath.match(regex)

if (!match)
return null
else {
const beginningBit = match[0]
if (wellFormattedReqPath.indexOf(beginningBit) != 0)
return null
else {
const parameters = (params || []).reduce((a, p, idx) => {
a[p] = match[idx + 1]
return a
}, {})
return {
match: beginningBit,
route: reqPath,
parameters
}
}
}
}

module.exports = {
getRouteDetails,
matchRoute
}
84 changes: 18 additions & 66 deletions src/webfunc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
const path = require('path')
const fs = require('fs')
const functions = require('firebase-functions')
const { getRouteDetails, matchRoute } = require('./routing')
const { app, HttpHandler } = require('./handler')

let _appconfig = null
const getAppConfig = memoize => {
Expand Down Expand Up @@ -117,17 +119,17 @@ const handleHttpRequest = (req, res, appconfig) => Promise.resolve(appconfig ||
/**
* Returns a function (req, res) => ... that the Google Cloud Function expects.
*
* @param {function} processHttpRequest Callback function (req, res) => ... This gets executed after all the headers checks.
* @param {function} httpNextRequest Callback function (req, res) => ... This gets executed after all the headers checks.
* @param {object} appconfig Optional configuration file. If it exists, it will override the appconfig.json file.
* @return {function} (req, res) => ...
*/
//const serveHttp = (processHttpRequest, appconfig) => (req, res) => {
//const serveHttp = (httpNextRequest, appconfig) => (req, res) => {
const serveHttp = (arg1, arg2, appconfig) => {
const appConfigFile = getAppConfig() || {}
const appConfigArg = appconfig || {}
let _appconfig = null
let route = null
let processHttpRequest = null
let httpNextRequest = null
const typeOfArg1 = typeof(arg1 || undefined)
const typeOfArg2 = typeof(arg2 || undefined)

Expand All @@ -136,14 +138,14 @@ const serveHttp = (arg1, arg2, appconfig) => {
route = getRouteDetails(arg1)
_appconfig = Object.assign(appConfigFile, appConfigArg)
if (typeOfArg2 == 'function')
processHttpRequest = arg2
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) => ...')
}
else {
_appconfig = Object.assign(appConfigFile, arg2 || {})
if (typeOfArg1 == 'function')
processHttpRequest = arg1
httpNextRequest = arg1
else if (arg1.length != undefined)
return serveHttpEndpoints(arg1, _appconfig)
else if (typeOfArg1 == 'object')
Expand Down Expand Up @@ -172,58 +174,15 @@ const serveHttp = (arg1, arg2, appconfig) => {

return handleHttpRequest(req, res, _appconfig)
.then(() => !res.headersSent
? setResponseHeaders(res, _appconfig).then(res => processHttpRequest(req, res, Object.assign(parameters, getRequestParameters(req))))
? setResponseHeaders(res, _appconfig).then(res => httpNextRequest(req, res, Object.assign(parameters, getRequestParameters(req))))
: res)
}

const firebaseHosting = _appconfig.hosting == 'firebase'
return firebaseHosting ? functions.https.onRequest(cloudFunction) : cloudFunction
}

const getRouteDetails = route => {
let wellFormattedRoute = (route.trim().match(/\/$/) ? route.trim() : route.trim() + '/')
wellFormattedRoute = wellFormattedRoute.match(/^\//) ? wellFormattedRoute : '/' + wellFormattedRoute

const variables = wellFormattedRoute.match(/{(.*?)}/g) || []
const variableNames = variables.map(x => x.replace(/^{/, '').replace(/}$/, ''))
const routeRegex = variables.reduce((a, v) => a.replace(v, '(.*?)'), wellFormattedRoute)
const rx = new RegExp(routeRegex)

return {
name: wellFormattedRoute,
params: variableNames,
regex: rx
}
}

const matchRoute = (reqPath, { params, regex }) => {
if (!reqPath)
return null

let wellFormattedReqPath = (reqPath.trim().match(/\/$/) ? reqPath.trim() : reqPath.trim() + '/').toLowerCase()
wellFormattedReqPath = wellFormattedReqPath.match(/^\//) ? wellFormattedReqPath : '/' + wellFormattedReqPath

const match = wellFormattedReqPath.match(regex)

if (!match)
return null
else {
const beginningBit = match[0]
if (wellFormattedReqPath.indexOf(beginningBit) != 0)
return null
else {
const parameters = (params || []).reduce((a, p, idx) => {
a[p] = match[idx + 1]
return a
}, {})
return {
match: beginningBit,
route: reqPath,
parameters
}
}
}
}

const getRequestParameters = req => {
let bodyParameters = {}
Expand All @@ -249,7 +208,7 @@ const getRequestParameters = req => {
/**
* Returns a function (req, res) => ... that the Google Cloud Function expects.
*
* @param {array} endpoints e.g. [{ route: { name: '/user', params: ..., regex: ... }, method: 'GET', processHttp: (req, res, params) => ... }, ...]
* @param {array} endpoints e.g. [{ route: { name: '/user', params: ..., regex: ... }, method: 'GET', httpNext: (req, res, params) => ... }, ...]
* @param {object} appconfig Optional configuration file. If it exists, it will override the appconfig.json file.
* @return {function} (req, res) => ...
*/
Expand All @@ -262,46 +221,39 @@ const serveHttpEndpoints = (endpoints, appconfig) => {
.then(() => !res.headersSent
? setResponseHeaders(res, _appconfig).then(res => {
const httpEndpoint = ((req._parsedUrl || {}).pathname || '/').toLowerCase()
const httpMethod = req.method
const httpMethod = (req.method || '').toUpperCase()
const endpoint = httpEndpoint == '/'
? endpoints.filter(e => e.route.name == '/' && e.method == httpMethod)[0]
? 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.endpoint.route.name != '/' && e.route && e.endpoint.method == httpMethod)
.filter(e => e.endpoint.route.name != '/' && e.route && (e.endpoint.method == httpMethod || !e.endpoint.method))
.sort((a, b) => b.route.match.length - a.route.match.length)[0] || {}).endpoint

if (!endpoint)
return res.send(404, `Endpoint '${httpEndpoint}' for method ${httpMethod} not found.`)

if (!endpoint.processHttp || typeof(endpoint.processHttp) != 'function')
return res.send(500, `Endpoint '${httpEndpoint}' for method ${httpMethod} does not define any 'processHttp(req, res)' function.`)
const httpNext = endpoint.httpNext || (() => Promise.resolve(null))
if (typeof(httpNext) != 'function')
return res.send(500, `Wrong argument exception. Endpoint '${httpEndpoint}' for method ${httpMethod} defines a 'httpNext' argument that is not a function similar to '(req, res, params) => ...'.`)

const parameters = getRequestParameters(req)
const requestParameters = matchRoute(httpEndpoint, endpoint.route).parameters

return endpoint.processHttp(req, res, Object.assign(parameters, requestParameters))
return httpNext(req, res, Object.assign(parameters, requestParameters))
})
: res)

const firebaseHosting = _appconfig.hosting == 'firebase'
return firebaseHosting ? functions.https.onRequest(cloudFunction) : cloudFunction
}

const app = {
get: (route, processHttp) => ({ route: getRouteDetails(route), method: 'GET', processHttp }),
post: (route, processHttp) => ({ route: getRouteDetails(route), method: 'POST', processHttp }),
put: (route, processHttp) => ({ route: getRouteDetails(route), method: 'PUT', processHttp }),
delete: (route, processHttp) => ({ route: getRouteDetails(route), method: 'DELETE', processHttp }),
head: (route, processHttp) => ({ route: getRouteDetails(route), method: 'HEAD', processHttp }),
options: (route, processHttp) => ({ route: getRouteDetails(route), method: 'OPTIONS', processHttp })
}

module.exports = {
setResponseHeaders,
handleHttpRequest,
serveHttp,
getAppConfig,
getActiveEnv,
app,
app: app(),
HttpHandler,
routing: {
getRouteDetails,
matchRoute
Expand Down
Loading

0 comments on commit c4ac75f

Please sign in to comment.