├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── echo-inputs.js ├── error-response.js ├── hello-world.js └── server.js ├── lib ├── index.js ├── local.js └── utils.js ├── package.json └── test ├── fixtures ├── circular.js ├── errback.js └── index.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "4" 5 | - "6" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Continuation Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hapi-gateway 2 | 3 | [![Current Version](https://img.shields.io/npm/v/hapi-gateway.svg)](https://www.npmjs.org/package/hapi-gateway) 4 | [![Build Status via Travis CI](https://travis-ci.org/continuationlabs/hapi-gateway.svg?branch=master)](https://travis-ci.org/continuationlabs/hapi-gateway) 5 | ![Dependencies](http://img.shields.io/david/continuationlabs/hapi-gateway.svg) 6 | 7 | [![belly-button-style](https://cdn.rawgit.com/continuationlabs/belly-button/master/badge.svg)](https://github.com/continuationlabs/belly-button) 8 | 9 | `hapi-gateway` is a hapi plugin that allows a hapi server to act as an API Gateway to AWS Lambda functions. `hapi-gateway` defines a new `lambda` handler type. When a lambda route handler is accessed, it invokes the backing the AWS Lambda function. 10 | 11 | `hapi-gateway` allows your Lambda function code to be deployed along with your hapi server. A lambda handler can be associated with a file containing your Lambda function code. When the hapi server starts, the code is deployed to AWS (overwriting any existing Lambda function of the same name). Optionally, the Lambda function can be removed from AWS when the hapi server is shut down. 12 | 13 | ## Example 14 | 15 | The following example creates a hapi server. On server startup, a Lambda function is deployed to AWS. The Lambda can be invoked via the `GET /hello-world` route. When the hapi server stops, the Lambda function is automatically deleted. A `SIGINT` signal handler has been added to catch `Control+C` and gracefully shutdown the server. 16 | 17 | ```javascript 18 | 'use strict'; 19 | 20 | const Path = require('path'); 21 | const Hapi = require('hapi'); 22 | const Gateway = require('hapi-gateway'); 23 | const server = new Hapi.Server(); 24 | 25 | server.connection(); 26 | server.register([ 27 | { 28 | register: Gateway, 29 | // These are options that are applied to all lambda handlers 30 | options: { 31 | role: 'arn:aws:iam::XXXX:role/lambda_basic_execution', // IAM role 32 | config: { 33 | accessKeyId: 'YOUR_ACCESS_KEY', // access key 34 | secretAccessKey: 'YOUR_SECRET_KEY', // secret key 35 | region: 'YOUR_REGION' // region 36 | } 37 | } 38 | } 39 | ], (err) => { 40 | if (err) { 41 | throw err; 42 | } 43 | 44 | server.route([ 45 | { 46 | // This deploys the lambda function code in deploy.source at startup. 47 | // The function can then be invoked via this route. When the server 48 | // shuts down, the lambda function is deleted. 49 | method: 'GET', 50 | path: '/hello-world', 51 | config: { 52 | handler: { 53 | lambda: { 54 | name: 'hello-world', 55 | deploy: { 56 | source: Path.join(__dirname, 'hello-world.js'), 57 | export: 'handler', 58 | teardown: true 59 | } 60 | } 61 | } 62 | } 63 | } 64 | ]); 65 | 66 | server.start((err) => { 67 | if (err) { 68 | throw err; 69 | } 70 | 71 | // Handle Control+C so the server can be stopped and lambdas torn down 72 | process.on('SIGINT', () => { 73 | console.log('Shutting down server...'); 74 | server.stop((err) => { 75 | if (err) { 76 | throw err; 77 | } 78 | 79 | process.exit(0); 80 | }); 81 | }); 82 | 83 | console.log(`Server started at ${server.info.uri}`); 84 | }); 85 | }); 86 | ``` 87 | 88 | The corresponding Lambda function code, which is loaded from `'hello-world.js'`, is shown below: 89 | 90 | ```javascript 91 | 'use strict'; 92 | 93 | module.exports.handler = function handler (event, context, callback) { 94 | callback(null, 'hello world!'); 95 | }; 96 | ``` 97 | 98 | ## API 99 | 100 | On plugin registration, `hapi-gateway` defines a new handler type named `lambda`. These routes are configured using an object with the following schema. 101 | 102 | - `name` (string) - The name of the Lambda function to invoke. 103 | - `setup(request, callback)` (function) - An optional function that creates the request payload sent to the Lambda function. `request` is the hapi request object associated with the route. Once `setup()` is complete, `callback()` is invoked with an error argument, followed by the payload to send to the Lambda function. If a custom `setup()` function is not provided, a default function is used which outputs a JSON string representing much of hapi request object. 104 | - `complete(err, response, request, reply)` (function) - An optional function that converts the Lambda function's response into a client reply. `err` and `response` are the error and response from the Lambda function. `request` and `reply` are the hapi request and reply objects. 105 | - `local` (boolean) - If `true`, the Lambda function will not be deployed to or invoked on AWS. Instead, the function will be run locally. This is useful for applying at the plugin level to test everything locally. Defaults to `false`. 106 | - `config` (object) - An optional configuration object passed directly to the [`Aws.Lambda()`](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Lambda.html#constructor-property) constructor. 107 | - `role` (string) - An AWS role with permission to execute the Lambda. 108 | - `deploy` (object) - An optional object used to deploy code as an AWS Lambda. Code is deployed at server startup using an `'onPreStart'` extension point. If this object is not provided, then the user is responsible for deploying the code prior to starting the server. If this object is present, it must adhere to the following schema. 109 | - `source` (string) - The path to a file containing Lambda function code. 110 | - `export` (string) - The name of the exported function in `source` that acts as the Lambda function's entry point. 111 | - `timeout` (number) - The execution timeout of the Lambda function in seconds. Defaults to three seconds. 112 | - `memory` (number) - The amount of memory, in MB, given to the Lambda function. Must be a multiple of 64MB. Defaults to 128MB. 113 | - `teardown` (boolean) - If `true`, the deployed Lambda function is deleted when the hapi server shuts down. The deletion is done during an `'onPostStop'` extension point. Defaults to `false`, meaning the deployed function is not deleted. 114 | - `exclude` (array) - An optional array of strings representing modules to exclude from the bundle. This array is passed to Browserify. This option is essential when bundling code that uses the `'aws-sdk'` module. You can bundle `'aws-sdk'` via the `files` option, or rely on the version that is natively available on Lambda. 115 | - `files` (array) - An optional array of strings and/or objects indicating additional files (such as standalone executables) to include in the zip archive. Strings specify file and directory paths. Objects should have `name` and `data` properties which are used as the file name and contents in the zip archive. 116 | 117 | It is worth noting that the same options can be provided to the plugin's `register()` function. The configuration for each route is used by merging the module defaults, the plugin registration options, and the individual route options (in order of increasing priority). 118 | -------------------------------------------------------------------------------- /examples/echo-inputs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.handler = function handler (event, context, callback) { 4 | callback(null, { event, context }); 5 | }; 6 | -------------------------------------------------------------------------------- /examples/error-response.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.handler = function handler (event, context, callback) { 4 | callback(new Error('something went wrong'), 'this should not be seen'); 5 | }; 6 | -------------------------------------------------------------------------------- /examples/hello-world.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.handler = function handler (event, context, callback) { 4 | callback(null, 'hello world!'); 5 | }; 6 | -------------------------------------------------------------------------------- /examples/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Path = require('path'); 4 | const Hapi = require('hapi'); 5 | const Gateway = require('../lib'); 6 | const server = new Hapi.Server(); 7 | 8 | server.connection(); 9 | server.register([ 10 | { 11 | register: Gateway, 12 | // These are options that are applied to all lambda handlers 13 | options: { 14 | role: 'arn:aws:iam::XXXX:role/lambda_basic_execution', // IAM role 15 | config: { 16 | accessKeyId: 'YOUR_ACCESS_KEY', // access key 17 | secretAccessKey: 'YOUR_SECRET_KEY', // secret key 18 | region: 'YOUR_REGION' // region 19 | } 20 | } 21 | } 22 | ], (err) => { 23 | if (err) { 24 | throw err; 25 | } 26 | 27 | server.route([ 28 | { 29 | // This is a "normal" hapi route. 30 | method: 'GET', 31 | path: '/typical', 32 | handler (request, reply) { 33 | reply('a typical hapi route'); 34 | } 35 | }, 36 | { 37 | // This calls a lambda function that is already deployed as "foo". 38 | // If you haven't deployed this already, the route will return a 500. 39 | method: 'GET', 40 | path: '/already-deployed', 41 | config: { 42 | handler: { 43 | lambda: { 44 | name: 'foo' 45 | } 46 | } 47 | } 48 | }, 49 | { 50 | // This deploys the lambda function code in deploy.source at startup. 51 | // The function can then be invoked via this route. When the server 52 | // shuts down, the lambda function is deleted. 53 | method: 'GET', 54 | path: '/hello-world', 55 | config: { 56 | handler: { 57 | lambda: { 58 | name: 'hello-world', 59 | deploy: { 60 | source: Path.join(__dirname, 'hello-world.js'), 61 | export: 'handler', 62 | teardown: true 63 | } 64 | } 65 | } 66 | } 67 | }, 68 | { 69 | // This demonstrates a lambda that returns an error 70 | method: 'GET', 71 | path: '/error-response', 72 | config: { 73 | handler: { 74 | lambda: { 75 | name: 'error-response', 76 | deploy: { 77 | source: Path.join(__dirname, 'error-response.js'), 78 | export: 'handler', 79 | teardown: true 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | { 86 | // This lambda returns its event and context values 87 | method: 'GET', 88 | path: '/echo-inputs', 89 | config: { 90 | handler: { 91 | lambda: { 92 | name: 'echo-inputs', 93 | deploy: { 94 | source: Path.join(__dirname, 'echo-inputs.js'), 95 | export: 'handler', 96 | teardown: true 97 | } 98 | } 99 | } 100 | } 101 | } 102 | ]); 103 | 104 | server.start((err) => { 105 | if (err) { 106 | throw err; 107 | } 108 | 109 | // Handle Control+C so the server can be stopped and lambdas torn down 110 | process.on('SIGINT', () => { 111 | console.log('Shutting down server...'); 112 | server.stop((err) => { 113 | if (err) { 114 | throw err; 115 | } 116 | 117 | process.exit(0); 118 | }); 119 | }); 120 | 121 | console.log(`Server started at ${server.info.uri}`); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Aws = require('aws-sdk'); 4 | const Bundler = require('lambundaler'); 5 | const Insync = require('insync'); 6 | const Joi = require('joi'); 7 | const Local = require('./local'); 8 | const Utils = require('./utils'); 9 | 10 | const settingsSymbol = Symbol('settings'); 11 | 12 | const schema = Joi.object({ 13 | name: Joi.string().required().description('name of lambda to invoke'), 14 | setup: Joi.func().required().arity(2).description('custom setup function'), 15 | complete: Joi.func().required().arity(4).description('custom complete function'), 16 | local: Joi.boolean().optional().default(false).description('override deploy behavior and run locally'), 17 | config: Joi.object().optional().default({}).description('general AWS config'), 18 | role: Joi.string().when('local', { is: true, then: Joi.optional(), otherwise: Joi.required() }).description('AWS role with execute permissions'), 19 | deploy: Joi.object().keys({ 20 | source: Joi.string().required().description('path to lambda source code'), 21 | export: Joi.string().required().description('export used as lambda handler'), 22 | memory: Joi.number().positive().integer().multiple(64).optional().default(Bundler.defaults.lambda.memory).description('allocated function memory in MB'), 23 | timeout: Joi.number().positive().integer().optional().default(Bundler.defaults.lambda.timeout).description('function execution timeout in seconds'), 24 | teardown: Joi.boolean().optional().default(false).description('delete function when the server shuts down'), 25 | exclude: Joi.array().items(Joi.string()).optional().default([]).description('modules to exclude during bundling'), 26 | files: Joi.array().items( 27 | Joi.string().description('file or directory to include in zip'), 28 | Joi.object().keys({ 29 | name: Joi.string().required().description('file name in zip file'), 30 | data: Joi.any().required().description('file data in zip file') 31 | }).description('name and data representing a zipped file') 32 | ).optional().default([]).description('additional files to include in zip file') 33 | }).optional().description('code to deploy to AWS') 34 | }); 35 | 36 | const defaults = { 37 | setup (request, callback) { 38 | callback(null, JSON.stringify({ 39 | app: request.app, 40 | auth: request.auth, 41 | headers: request.headers, 42 | id: request.id, 43 | info: request.info, 44 | method: request.method, 45 | mime: request.mime, 46 | params: request.params, 47 | path: request.path, 48 | payload: request.payload, 49 | query: request.query, 50 | state: request.state 51 | })); 52 | }, 53 | complete (err, response, request, reply) { 54 | if (err) { 55 | return reply(err); 56 | } 57 | 58 | const statusCode = response.FunctionError !== undefined ? 59 | 500 : response.StatusCode; 60 | const payload = Utils.tryParse(response.Payload); 61 | 62 | reply(payload).code(statusCode); 63 | } 64 | }; 65 | 66 | 67 | function forEachLambdaHandler (server, fn, callback) { 68 | Insync.eachSeries(server.connections, function eachConnection (conn, cb) { 69 | Insync.eachSeries(conn.table(), function eachRoute (route, next) { 70 | const handler = route.settings.handler; 71 | const settings = handler[settingsSymbol]; 72 | 73 | // Skip non-lambda routes 74 | if (typeof settings !== 'object') { 75 | return next(); 76 | } 77 | 78 | fn(settings, next); 79 | }, cb); 80 | }, callback); 81 | } 82 | 83 | 84 | function invokeLambda (settings, payload, callback) { 85 | settings._lambda.invoke({ 86 | FunctionName: settings.name, 87 | Payload: payload 88 | }, callback); 89 | } 90 | 91 | 92 | module.exports.register = function register (server, pluginOptions, next) { 93 | server.handler('lambda', function createLambdaHandler (route, options) { 94 | let settings = Object.assign({}, defaults, pluginOptions, options); 95 | const validation = Joi.validate(settings, schema); 96 | 97 | if (validation.error) { 98 | throw validation.error; 99 | } 100 | 101 | settings = validation.value; 102 | settings._deployed = false; 103 | 104 | if (settings.local === true) { 105 | settings._lambda = null; 106 | settings._invoke = Local.invoke; 107 | } else { 108 | settings._lambda = new Aws.Lambda(settings.config); 109 | settings._invoke = invokeLambda; 110 | } 111 | 112 | const handler = function handler (request, reply) { 113 | settings.setup(request, function setupCb (err, payload) { 114 | if (err) { 115 | return settings.complete(err, payload, request, reply); 116 | } 117 | 118 | settings._invoke(settings, payload, function invokeCb (err, response) { 119 | settings.complete(err, response, request, reply); 120 | }); 121 | }); 122 | }; 123 | 124 | handler[settingsSymbol] = settings; 125 | 126 | return handler; 127 | }); 128 | 129 | server.ext({ 130 | type: 'onPreStart', 131 | method (server, next) { 132 | forEachLambdaHandler(server, function maybeDeploy (settings, cb) { 133 | const deploy = settings.deploy; 134 | 135 | // Only process routes that want to be deployed at startup 136 | if (typeof deploy !== 'object' || settings.local === true) { 137 | return cb(); 138 | } 139 | 140 | settings._deployed = true; 141 | Bundler.bundle({ 142 | entry: deploy.source, 143 | export: deploy.export, 144 | exclude: deploy.exclude, 145 | files: deploy.files, 146 | deploy: { 147 | config: settings.config, 148 | name: settings.name, 149 | role: settings.role, 150 | timeout: deploy.timeout, 151 | memory: deploy.memory, 152 | overwrite: true 153 | } 154 | }, cb); 155 | }, next); 156 | } 157 | }); 158 | 159 | server.ext({ 160 | type: 'onPostStop', 161 | method (server, next) { 162 | forEachLambdaHandler(server, function maybeDelete (settings, cb) { 163 | // Only process deployed routes that want to be destroyed at shutdown 164 | if (settings._deployed !== true || settings.deploy.teardown !== true) { 165 | return cb(); 166 | } 167 | 168 | settings._lambda.deleteFunction({ FunctionName: settings.name }, cb); 169 | }, next); 170 | } 171 | }); 172 | 173 | next(); 174 | }; 175 | 176 | 177 | module.exports.register.attributes = { 178 | pkg: require('../package.json') 179 | }; 180 | -------------------------------------------------------------------------------- /lib/local.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Utils = require('./utils'); 4 | 5 | 6 | module.exports.invoke = function invoke (settings, payload, callback) { 7 | try { 8 | const deploy = settings.deploy; 9 | const handler = require(deploy.source)[deploy.export]; 10 | const event = Utils.tryParse(payload); 11 | const context = {}; // Currently intentionally left empty 12 | 13 | handler(event, context, function handlerCb (err, data) { 14 | if (err) { 15 | return callback(null, formatErrorAsLambdaResponse(err)); 16 | } 17 | 18 | callback(null, createLambdaResponse(data)); 19 | }); 20 | } catch (err) { 21 | return callback(new Error('cannot invoke function locally')); 22 | } 23 | }; 24 | 25 | 26 | function createLambdaResponse (data) { 27 | try { 28 | return { 29 | StatusCode: 200, 30 | Payload: JSON.stringify(data) 31 | }; 32 | } catch (err) { 33 | return formatErrorAsLambdaResponse(err); 34 | } 35 | } 36 | 37 | 38 | function formatErrorAsLambdaResponse (err) { 39 | return { 40 | StatusCode: 200, 41 | FunctionError: 'Handled', 42 | Payload: JSON.stringify({ 43 | errorMessage: err.message, 44 | errorType: err.constructor.name, 45 | stackTrace: [] // Currently intentionally left empty 46 | }) 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.tryParse = function tryParse (data) { 4 | try { 5 | return JSON.parse(data); 6 | } catch (err) { 7 | return data; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-gateway", 3 | "version": "0.6.0", 4 | "description": "use hapi as a gateway to lambda functions", 5 | "author": "Continuation Labs (http://continuation.io/)", 6 | "main": "lib/index.js", 7 | "homepage": "https://github.com/continuationlabs/hapi-gateway", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/continuationlabs/hapi-gateway" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/continuationlabs/hapi-gateway/issues" 14 | }, 15 | "license": "MIT", 16 | "scripts": { 17 | "lint": "belly-button -f", 18 | "test": "npm run lint && lab -v -a code -t 100" 19 | }, 20 | "engines": { 21 | "node": ">=4.0.0" 22 | }, 23 | "dependencies": { 24 | "aws-sdk": "2.4.12", 25 | "insync": "2.1.1", 26 | "joi": "9.0.4", 27 | "lambundaler": "0.7.0" 28 | }, 29 | "devDependencies": { 30 | "aws-sdk-mock": "1.4.x", 31 | "belly-button": "3.x.x", 32 | "code": "3.x.x", 33 | "hapi": "14.x.x", 34 | "lab": "10.x.x", 35 | "stand-in": "4.x.x" 36 | }, 37 | "keywords": [ 38 | "AWS", 39 | "lambda", 40 | "lambda function", 41 | "gateway", 42 | "API gateway", 43 | "API", 44 | "hapi", 45 | "plugin" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /test/fixtures/circular.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.handler = function handler (event, context, callback) { 4 | const obj = { foo: 'bar' }; 5 | 6 | obj.baz = obj; 7 | callback(null, obj); 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/errback.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.handler = function handler (event, context, callback) { 4 | callback(new Error('problem'), 'this should not be seen'); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.handler = function handler (event, context, callback) { 4 | callback(null, { event, context }); 5 | }; 6 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Path = require('path'); 4 | const AwsMock = require('aws-sdk-mock'); 5 | const Bundler = require('lambundaler'); 6 | const Code = require('code'); 7 | const Hapi = require('hapi'); 8 | const Lab = require('lab'); 9 | const StandIn = require('stand-in'); 10 | const Plugin = require('../lib'); 11 | 12 | const lab = exports.lab = Lab.script(); 13 | const expect = Code.expect; 14 | const describe = lab.describe; 15 | const it = lab.it; 16 | 17 | const fixturesDir = Path.join(__dirname, 'fixtures'); 18 | 19 | function prepareServer (options, callback) { 20 | const server = new Hapi.Server(); 21 | 22 | if (typeof options === 'function') { 23 | callback = options; 24 | options = {}; 25 | } 26 | 27 | options.plugin = options.plugin || { 28 | role: 'arn:aws:iam::12345:role/lambda_basic_execution' 29 | }; 30 | 31 | options.routes = options.routes || [ 32 | { 33 | method: 'GET', 34 | path: '/foo', 35 | config: { 36 | handler: { 37 | lambda: { 38 | name: 'foo' 39 | } 40 | } 41 | } 42 | } 43 | ]; 44 | 45 | server.connection(options.connection); 46 | server.register([{ register: Plugin, options: options.plugin }], (err) => { 47 | if (err) { 48 | return callback(err); 49 | } 50 | 51 | server.route(options.routes); 52 | server.initialize((err) => { 53 | callback(err, server); 54 | }); 55 | }); 56 | } 57 | 58 | describe('hapi Gateway', () => { 59 | it('invokes the lambda function', (done) => { 60 | AwsMock.mock('Lambda', 'invoke', function (options, callback) { 61 | callback(null, { StatusCode: 200, Payload: 'foobar' }); 62 | }); 63 | 64 | prepareServer((err, server) => { 65 | expect(err).to.not.exist(); 66 | 67 | server.inject({ 68 | method: 'GET', 69 | url: '/foo' 70 | }, (res) => { 71 | AwsMock.restore('Lambda', 'invoke'); 72 | expect(res.statusCode).to.equal(200); 73 | expect(res.result).to.equal('foobar'); 74 | server.stop(done); 75 | }); 76 | }); 77 | }); 78 | 79 | it('accepts a custom setup function', (done) => { 80 | const options = { 81 | routes: [ 82 | { 83 | method: 'GET', 84 | path: '/foo', 85 | config: { 86 | handler: { 87 | lambda: { 88 | name: 'foo', 89 | setup (request, callback) { 90 | callback(null, { foo: 'bar' }); 91 | } 92 | } 93 | } 94 | } 95 | } 96 | ] 97 | }; 98 | 99 | AwsMock.mock('Lambda', 'invoke', function (options, callback) { 100 | callback(null, { StatusCode: 200, Payload: options.Payload }); 101 | }); 102 | 103 | prepareServer(options, (err, server) => { 104 | expect(err).to.not.exist(); 105 | 106 | server.inject({ 107 | method: 'GET', 108 | url: '/foo' 109 | }, (res) => { 110 | AwsMock.restore('Lambda', 'invoke'); 111 | expect(res.statusCode).to.equal(200); 112 | expect(res.result).to.equal({ foo: 'bar' }); 113 | server.stop(done); 114 | }); 115 | }); 116 | }); 117 | 118 | it('handles errors from the setup function', (done) => { 119 | const options = { 120 | routes: [ 121 | { 122 | method: 'GET', 123 | path: '/foo', 124 | config: { 125 | handler: { 126 | lambda: { 127 | name: 'foo', 128 | setup (request, callback) { 129 | callback(new Error('foo')); 130 | } 131 | } 132 | } 133 | } 134 | } 135 | ] 136 | }; 137 | 138 | prepareServer(options, (err, server) => { 139 | expect(err).to.not.exist(); 140 | 141 | server.inject({ 142 | method: 'GET', 143 | url: '/foo' 144 | }, (res) => { 145 | expect(res.statusCode).to.equal(500); 146 | server.stop(done); 147 | }); 148 | }); 149 | }); 150 | 151 | it('accepts a custom complete function', (done) => { 152 | const options = { 153 | routes: [ 154 | { 155 | method: 'GET', 156 | path: '/foo', 157 | config: { 158 | handler: { 159 | lambda: { 160 | name: 'foo', 161 | complete (ignoreErr, response, request, reply) { 162 | reply('foobar'); 163 | } 164 | } 165 | } 166 | } 167 | } 168 | ] 169 | }; 170 | 171 | AwsMock.mock('Lambda', 'invoke', function (options, callback) { 172 | callback(null, options); 173 | }); 174 | 175 | prepareServer(options, (err, server) => { 176 | expect(err).to.not.exist(); 177 | 178 | server.inject({ 179 | method: 'GET', 180 | url: '/foo' 181 | }, (res) => { 182 | AwsMock.restore('Lambda', 'invoke'); 183 | expect(res.statusCode).to.equal(200); 184 | expect(res.result).to.equal('foobar'); 185 | server.stop(done); 186 | }); 187 | }); 188 | }); 189 | 190 | it('handles errors from AWS', (done) => { 191 | AwsMock.mock('Lambda', 'invoke', function (options, callback) { 192 | callback(new Error('foo')); 193 | }); 194 | 195 | prepareServer((err, server) => { 196 | expect(err).to.not.exist(); 197 | 198 | server.inject({ 199 | method: 'GET', 200 | url: '/foo' 201 | }, (res) => { 202 | AwsMock.restore('Lambda', 'invoke'); 203 | expect(res.statusCode).to.equal(500); 204 | server.stop(done); 205 | }); 206 | }); 207 | }); 208 | 209 | it('throws if input validation fails', (done) => { 210 | const options = { 211 | routes: [ 212 | { 213 | method: 'GET', 214 | path: '/foo', 215 | config: { handler: { lambda: {} } } 216 | } 217 | ] 218 | }; 219 | 220 | expect(() => { 221 | prepareServer(options, (ignoreErr, server) => {}); 222 | }).to.throw(Error); 223 | done(); 224 | }); 225 | 226 | it('only deploys lambda routes that are configured to do so', (done) => { 227 | const stand = StandIn.replace(Bundler, 'bundle', (stand, options, callback) => { 228 | callback(null, null, {}); 229 | }); 230 | 231 | const options = { 232 | plugin: { 233 | role: 'arn:aws:iam::12345:role/lambda_basic_execution', 234 | config: { 235 | accessKeyId: 'foo', 236 | secretAccessKey: 'bar', 237 | region: 'us-east-1' 238 | } 239 | }, 240 | routes: [ 241 | // non-lambda route 242 | { 243 | method: 'GET', 244 | path: '/baz', 245 | handler (request, reply) { 246 | reply('baz'); 247 | } 248 | }, 249 | // lambda route with no deploy information 250 | { 251 | method: 'GET', 252 | path: '/bar', 253 | config: { 254 | handler: { 255 | lambda: { 256 | name: 'bar' 257 | } 258 | } 259 | } 260 | }, 261 | // lambda route with deploy information 262 | { 263 | method: 'GET', 264 | path: '/foo', 265 | config: { 266 | handler: { 267 | lambda: { 268 | name: 'foo', 269 | deploy: { 270 | source: Path.join(fixturesDir, 'index.js'), 271 | export: 'handler' 272 | } 273 | } 274 | } 275 | } 276 | }, 277 | // lambda route with deploy information but marked as local 278 | { 279 | method: 'GET', 280 | path: '/quux', 281 | config: { 282 | handler: { 283 | lambda: { 284 | name: 'quux', 285 | local: true, 286 | deploy: { 287 | source: Path.join(fixturesDir, 'index.js'), 288 | export: 'handler' 289 | } 290 | } 291 | } 292 | } 293 | } 294 | ] 295 | }; 296 | 297 | prepareServer(options, (err, server) => { 298 | stand.restore(); 299 | expect(stand.invocations).to.equal(1); 300 | expect(err).to.not.exist(); 301 | server.stop(done); 302 | }); 303 | }); 304 | 305 | it('handles deployment errors', (done) => { 306 | const stand = StandIn.replace(Bundler, 'bundle', (stand, options, callback) => { 307 | callback(new Error('foo')); 308 | }); 309 | 310 | const options = { 311 | plugin: { 312 | role: 'arn:aws:iam::12345:role/lambda_basic_execution', 313 | config: { 314 | accessKeyId: 'foo', 315 | secretAccessKey: 'bar', 316 | region: 'us-east-1' 317 | } 318 | }, 319 | routes: [ 320 | { 321 | method: 'GET', 322 | path: '/foo', 323 | config: { 324 | handler: { 325 | lambda: { 326 | name: 'foo', 327 | deploy: { 328 | source: Path.join(fixturesDir, 'index.js'), 329 | export: 'handler' 330 | } 331 | } 332 | } 333 | } 334 | } 335 | ] 336 | }; 337 | 338 | prepareServer(options, (err, server) => { 339 | stand.restore(); 340 | expect(err).to.be.an.error(Error, 'foo'); 341 | server.stop(done); 342 | }); 343 | }); 344 | 345 | it('deletes functions when teardown is true', (done) => { 346 | const bundleStand = StandIn.replace(Bundler, 'bundle', (stand, options, callback) => { 347 | callback(null, null, {}); 348 | }); 349 | 350 | const options = { 351 | plugin: { 352 | role: 'arn:aws:iam::12345:role/lambda_basic_execution', 353 | config: { 354 | accessKeyId: 'foo', 355 | secretAccessKey: 'bar', 356 | region: 'us-east-1' 357 | } 358 | }, 359 | routes: [ 360 | // lambda route with no deploy information 361 | { 362 | method: 'GET', 363 | path: '/baz', 364 | config: { 365 | handler: { 366 | lambda: { 367 | name: 'baz' 368 | } 369 | } 370 | } 371 | }, 372 | // lambda route with deploy information but no teardown 373 | { 374 | method: 'GET', 375 | path: '/bar', 376 | config: { 377 | handler: { 378 | lambda: { 379 | name: 'bar', 380 | deploy: { 381 | source: Path.join(fixturesDir, 'index.js'), 382 | export: 'handler' 383 | } 384 | } 385 | } 386 | } 387 | }, 388 | // lambda route with deploy information and teardown 389 | { 390 | method: 'GET', 391 | path: '/foo', 392 | config: { 393 | handler: { 394 | lambda: { 395 | name: 'foo', 396 | deploy: { 397 | source: Path.join(fixturesDir, 'index.js'), 398 | export: 'handler', 399 | teardown: true 400 | } 401 | } 402 | } 403 | } 404 | } 405 | ] 406 | }; 407 | 408 | prepareServer(options, (err, server) => { 409 | bundleStand.restore(); 410 | expect(bundleStand.invocations).to.equal(2); 411 | expect(err).to.not.exist(); 412 | 413 | AwsMock.mock('Lambda', 'deleteFunction', function (options, callback) { 414 | callback(null, {}); 415 | }); 416 | 417 | server.stop((err) => { 418 | AwsMock.restore('Lambda', 'deleteFunction'); 419 | expect(err).to.not.exist(); 420 | done(); 421 | }); 422 | }); 423 | }); 424 | 425 | it('invokes lambdas locally', (done) => { 426 | const options = { 427 | plugin: { local: true }, 428 | routes: [ 429 | { 430 | method: 'GET', 431 | path: '/foo', 432 | config: { 433 | handler: { 434 | lambda: { 435 | name: 'foo', 436 | deploy: { 437 | source: Path.join(fixturesDir, 'index.js'), 438 | export: 'handler' 439 | } 440 | } 441 | } 442 | } 443 | } 444 | ] 445 | }; 446 | 447 | prepareServer(options, (err, server) => { 448 | expect(err).to.not.exist(); 449 | 450 | server.inject({ 451 | method: 'GET', 452 | url: '/foo' 453 | }, (res) => { 454 | expect(res.statusCode).to.equal(200); 455 | expect(res.result).to.be.an.object(); 456 | expect(res.result.context).to.equal({}); 457 | expect(res.result.event).to.be.an.object(); 458 | expect(res.result.event.app).to.equal({}); 459 | expect(res.result.event.auth).to.be.an.object(); 460 | expect(res.result.event.headers).to.be.an.object(); 461 | expect(res.result.event.id).to.be.a.string(); 462 | expect(res.result.event.info).to.be.an.object(); 463 | expect(res.result.event.method).to.be.equal('get'); 464 | expect(res.result.event.mime).to.equal(null); 465 | expect(res.result.event.params).to.be.an.object(); 466 | expect(res.result.event.path).to.equal('/foo'); 467 | expect(res.result.event.payload).to.equal(null); 468 | expect(res.result.event.query).to.be.an.object(); 469 | expect(res.result.event.state).to.be.an.object(); 470 | server.stop(done); 471 | }); 472 | }); 473 | }); 474 | 475 | it('handles lambda errors when run locally', (done) => { 476 | const options = { 477 | plugin: { 478 | role: 'arn:aws:iam::12345:role/lambda_basic_execution', 479 | local: true 480 | }, 481 | routes: [ 482 | { 483 | method: 'GET', 484 | path: '/foo', 485 | config: { 486 | handler: { 487 | lambda: { 488 | name: 'foo', 489 | deploy: { 490 | source: Path.join(fixturesDir, 'errback.js'), 491 | export: 'handler' 492 | } 493 | } 494 | } 495 | } 496 | }, 497 | { 498 | method: 'GET', 499 | path: '/bar', 500 | config: { 501 | handler: { 502 | lambda: { 503 | name: 'bar', 504 | deploy: { 505 | source: Path.join(fixturesDir, 'circular.js'), 506 | export: 'handler' 507 | } 508 | } 509 | } 510 | } 511 | } 512 | ] 513 | }; 514 | 515 | prepareServer(options, (err, server) => { 516 | expect(err).to.not.exist(); 517 | 518 | server.inject({ 519 | method: 'GET', 520 | url: '/foo' 521 | }, (res) => { 522 | expect(res.statusCode).to.equal(500); 523 | expect(res.result).to.equal({ 524 | errorMessage: 'problem', 525 | errorType: 'Error', 526 | stackTrace: [] 527 | }); 528 | 529 | server.inject({ 530 | method: 'GET', 531 | url: '/bar' 532 | }, (res) => { 533 | expect(res.statusCode).to.equal(500); 534 | expect(res.result).to.equal({ 535 | errorMessage: 'Converting circular structure to JSON', 536 | errorType: 'TypeError', 537 | stackTrace: [] 538 | }); 539 | server.stop(done); 540 | }); 541 | }); 542 | }); 543 | }); 544 | 545 | it('cannot invoke a function locally without code', (done) => { 546 | const options = { 547 | plugin: { 548 | role: 'arn:aws:iam::12345:role/lambda_basic_execution', 549 | local: true 550 | }, 551 | routes: [ 552 | { 553 | method: 'GET', 554 | path: '/foo', 555 | config: { handler: { lambda: { name: 'foo' } } } 556 | } 557 | ] 558 | }; 559 | 560 | prepareServer(options, (err, server) => { 561 | expect(err).to.not.exist(); 562 | 563 | server.inject({ 564 | method: 'GET', 565 | url: '/foo' 566 | }, (res) => { 567 | expect(res.statusCode).to.equal(500); 568 | server.stop(done); 569 | }); 570 | }); 571 | }); 572 | }); 573 | --------------------------------------------------------------------------------