diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a01699 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.DS_Store +*.log +.nyc_output/ +node_modules/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7cb323e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: node_js +node_js: + - '6.0' + - '6.10' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9637ca9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 DieProduktMacher GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..099f561 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +Serverless Local Dev Server Plugin (Beta) +======= + +[![Build Status](https://travis-ci.org/DieProduktMacher/serverless-local-dev-server.svg?branch=develop)](https://travis-ci.org/DieProduktMacher/serverless-local-dev-server) + +This plugin exposes your Alexa-Skill and HTTP functions as local HTTP endpoints, removing the need to deploy every change to AWS Lambda. You can connect these endpoints to Alexa or services like Messenger Bots via forwardhq, ngrok or any other forwarding tool. + +Supported features: + +* Expose `alexa-skill` and `http` events as HTTP endpoints +* Environment variables +* Very basic HTTP integration +* Auto reload via nodemon (see *How To*) + +This package requires node >= 6.0 + + +# How To + +### 1. Install the plugin + +```sh +npm install serverless-local-dev-server --save-dev +``` + +### 2. Add the plugin to your serverless configuration file + +*serverless.yml* configuration example: + +```yaml +provider: + name: aws + runtime: nodejs6.10 + +functions: + hello: + handler: handler.hello + events: + - alexaSkill + - http: GET / + +# Add serverless-local-dev-server to your plugins: +plugins: + - serverless-local-dev-server +``` + +### 3. Start the server + +```sh +serverless local-dev-server +``` + +On default the server listens on port 5005. You can specify another one with the *--port* argument: + +```sh +serverless local-dev-server --port 5000 +``` + +To automatically restart the server when files change, you may use nodemon: + +```sh +nodemon --exec "serverless local-dev-server" -e "js yml json" +``` + +To see responses returned from Lambda and stack traces, prepend SLS_DEBUG=* + +```sh +SLS_DEBUG=* serverless local-http-server +``` + +### 4. For Alexa Skills + +#### 4.1 Share localhost with the internet + +For example with forwardhq: + +```sh +forward 5005 +``` + +#### 4.2 Configure AWS to use your HTTPS endpoint + +In the Configuration pane, select HTTPS as service endpoint type and specify the forwarded endpoint URL. + +As method for SSL Certificate validation select *My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority*. + + +# License & Credits + +Licensed under the MIT license. + +Created and maintained by [DieProduktMacher](http://www.dieproduktmacher.com). diff --git a/package.json b/package.json new file mode 100644 index 0000000..f137863 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "serverless-local-http-server", + "version": "0.1.0", + "engines": { + "node": ">=6.0" + }, + "description": "Develop Alexa-Skill and HTTP functions in Serverless without deploying to AWS", + "author": "DieProduktMacher ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/DieProduktMacher/serverless-local-http-server" + }, + "keywords": [ + "serverless", + "serverless-plugin", + "alexa", + "alexa-skill", + "http", + "development", + "dev", + "local", + "aws-lambda" + ], + "main": "src/index.js", + "scripts": { + "test": "nyc mocha", + "lint": "standard" + }, + "dependencies": { + "body-parser": "^1.17.2", + "express": "^4.15.3" + }, + "devDependencies": { + "chai": "^4.0.0", + "chai-as-promised": "^7.0.0", + "mocha": "^3.4.2", + "node-fetch": "^1.7.1", + "nyc": "^10.3.2", + "serverless": "^1.14.0", + "sinon": "^2.3.2", + "standard": "^10.0.2" + } +} diff --git a/src/Server.js b/src/Server.js new file mode 100644 index 0000000..6f9b922 --- /dev/null +++ b/src/Server.js @@ -0,0 +1,93 @@ +'use strict' + +const Express = require('express') +const BodyParser = require('body-parser') +const path = require('path') +const getEndpoints = require('./endpoints/get') + +class Server { + constructor () { + this.functions = [] + this.defaultEnvironment = Object.assign({}, process.env) + this.log = console.log + } + // Starts the server + start (port) { + if (this.functions.length === 0) { + this.log('No Lambdas with Alexa-Skill or HTTP events found') + return + } + this.app = Express() + this.app.use(BodyParser.json()) + this.functions.forEach(func => + func.endpoints.forEach(endpoint => this._attachEndpoint(func, endpoint)) + ) + this.app.listen(port, _ => { + this.log(`Listening on port ${port} for requests 🚀`) + this.log('----') + this.functions.forEach(func => { + this.log(`${func.name}:`) + func.endpoints.forEach(endpoint => { + this.log(` ${endpoint.method} http://localhost:${port}${endpoint.path}`) + }) + }) + this.log('----') + }) + } + // Sets functions, including endpoints, using the serverless config and service path + setFunctions (serverlessConfig, servicePath) { + this.functions = Object.keys(serverlessConfig.functions).map(name => { + let functionConfig = serverlessConfig.functions[name] + let handlerParts = functionConfig.handler.split('.') + return { + name: name, + config: serverlessConfig.functions[name], + handlerModulePath: path.join(servicePath, handlerParts[0]), + handlerFunctionName: handlerParts[1], + environment: Object.assign({}, serverlessConfig.provider.environment, functionConfig.environment) + } + }).map(func => + Object.assign({}, func, { endpoints: getEndpoints(func) }) + ).filter(func => + func.endpoints.length > 0 + ) + } + // Attaches HTTP endpoint to Express + _attachEndpoint (func, endpoint) { + // Validate method and path + /* istanbul ignore next */ + if (!endpoint.method || !endpoint.path) { + return this.log(`Endpoint ${endpoint.type} for function ${func.name} has no method or path`) + } + // Add HTTP endpoint to Express + this.app[endpoint.method.toLowerCase()](endpoint.path, (request, response) => { + this.log(`${endpoint}`) + // Execute Lambda with corresponding event, forward response to Express + let lambdaEvent = endpoint.getLambdaEvent(request) + this._executeLambdaHandler(func, lambdaEvent).then(result => { + this.log(' ➡ Success') + if (process.env.SLS_DEBUG) console.info(result) + endpoint.handleLambdaSuccess(response, result) + }).catch(error => { + this.log(` ➡ Failure: ${error.message}`) + if (process.env.SLS_DEBUG) console.error(error.stack) + endpoint.handleLambdaFailure(response, error) + }) + }) + } + // Loads and executes the Lambda handler + _executeLambdaHandler (func, event) { + return new Promise((resolve, reject) => { + // Load function and variables + let handle = require(func.handlerModulePath)[func.handlerFunctionName] + let context = { succeed: resolve, fail: reject } + let callback = (error, result) => (!error) ? resolve(result) : reject(error) + // Set new environment variables, execute handler function + process.env = Object.assign({ IS_OFFLINE: true }, func.environment, this.defaultEnvironment) + handle(event, context, callback) + process.env = Object.assign({}, this.defaultEnvironment) + }) + } +} + +module.exports = Server diff --git a/src/endpoints/AlexaSkillEndpoint.js b/src/endpoints/AlexaSkillEndpoint.js new file mode 100644 index 0000000..12b6ef1 --- /dev/null +++ b/src/endpoints/AlexaSkillEndpoint.js @@ -0,0 +1,24 @@ +'use strict' + +const Endpoint = require('./Endpoint') + +class AlexaSkillEndpoint extends Endpoint { + constructor (alexaSkillConfig, func) { + super(alexaSkillConfig, func) + this.name = func.name + this.method = 'POST' + this.path = `/alexa-skill/${this.name}` + } + getLambdaEvent (request) { + // Pass-through + return request.body + } + handleLambdaSuccess (response, result) { + response.send(result) + } + toString () { + return `Alexa-Skill: ${this.name}` + } +} + +module.exports = AlexaSkillEndpoint diff --git a/src/endpoints/Endpoint.js b/src/endpoints/Endpoint.js new file mode 100644 index 0000000..bc95745 --- /dev/null +++ b/src/endpoints/Endpoint.js @@ -0,0 +1,22 @@ +'use strict' + +class Endpoint { + constructor (eventConfig, func) { + this.method = 'GET' + this.path = '' + } + /* istanbul ignore next */ + getLambdaEvent (request) { + return {} + } + /* istanbul ignore next */ + handleLambdaSuccess (response, result) { + response.sendStatus(204) + } + /* istanbul ignore next */ + handleLambdaFailure (response, error) { + response.sendStatus(500) + } +} + +module.exports = Endpoint diff --git a/src/endpoints/HttpEndpoint.js b/src/endpoints/HttpEndpoint.js new file mode 100644 index 0000000..9692ce9 --- /dev/null +++ b/src/endpoints/HttpEndpoint.js @@ -0,0 +1,36 @@ +'use strict' + +const path = require('path') +const Endpoint = require('./Endpoint') + +class HttpEndpoint extends Endpoint { + constructor (httpConfig, func) { + super(httpConfig, func) + if (typeof httpConfig === 'string') { + let s = httpConfig.split(' ') + httpConfig = { method: s[0], path: s[1] } + } + this.method = httpConfig.method + this.resourcePath = httpConfig.path.replace(/\{([a-zA-Z_]+)\}/g, ':$1') + this.path = path.join('/http', this.resourcePath) + } + getLambdaEvent (request) { + return { + httpMethod: request.method, + body: JSON.stringify(request.body, null, ' '), + queryStringParameters: request.query + } + } + handleLambdaSuccess (response, result) { + if (result.headers) { + response.set(result.headers) + } + response.status(result.statusCode) + response.send(result.body === 'object' ? JSON.stringify(result.body) : result.body) + } + toString () { + return `HTTP: ${this.method} ${this.resourcePath}` + } +} + +module.exports = HttpEndpoint diff --git a/src/endpoints/get.js b/src/endpoints/get.js new file mode 100644 index 0000000..7b95eb2 --- /dev/null +++ b/src/endpoints/get.js @@ -0,0 +1,27 @@ +'use strict' + +const mappings = { + 'alexaSkill': require('./AlexaSkillEndpoint'), + 'http': require('./HttpEndpoint') +} + +module.exports = (func) => { + return func.config.events.map(event => { + switch (typeof event) { + case 'string': + return { type: event, config: {} } + case 'object': + let type = Object.keys(event)[0] + return { type: type, config: event[type] } + /* istanbul ignore next */ + default: + return null + } + }).filter(_ => + !!mappings[_.type] + ).map(_ => { + let endpoint = new mappings[_.type](_.config, func) + endpoint.type = _.type + return endpoint + }) +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..fe52677 --- /dev/null +++ b/src/index.js @@ -0,0 +1,33 @@ +'use strict' + +const Server = require('./Server.js') + +class ServerlessPlugin { + constructor (serverless, options) { + this.serverless = serverless + this.options = options || {} + + this.commands = { + 'local-dev-server': { + usage: 'Runs a local dev server for Alexa-Skill and HTTP functions', + lifecycleEvents: [ 'start' ], + options: { + port: { usage: 'Port to listen on', shortcut: 'p' } + } + } + } + + this.hooks = { + 'local-dev-server:start': this.start.bind(this) + } + } + + start () { + let server = new Server() + server.log = this.serverless.cli.log.bind(this.serverless.cli) + server.setFunctions(this.serverless.service, this.serverless.config.servicePath) + server.start(this.options.port || 5005) + } +} + +module.exports = ServerlessPlugin diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..9a7e8a1 --- /dev/null +++ b/test/index.js @@ -0,0 +1,171 @@ +'use strict' + +/* global describe it beforeEach afterEach */ +const chai = require('chai') +const chaiAsPromised = require('chai-as-promised') +const fetch = require('node-fetch') +const sinon = require('sinon') +const Serverless = require('serverless/lib/Serverless') +const AwsProvider = require('serverless/lib/plugins/aws/provider/awsProvider') +const AlexaDevServer = require('../src') + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('index.js', () => { + var sandbox, serverless, alexaDevServer + + const sendAlexaRequest = (port, name) => { + return fetch(`http://localhost:${port}/alexa-skill/${name}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: '{"session":{},"request":{},"version":"1.0"}' + }) + } + + const sendHttpGetRequest = (port, path) => { + return fetch(`http://localhost:${port}/http/${path}`) + } + + const sendHttpPostRequest = (port, path) => { + return fetch(`http://localhost:${port}/http/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: '{"foo":"bar"}' + }) + } + + beforeEach(() => { + sandbox = sinon.sandbox.create() + + serverless = new Serverless() + serverless.init() + serverless.setProvider('aws', new AwsProvider(serverless)) + serverless.config.servicePath = __dirname + }) + + afterEach((done) => { + sandbox.restore() + done() + }) + + it('should have hooks', () => { + alexaDevServer = new AlexaDevServer(serverless) + expect(Object.keys(alexaDevServer.hooks).length).to.not.equal(0) + }) + + it('should start a server and accept various requests', () => { + serverless.service.functions = { + 'MyAlexaSkill': { + handler: 'lambda-handler.alexaSkill', + events: [ 'alexaSkill' ] + }, + 'MyHttpResource': { + handler: 'lambda-handler.httpGet', + events: [ { http: { method: 'GET', path: '/' } } ] + }, + 'MyShorthandHttpResource': { + handler: 'lambda-handler.httpPost', + events: [ { http: 'POST shorthand' } ] + } + } + alexaDevServer = new AlexaDevServer(serverless) + alexaDevServer.hooks['local-dev-server:start']() + return Promise.all([ + sendAlexaRequest(5005, 'MyAlexaSkill').then(result => + expect(result.ok).equal(true) + ), + sendHttpGetRequest(5005, '?a=b&c=d').then(result => { + expect(result.status).equal(200) + return result.json().then(json => { + expect(json.queryStringParameters.a).equal('b') + expect(json.queryStringParameters.c).equal('d') + }) + }), + sendHttpPostRequest(5005, 'shorthand', {}).then(result => { + expect(result.status).equal(204) + }) + ]) + }) + + it('should start a server with a custom port and accept requests', () => { + serverless.service.functions = { + 'MyHttpResource': { + handler: 'lambda-handler.httpGet', + events: [ { http: 'GET /' } ] + } + } + alexaDevServer = new AlexaDevServer(serverless, { port: 5006 }) + alexaDevServer.hooks['local-dev-server:start']() + return sendHttpGetRequest(5006, '').then(result => + expect(result.ok).equal(true) + ) + }) + + it('should set environment variables correctly', () => { + serverless.service.provider.environment = { + foo: 'bar', + bla: 'blub' + } + serverless.service.functions = { + 'MyAlexaSkill': { + handler: 'lambda-handler.mirrorEnv', + events: [ 'alexaSkill' ], + environment: { + foo: 'baz' + } + } + } + alexaDevServer = new AlexaDevServer(serverless, { port: 5007 }) + alexaDevServer.hooks['local-dev-server:start']() + return sendAlexaRequest(5007, 'MyAlexaSkill').then(result => { + expect(result.ok).equal(true) + return result.json() + }).then(json => { + expect(json.foo).equal('baz') + expect(json.bla).equal('blub') + }) + }) + + it('should not start a server if supported events are specified', () => { + serverless.service.functions = { + 'SomeFunction': { + handler: 'lambda-handler.none', + events: [ 'blub' ] + } + } + alexaDevServer = new AlexaDevServer(serverless, { port: 5008 }) + alexaDevServer.hooks['local-dev-server:start']() + // Expect rejection of request as no server is running on port 5008 + return expect(sendAlexaRequest(5008)).to.be.rejected + }) + + it('should handle failures', () => { + serverless.service.functions = { + 'MyAlexaSkill': { + handler: 'lambda-handler.fail', + events: [ 'alexaSkill' ] + }, + 'MyHttpResource': { + handler: 'lambda-handler.fail', + events: [ { http: 'GET /' } ] + } + } + alexaDevServer = new AlexaDevServer(serverless, { port: 5009 }) + alexaDevServer.hooks['local-dev-server:start']() + return Promise.all([ + sendAlexaRequest(5009).then(result => + expect(result.ok).equal(false) + ), + sendHttpGetRequest(5009, '').then(result => + expect(result.ok).equal(false) + ) + ]) + }) +}) diff --git a/test/lambda-handler.js b/test/lambda-handler.js new file mode 100644 index 0000000..0a1656f --- /dev/null +++ b/test/lambda-handler.js @@ -0,0 +1,57 @@ +'use strict' + +// Invokes the succeed callback +module.exports.succeed = (_, context) => { + context.succeed() +} + +// Invokes the fail callback +module.exports.fail = (_, context) => { + context.fail(new Error('Some reason')) +} + +// Returns process.env +module.exports.mirrorEnv = (request, context) => { + context.succeed(process.env) +} + +// Succeed if request object has correct form +module.exports.alexaSkill = (request, context) => { + if (!request.session) { + context.fail(new Error('session-object not in request JSON')) + } else if (!request.request) { + context.fail(new Error('request-object not in request JSON')) + } else if (request.version !== '1.0') { + context.fail(new Error('version not 1.0')) + } else { + context.succeed() + } +} + +// Succeed if request object has correct form, returning the request object +module.exports.httpGet = (request, context) => { + if (request.httpMethod !== 'GET') { + context.fail(new Error('httpMethod should be GET')) + } else if (request.body.toString() !== '{}') { + context.fail(new Error('body should be empty')) + } else { + context.succeed({ + headers: { 'Content-Type': 'application/json' }, + statusCode: 200, + body: request + }) + } +} + +// Succeed if request object has correct form +module.exports.httpPost = (request, context) => { + if (request.httpMethod !== 'POST') { + context.fail(new Error('httpMethod not POST')) + } else if (request.body.toString() === '{"foo":"bar"}') { + context.fail(new Error('body should not be empty')) + } else { + context.succeed({ + statusCode: 204 + }) + } +}