diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..b5a260c --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "plugins": ["syntax-async-functions", "transform-async-to-generator"] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ba02b75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..235b43d --- /dev/null +++ b/.eslintrc @@ -0,0 +1,45 @@ +{ + "parser": "babel-eslint", + "rules": { + "block-spacing": [ 2 ], + "brace-style": [ 2, "1tbs", { + "allowSingleLine": true + }], + "comma-dangle": [ 2, "never" ], + "comma-spacing": [ 2 ], + "curly": [ 2, "multi-line" ], + "eqeqeq": [ 2 ], + "indent": [ 2, 2, { + "SwitchCase": 1 + }], + "key-spacing": [ 2 ], + "keyword-spacing": [ 2 ], + "linebreak-style": [ 2, "unix" ], + "no-console": [ 0 ], + "no-empty": [ 2, { + "allowEmptyCatch": true + }], + "no-else-return": [ 2 ], + "no-eval": [ 2 ], + "no-mixed-spaces-and-tabs": [ 2 ], + "no-multi-spaces": [ 2 ], + "no-spaced-func": [ 2 ], + "no-trailing-spaces": [ 2 ], + "no-unused-vars": [ 2 ], + "no-whitespace-before-property": [ 2 ], + "one-var": [ 2, { + "initialized": "never", + "uninitialized": "always" + }], + "quotes": [ 2, "single" ], + "semi": [ 2, "always" ], + "space-before-blocks": [ 2 ], + "space-before-function-paren": [ 2, "never" ], + "space-in-parens": [ 2 ] + }, + "env": { + "node": true, + "es6": true + }, + "extends": "eslint:recommended" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69dd9b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# Commenting this out is preferred by some people, see +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- +node_modules +npm-debug.log + +# Users Environment Variables +.lock-wscript + +.DS_Store +dist diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..a3f930d --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +src/ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e08604 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# lambda-router diff --git a/package.json b/package.json new file mode 100644 index 0000000..011607b --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "lambda-router", + "version": "1.0.0", + "description": "Lambda router for serverless framework", + "main": "index.js", + "scripts": { + "build": "babel -d dist src", + "watch": "babel -w -d dist src", + "prepublish": "npm run build", + "lint": "eslint src/", + "test": "mocha test/**/*.spec.js", + "test:watch": "mocha -w test/**/*.spec.js" + }, + "author": "Steven White ", + "license": "MIT", + "devDependencies": { + "babel-cli": "^6.24.0", + "babel-core": "^6.24.0", + "babel-eslint": "^7.2.1", + "babel-plugin-syntax-async-functions": "^6.13.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-runtime": "^6.23.0", + "chai": "^3.5.0", + "eslint": "^3.19.0", + "mocha": "^3.2.0", + "sinon": "^2.1.0", + "sinon-chai": "^2.9.0" + }, + "dependencies": { + "boom": "^4.3.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..884760c --- /dev/null +++ b/src/index.js @@ -0,0 +1,157 @@ +/** + * Lambda Router + */ + +const Boom = require('boom'); + +class LambdaRouter { + + /** + * LambdaRouter constructor. + * @param {Object} options + * @param {Object} options.headers + * @param {Function} options.onInvoke + * @param {Function} options.onError + */ + constructor(options = {}) { + this.routes = {}; + this.headers = options.headers || {}; + this.onInvoke = options.onInvoke; + this.onError = options.onError; + } + + /** + * Lambda handler function. + * Maps event details to previously registered route handlers. + * @returns {Function} + */ + handler() { + return async (event, context, cb) => { + // Prevent callback waiting. + // IMPORTANT, otherwise lambda may potentially timeout + // needlessly. + context.callbackWaitsForEmptyEventLoop = false; + + // Setup state object to allow handlers to pass data along + context.state = {}; + + // Find appropriate handlers + const route = this._divineRoute(event); + + try { + // Verify route was found + if (!route || !route.handlers) throw Boom.notFound('Resource not found'); + + // Notify onInvoke handler if provided + if (this.onInvoke) this.onInvoke(event); + + // Invoke handlers for reply + let payload; + for (let handler of route.handlers) { + payload = await handler(event, context); + } + + // Deliver response + return cb(null, { + statusCode: 200, + headers: this.headers, + body: JSON.stringify(Object.assign({ success: true }, payload)) + }); + } catch (err) { + // Capture error details from boom + const details = err.output.payload; + + // Get error body from onError handler if provided + let body; + if (this.onError) { + body = this.onError(err, event); + } else { + body = { + success: false, + error: Object.assign(err.data || {}, { + statusCode: details.statusCode || 400, + message: details.message, + code: details.error + }) + }; + } + + // Deliver response + return cb(null, { + statusCode: details.statusCode || 400, + headers: this.headers, + body: JSON.stringify(body) + }); + } + + }; + } + + /** + * Add handler for get route. + * @param {String} path + * @param {Function} hdlr - list of handlers to call in succession + */ + get(path, ...hdlr) { + this._wrap('GET', path, hdlr); + } + + /** + * Add handler for post route. + * @param {String} path + * @param {Function} hdlr - list of handlers to call in succession + */ + post(path, ...hdlr) { + this._wrap('POST', path, hdlr); + } + + /** + * Add handler for put route. + * @param {String} path + * @param {Function} hdlr - list of handlers to call in succession + */ + put(path, ...hdlr) { + this._wrap('PUT', path, hdlr); + } + + /** + * Add handler for delete route. + * @param {String} path + * @param {Function} hdlr - list of handlers to call in succession + */ + del(path, ...hdlr) { + this._wrap('DELETE', path, hdlr); + } + + /** + * Add handler for options route. + * @param {String} path + * @param {Function} hdlr - list of handlers to call in succession + */ + options(path, ...hdlr) { + this._wrap('OPTIONS', path, hdlr); + } + + /** + * Store route handler reference in routes + * @param {String} method + * @param {String} path + * @param {Function} hdlr - list of handlers to call in succession + */ + _wrap(method, path, handlers) { + if (!this.routes[method]) this.routes[method] = []; + this.routes[method].push({ path, handlers }); + } + + /** + * Deliver handler that matches lambda event. + * @param {Object} event + * @returns {Object} + */ + _divineRoute({ httpMethod, resource }) { + if (!this.routes[httpMethod]) return; + return this.routes[httpMethod].find(r => r.path === resource); + } +} + +module.exports = LambdaRouter;