diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..1dafac39 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + extends: 'standard', + globals: { + test: true, + expect: true, + describe: true, + jest: true + } +}; \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ab40d21d..a96bfc50 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,7 @@ +Please ensure all pull requests are made against the `develop` branch. + *Issue #, if available:* *Description of changes:* - By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 97a8e138..00000000 --- a/.jshintrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "esversion": 6, - "node": true, - "asi": true, - "globals": { - /* jest */ - "test": false, - "expect": false, - "describe": false - } -} diff --git a/.travis.yml b/.travis.yml index f5479049..c82c5f0f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,11 +4,11 @@ node_js: - 4 - 6 - 8 - # - node # Latest; NOTE: latest does not work due to Buffer deprecation + # - node # runs tests against latest version of Node.js for future-proofing before_install: - echo 'Installing example dependencies...' - - cd example && npm install && cd .. + - npm run install-example-dependencies - echo 'Example dependencies installed!' jobs: @@ -19,6 +19,7 @@ jobs: - stage: lint script: - commitlint-travis + - npm run lint - stage: deploy script: skip node_js: 8 # semantic-release requires Node >= 8.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7fde120..d0ffee79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,22 @@ # Contributing Guidelines -Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional -documentation, we greatly value feedback and contributions from our community. - -Please read through this document before submitting any issues or pull requests to ensure we have all the necessary -information to effectively respond to your bug report or contribution. +Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community. +Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution. ## Reporting Bugs/Feature Requests We welcome you to use the GitHub issue tracker to report bugs or suggest features. -When filing an issue, please check [existing open](https://github.com/awslabs/aws-serverless-express/issues), or [recently closed](https://github.com/awslabs/aws-serverless-express/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already -reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: +When filing an issue, please check [existing open](https://github.com/awslabs/aws-serverless-express/issues), or [recently closed](https://github.com/awslabs/aws-serverless-express/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: * A reproducible test case or series of steps * The version of our code being used * Any modifications you've made relevant to the bug * Anything unusual about your environment or deployment - ## Contributing via Pull Requests + Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 1. You are working against the latest source on the *master* branch. @@ -36,23 +32,19 @@ To send us a pull request, please: 5. Send us a pull request, answering any default questions in the pull request interface. 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. -GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and -[creating a pull request](https://help.github.com/articles/creating-a-pull-request/). - +GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). ## Finding contributions to work on -Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/${GITHUB_ORG}/${GITHUB_REPO}/labels/help%20wanted) issues is a great place to start. +Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/${GITHUB_ORG}/${GITHUB_REPO}/labels/help%20wanted) issues is a great place to start. ## Code of Conduct -This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). -For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact -opensource-codeofconduct@amazon.com with any additional questions or comments. +This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact opensource-codeofconduct@amazon.com with any additional questions or comments. ## Security issue notifications -If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. +If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. ## Licensing diff --git a/README.md b/README.md index 08afc080..db01cf66 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ exports.handler = (event, context) => awsServerlessExpress.proxy(server, event, ## Quick Start/Example -Want to get up and running quickly? [Check out our example](example) which includes: +Want to get up and running quickly? [Check out our basic starter example](examples/basic-starter) which includes: - Lambda function - Express server diff --git a/__tests__/index.js b/__tests__/index.js deleted file mode 100644 index 60805700..00000000 --- a/__tests__/index.js +++ /dev/null @@ -1,241 +0,0 @@ -'use strict' -const awsServerlessExpress = require('../index') - -test('getPathWithQueryStringParams: no params', () => { - const event = { - path: '/foo/bar' - } - const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) - expect(pathWithQueryStringParams).toEqual('/foo/bar') -}) - -test('getPathWithQueryStringParams: 1 param', () => { - const event = { - path: '/foo/bar', - queryStringParameters: { - 'bizz': 'bazz' - } - } - const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) - expect(pathWithQueryStringParams).toEqual('/foo/bar?bizz=bazz') -}) - -test('getPathWithQueryStringParams: to be url-encoded param', () => { - const event = { - path: '/foo/bar', - queryStringParameters: { - 'redirect_uri': 'http://lvh.me:3000/cb' - } - } - const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) - expect(pathWithQueryStringParams).toEqual('/foo/bar?redirect_uri=http%3A%2F%2Flvh.me%3A3000%2Fcb') -}) - -test('getPathWithQueryStringParams: 2 params', () => { - const event = { - path: '/foo/bar', - queryStringParameters: { - 'bizz': 'bazz', - 'buzz': 'bozz' - } - } - const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) - expect(pathWithQueryStringParams).toEqual('/foo/bar?bizz=bazz&buzz=bozz') -}) - -function mapApiGatewayEventToHttpRequest(headers) { - const event = { - path: '/foo', - httpMethod: 'GET', - body: 'Hello serverless!', - headers - } - const eventClone = JSON.parse(JSON.stringify(event)) - delete eventClone.body - const context = { - 'foo': 'bar' - } - const socketPath = '/tmp/server0.sock' - const httpRequest = awsServerlessExpress.mapApiGatewayEventToHttpRequest(event, context, socketPath) - - return {httpRequest, eventClone, context} -} - -test('mapApiGatewayEventToHttpRequest: with headers', () => { - const r = mapApiGatewayEventToHttpRequest({'x-foo': 'foo'}) - - expect(r.httpRequest).toEqual({ - method: 'GET', - path: '/foo', - headers: { - 'x-foo': 'foo', - 'x-apigateway-event': encodeURIComponent(JSON.stringify(r.eventClone)), - 'x-apigateway-context': encodeURIComponent(JSON.stringify(r.context)) - }, - socketPath: '/tmp/server0.sock' - }) -}) - -test('mapApiGatewayEventToHttpRequest: without headers', () => { - const r = mapApiGatewayEventToHttpRequest() - - expect(r.httpRequest).toEqual({ - method: 'GET', - path: '/foo', - headers: { - 'x-apigateway-event': encodeURIComponent(JSON.stringify(r.eventClone)), - 'x-apigateway-context': encodeURIComponent(JSON.stringify(r.context)) - }, - socketPath: '/tmp/server0.sock' - }) -}) - -test('getSocketPath', () => { - const socketPath = awsServerlessExpress.getSocketPath('12345abcdef') - expect(socketPath).toEqual('/tmp/server-12345abcdef.sock') -}) - -const PassThrough = require('stream').PassThrough - -class MockResponse extends PassThrough { - constructor(statusCode, headers, body) { - super() - this.statusCode = statusCode - this.headers = headers || {} - this.write(body) - this.end() - } -} - -class MockServer { - constructor(binaryTypes) { - this._binaryTypes = binaryTypes || [] - } -} - -class MockContext { - constructor(resolve) { - this.resolve = resolve - } - succeed(successResponse) { - this.resolve(successResponse) - } -} - -describe('forwardResponseToApiGateway: header handling', () => { - test('multiple headers with the same name get transformed', () => { - const server = new MockServer() - const headers = {'foo': ['bar', 'baz'], 'Set-Cookie': ['bar', 'baz']} - const body = 'hello world' - const response = new MockResponse(200, headers, body) - return new Promise( - (resolve, reject) => { - const context = new MockContext(resolve) - awsServerlessExpress.forwardResponseToApiGateway( - server, response, context) - } - ).then(successResponse => expect(successResponse).toEqual({ - statusCode: 200, - body: body, - headers: { foo: 'bar,baz', 'SEt-Cookie': 'baz', 'set-Cookie': 'bar' }, - isBase64Encoded: false - })) - }) -}) - -describe('forwardResponseToApiGateway: content-type encoding', () => { - test('content-type header missing', () => { - const server = new MockServer() - const headers = {'foo': 'bar'} - const body = 'hello world' - const response = new MockResponse(200, headers, body) - return new Promise( - (resolve, reject) => { - const context = new MockContext(resolve) - awsServerlessExpress.forwardResponseToApiGateway( - server, response, context) - } - ).then(successResponse => expect(successResponse).toEqual({ - statusCode: 200, - body: body, - headers: headers, - isBase64Encoded: false - })) - }) - - test('content-type image/jpeg base64 encoded', () => { - const server = new MockServer(['image/jpeg']) - const headers = {'content-type': 'image/jpeg'} - const body = 'hello world' - const response = new MockResponse(200, headers, body) - return new Promise( - (resolve, reject) => { - const context = new MockContext(resolve) - awsServerlessExpress.forwardResponseToApiGateway( - server, response, context) - } - ).then(successResponse => expect(successResponse).toEqual({ - statusCode: 200, - body: new Buffer(body).toString('base64'), - headers: headers, - isBase64Encoded: true - })) - }) - - test('content-type application/json', () => { - const server = new MockServer() - const headers = {'content-type': 'application/json'} - const body = JSON.stringify({'hello': 'world'}) - const response = new MockResponse(200, headers, body) - return new Promise( - (resolve, reject) => { - const context = new MockContext(resolve) - awsServerlessExpress.forwardResponseToApiGateway( - server, response, context) - } - ).then(successResponse => expect(successResponse).toEqual({ - statusCode: 200, - body: body, - headers: headers, - isBase64Encoded: false - })) - }) - - test('wildcards in binary types array', () => { - const server = new MockServer(['image/*']) - const headers = {'content-type': 'image/jpeg'} - const body = 'hello world' - const response = new MockResponse(200, headers, body) - return new Promise( - (resolve, reject) => { - const context = new MockContext(resolve) - awsServerlessExpress.forwardResponseToApiGateway( - server, response, context) - } - ).then(successResponse => expect(successResponse).toEqual({ - statusCode: 200, - body: new Buffer(body).toString('base64'), - headers: headers, - isBase64Encoded: true - })) - }) - - test('extensions in binary types array', () => { - const server = new MockServer(['.png']) - const headers = {'content-type': 'image/png'} - const body = 'hello world' - const response = new MockResponse(200, headers, body) - return new Promise( - (resolve, reject) => { - const context = new MockContext(resolve) - awsServerlessExpress.forwardResponseToApiGateway( - server, response, context) - } - ).then(successResponse => expect(successResponse).toEqual({ - statusCode: 200, - body: new Buffer(body).toString('base64'), - headers: headers, - isBase64Encoded: true - })) - }) -}) diff --git a/__tests__/integration.js b/__tests__/integration.js index b6e515b2..21390b2e 100644 --- a/__tests__/integration.js +++ b/__tests__/integration.js @@ -1,19 +1,19 @@ const path = require('path') const fs = require('fs') -const awsServerlessExpress = require('../index') -const apiGatewayEvent = require('../example/api-gateway-event.json') -const app = require('../example/app') +const awsServerlessExpress = require('../src/index') +const apiGatewayEvent = require('../examples/basic-starter/api-gateway-event.json') +const app = require('../examples/basic-starter/app') const server = awsServerlessExpress.createServer(app) const lambdaFunction = { handler: (event, context) => awsServerlessExpress.proxy(server, event, context) } -function clone(json) { +function clone (json) { return JSON.parse(JSON.stringify(json)) } -function makeEvent(eventOverrides) { +function makeEvent (eventOverrides) { const baseEvent = clone(apiGatewayEvent) const headers = Object.assign({}, baseEvent.headers, eventOverrides.headers) const root = Object.assign({}, baseEvent, eventOverrides) @@ -21,27 +21,27 @@ function makeEvent(eventOverrides) { return root } -function expectedRootResponse() { +function expectedRootResponse () { return makeResponse({ - "headers": { - "content-length": "3747", - "content-type": "text/html; charset=utf-8", - "etag": "W/\"ea3-WawLnWdlaCO/ODv9DBVcX0ZTchw\"" + 'headers': { + 'content-length': '3747', + 'content-type': 'text/html; charset=utf-8', + 'etag': 'W/"ea3-WawLnWdlaCO/ODv9DBVcX0ZTchw"' } }) } -function makeResponse(response) { +function makeResponse (response) { const baseResponse = { - "body": "", - "isBase64Encoded": false, - "statusCode": 200 + 'body': '', + 'isBase64Encoded': false, + 'statusCode': 200 } const baseHeaders = { - "access-control-allow-origin": "*", - "connection": "close", - "content-type": "application/json; charset=utf-8", - "x-powered-by": "Express" + 'access-control-allow-origin': '*', + 'connection': 'close', + 'content-type': 'application/json; charset=utf-8', + 'x-powered-by': 'Express' } const headers = Object.assign({}, baseHeaders, response.headers) const finalResponse = Object.assign({}, baseResponse, response) @@ -67,13 +67,13 @@ describe('integration tests', () => { test('GET HTML (subsequent request)', (done) => { const succeed = response => { - delete response.headers.date - expect(response.body.startsWith('')).toBe(true) - const expectedResponse = expectedRootResponse() - delete response.body - delete expectedResponse.body - expect(response).toEqual(expectedResponse) - done() + delete response.headers.date + expect(response.body.startsWith('')).toBe(true) + const expectedResponse = expectedRootResponse() + delete response.body + delete expectedResponse.body + expect(response).toEqual(expectedResponse) + done() } lambdaFunction.handler(makeEvent({ path: '/', @@ -87,10 +87,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": '[{"id":1,"name":"Joe"},{"id":2,"name":"Jane"}]', - "headers": { - "content-length": "46", - "etag": "W/\"2e-Lu6qxFOQSPFulDAGUFiiK6QgREo\"", + 'body': '[{"id":1,"name":"Joe"},{"id":2,"name":"Jane"}]', + 'headers': { + 'content-length': '46', + 'etag': 'W/"2e-Lu6qxFOQSPFulDAGUFiiK6QgREo"' } })) done() @@ -108,11 +108,11 @@ describe('integration tests', () => { delete response.headers.date expect(response.body.startsWith('')).toBe(true) const expectedResponse = makeResponse({ - "headers": { - "content-length": "151", - "content-security-policy": "default-src \'self\'", - "content-type": "text/html; charset=utf-8", - "x-content-type-options": "nosniff", + 'headers': { + 'content-length': '151', + 'content-security-policy': "default-src 'self'", + 'content-type': 'text/html; charset=utf-8', + 'x-content-type-options': 'nosniff' }, statusCode: 404 }) @@ -133,10 +133,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": '{"id":1,"name":"Joe"}', - "headers": { - "content-length": "21", - "etag": "W/\"15-rRboW+j/yFKqYqV6yklp53+fANQ\"", + 'body': '{"id":1,"name":"Joe"}', + 'headers': { + 'content-length': '21', + 'etag': 'W/"15-rRboW+j/yFKqYqV6yklp53+fANQ"' } })) done() @@ -153,10 +153,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": "{}", - "headers": { - "content-length": "2", - "etag": "W/\"2-vyGp6PvFo4RvsFtPoIWeCReyIC8\"", + 'body': '{}', + 'headers': { + 'content-length': '2', + 'etag': 'W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"' }, statusCode: 404 })) @@ -176,41 +176,41 @@ describe('integration tests', () => { delete response.headers.etag delete response.headers['last-modified'] - const samLogoPath = path.resolve(path.join(__dirname, '../example/sam-logo.png')) + const samLogoPath = path.resolve(path.join(__dirname, '../examples/basic-starter/sam-logo.png')) const samLogoImage = fs.readFileSync(samLogoPath) - const samLogoBase64 = new Buffer(samLogoImage).toString('base64') + const samLogoBase64 = Buffer.from(samLogoImage).toString('base64') expect(response).toEqual(makeResponse({ - "body": samLogoBase64, - "headers": { - "accept-ranges": "bytes", - "cache-control": "public, max-age=0", - "content-length": "15933", - "content-type": "image/png" + 'body': samLogoBase64, + 'headers': { + 'accept-ranges': 'bytes', + 'cache-control': 'public, max-age=0', + 'content-length': '15933', + 'content-type': 'image/png' }, - "isBase64Encoded": true + 'isBase64Encoded': true })) serverWithBinaryTypes.close() done() } const serverWithBinaryTypes = awsServerlessExpress.createServer(app, null, ['image/*']) awsServerlessExpress.proxy(serverWithBinaryTypes, makeEvent({ - path: '/sam', - httpMethod: 'GET' - }), { + path: '/sam', + httpMethod: 'GET' + }), { succeed }) }) const newName = 'Sandy Samantha Salamander' - + test('POST JSON', (done) => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": `{"id":3,"name":"${newName}"}`, - "headers": { - "content-length": "43", - "etag": "W/\"2b-ksYHypm1DmDdjEzhtyiv73Bluqk\"", + 'body': `{"id":3,"name":"${newName}"}`, + 'headers': { + 'content-length': '43', + 'etag': 'W/"2b-ksYHypm1DmDdjEzhtyiv73Bluqk"' }, statusCode: 201 })) @@ -229,10 +229,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": `{"id":3,"name":"${newName}"}`, - "headers": { - "content-length": "43", - "etag": "W/\"2b-ksYHypm1DmDdjEzhtyiv73Bluqk\"", + 'body': `{"id":3,"name":"${newName}"}`, + 'headers': { + 'content-length': '43', + 'etag': 'W/"2b-ksYHypm1DmDdjEzhtyiv73Bluqk"' }, statusCode: 200 })) @@ -250,10 +250,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": `[{"id":2,"name":"Jane"},{"id":3,"name":"${newName}"}]`, - "headers": { - "content-length": "68", - "etag": "W/\"44-AtuxlvrIBL8NXP4gvEQTI77suNg\"", + 'body': `[{"id":2,"name":"Jane"},{"id":3,"name":"${newName}"}]`, + 'headers': { + 'content-length': '68', + 'etag': 'W/"44-AtuxlvrIBL8NXP4gvEQTI77suNg"' }, statusCode: 200 })) @@ -271,10 +271,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": '{"id":2,"name":"Samuel"}', - "headers": { - "content-length": "24", - "etag": "W/\"18-uGyzhJdtXqacOe9WRxtXSNjIk5Q\"", + 'body': '{"id":2,"name":"Samuel"}', + 'headers': { + 'content-length': '24', + 'etag': 'W/"18-uGyzhJdtXqacOe9WRxtXSNjIk5Q"' }, statusCode: 200 })) @@ -293,10 +293,10 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual(makeResponse({ - "body": '{"id":2,"name":"Samuel"}', - "headers": { - "content-length": "24", - "etag": "W/\"18-uGyzhJdtXqacOe9WRxtXSNjIk5Q\"", + 'body': '{"id":2,"name":"Samuel"}', + 'headers': { + 'content-length': '24', + 'etag': 'W/"18-uGyzhJdtXqacOe9WRxtXSNjIk5Q"' }, statusCode: 200 })) @@ -305,7 +305,7 @@ describe('integration tests', () => { lambdaFunction.handler(makeEvent({ path: '/users/2', httpMethod: 'PUT', - body: btoa('{"name": "Samuel"}'), + body: global.btoa('{"name": "Samuel"}'), isBase64Encoded: true }), { succeed @@ -316,8 +316,8 @@ describe('integration tests', () => { const succeed = response => { delete response.headers.date expect(response).toEqual({ - "body": "", - "headers": {}, + 'body': '', + 'headers': {}, statusCode: 502 }) done() @@ -325,7 +325,7 @@ describe('integration tests', () => { lambdaFunction.handler(makeEvent({ path: '/', httpMethod: 'GET', - body: "{\"name\": \"Sam502\"}", + body: '{"name": "Sam502"}', headers: { 'Content-Length': '-1' } @@ -333,7 +333,7 @@ describe('integration tests', () => { succeed }) }) - + const mockApp = function (req, res) { res.end('') } @@ -394,4 +394,4 @@ describe('integration tests', () => { succeed }) }) -}) \ No newline at end of file +}) diff --git a/__tests__/middleware.js b/__tests__/middleware.js index e26285df..7a21bb11 100644 --- a/__tests__/middleware.js +++ b/__tests__/middleware.js @@ -1,76 +1,73 @@ 'use strict' -const awsServerlessExpressMiddleware = require('../middleware') +const awsServerlessExpressMiddleware = require('../src/middleware') const eventContextMiddleware = awsServerlessExpressMiddleware.eventContext const mockNext = () => true const generateMockReq = () => { - return { - headers: { - 'x-apigateway-event': encodeURIComponent(JSON.stringify({ - path: '/foo/bar', - queryStringParameters: { - foo: '🖖', - bar: '~!@#$%^&*()_+`-=;\':",./<>?`' - } - })), - 'x-apigateway-context': encodeURIComponent(JSON.stringify({foo: 'bar'})) + return { + headers: { + 'x-apigateway-event': encodeURIComponent(JSON.stringify({ + path: '/foo/bar', + queryStringParameters: { + foo: '🖖', + bar: '~!@#$%^&*()_+`-=;\':",./<>?`' } + })), + 'x-apigateway-context': encodeURIComponent(JSON.stringify({foo: 'bar'})) } + } } const mockRes = {} test('defaults', () => { - const req = generateMockReq() - const originalHeaders = Object.assign({}, req.headers) + const req = generateMockReq() + const originalHeaders = Object.assign({}, req.headers) - eventContextMiddleware()(req, mockRes, mockNext) + eventContextMiddleware()(req, mockRes, mockNext) - expect(req.apiGateway.event).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-event']))) - expect(req.apiGateway.context).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-context']))) - expect(req.headers['x-apigateway-event']).toBe(undefined) - expect(req.headers['x-apigateway-context']).toBe(undefined) + expect(req.apiGateway.event).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-event']))) + expect(req.apiGateway.context).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-context']))) + expect(req.headers['x-apigateway-event']).toBe(undefined) + expect(req.headers['x-apigateway-context']).toBe(undefined) }) test('options.reqPropKey', () => { - const req = generateMockReq() - const originalHeaders = Object.assign({}, req.headers) + const req = generateMockReq() + const originalHeaders = Object.assign({}, req.headers) - eventContextMiddleware({ reqPropKey: '_apiGateway'})(req, mockRes, mockNext) + eventContextMiddleware({ reqPropKey: '_apiGateway' })(req, mockRes, mockNext) - expect(req._apiGateway.event).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-event']))) - expect(req._apiGateway.context).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-context']))) - expect(req.headers['x-apigateway-event']).toBe(undefined) - expect(req.headers['x-apigateway-context']).toBe(undefined) + expect(req._apiGateway.event).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-event']))) + expect(req._apiGateway.context).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-context']))) + expect(req.headers['x-apigateway-event']).toBe(undefined) + expect(req.headers['x-apigateway-context']).toBe(undefined) }) - test('options.deleteHeaders = false', () => { - const req = generateMockReq() - const originalHeaders = Object.assign({}, req.headers) + const req = generateMockReq() + const originalHeaders = Object.assign({}, req.headers) - eventContextMiddleware({ deleteHeaders: false})(req, mockRes, mockNext) + eventContextMiddleware({ deleteHeaders: false })(req, mockRes, mockNext) - expect(req.apiGateway.event).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-event']))) - expect(req.apiGateway.context).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-context']))) - expect(req.headers['x-apigateway-event']).toEqual(originalHeaders['x-apigateway-event']) - expect(req.headers['x-apigateway-context']).toEqual(originalHeaders['x-apigateway-context']) + expect(req.apiGateway.event).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-event']))) + expect(req.apiGateway.context).toEqual(JSON.parse(decodeURIComponent(originalHeaders['x-apigateway-context']))) + expect(req.headers['x-apigateway-event']).toEqual(originalHeaders['x-apigateway-event']) + expect(req.headers['x-apigateway-context']).toEqual(originalHeaders['x-apigateway-context']) }) test('Missing x-apigateway-event', () => { - const req = generateMockReq() - delete req.headers['x-apigateway-event'] - const originalHeaders = Object.assign({}, req.headers) + const req = generateMockReq() + delete req.headers['x-apigateway-event'] - eventContextMiddleware({ deleteHeaders: false})(req, mockRes, mockNext) + eventContextMiddleware({ deleteHeaders: false })(req, mockRes, mockNext) - expect(req.apiGateway).toBe(undefined) + expect(req.apiGateway).toBe(undefined) }) test('Missing x-apigateway-context', () => { - const req = generateMockReq() - delete req.headers['x-apigateway-context'] - const originalHeaders = Object.assign({}, req.headers) + const req = generateMockReq() + delete req.headers['x-apigateway-context'] - eventContextMiddleware({ deleteHeaders: false})(req, mockRes, mockNext) + eventContextMiddleware({ deleteHeaders: false })(req, mockRes, mockNext) - expect(req.apiGateway).toBe(undefined) + expect(req.apiGateway).toBe(undefined) }) diff --git a/__tests__/unit.js b/__tests__/unit.js new file mode 100644 index 00000000..d5e960af --- /dev/null +++ b/__tests__/unit.js @@ -0,0 +1,241 @@ +'use strict' +const awsServerlessExpress = require('../src/index') + +test('getPathWithQueryStringParams: no params', () => { + const event = { + path: '/foo/bar' + } + const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) + expect(pathWithQueryStringParams).toEqual('/foo/bar') +}) + +test('getPathWithQueryStringParams: 1 param', () => { + const event = { + path: '/foo/bar', + queryStringParameters: { + 'bizz': 'bazz' + } + } + const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) + expect(pathWithQueryStringParams).toEqual('/foo/bar?bizz=bazz') +}) + +test('getPathWithQueryStringParams: to be url-encoded param', () => { + const event = { + path: '/foo/bar', + queryStringParameters: { + 'redirect_uri': 'http://lvh.me:3000/cb' + } + } + const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) + expect(pathWithQueryStringParams).toEqual('/foo/bar?redirect_uri=http%3A%2F%2Flvh.me%3A3000%2Fcb') +}) + +test('getPathWithQueryStringParams: 2 params', () => { + const event = { + path: '/foo/bar', + queryStringParameters: { + 'bizz': 'bazz', + 'buzz': 'bozz' + } + } + const pathWithQueryStringParams = awsServerlessExpress.getPathWithQueryStringParams(event) + expect(pathWithQueryStringParams).toEqual('/foo/bar?bizz=bazz&buzz=bozz') +}) + +function mapApiGatewayEventToHttpRequest (headers) { + const event = { + path: '/foo', + httpMethod: 'GET', + body: 'Hello serverless!', + headers + } + const eventClone = JSON.parse(JSON.stringify(event)) + delete eventClone.body + const context = { + 'foo': 'bar' + } + const socketPath = '/tmp/server0.sock' + const httpRequest = awsServerlessExpress.mapApiGatewayEventToHttpRequest(event, context, socketPath) + + return {httpRequest, eventClone, context} +} + +test('mapApiGatewayEventToHttpRequest: with headers', () => { + const r = mapApiGatewayEventToHttpRequest({'x-foo': 'foo'}) + + expect(r.httpRequest).toEqual({ + method: 'GET', + path: '/foo', + headers: { + 'x-foo': 'foo', + 'x-apigateway-event': encodeURIComponent(JSON.stringify(r.eventClone)), + 'x-apigateway-context': encodeURIComponent(JSON.stringify(r.context)) + }, + socketPath: '/tmp/server0.sock' + }) +}) + +test('mapApiGatewayEventToHttpRequest: without headers', () => { + const r = mapApiGatewayEventToHttpRequest() + + expect(r.httpRequest).toEqual({ + method: 'GET', + path: '/foo', + headers: { + 'x-apigateway-event': encodeURIComponent(JSON.stringify(r.eventClone)), + 'x-apigateway-context': encodeURIComponent(JSON.stringify(r.context)) + }, + socketPath: '/tmp/server0.sock' + }) +}) + +test('getSocketPath', () => { + const socketPath = awsServerlessExpress.getSocketPath('12345abcdef') + expect(socketPath).toEqual('/tmp/server-12345abcdef.sock') +}) + +const PassThrough = require('stream').PassThrough + +class MockResponse extends PassThrough { + constructor (statusCode, headers, body) { + super() + this.statusCode = statusCode + this.headers = headers || {} + this.write(body) + this.end() + } +} + +class MockServer { + constructor (binaryTypes) { + this._binaryTypes = binaryTypes || [] + } +} + +class MockContext { + constructor (resolve) { + this.resolve = resolve + } + succeed (successResponse) { + this.resolve(successResponse) + } +} + +describe('forwardResponseToApiGateway: header handling', () => { + test('multiple headers with the same name get transformed', () => { + const server = new MockServer() + const headers = {'foo': ['bar', 'baz'], 'Set-Cookie': ['bar', 'baz']} + const body = 'hello world' + const response = new MockResponse(200, headers, body) + return new Promise( + (resolve, reject) => { + const context = new MockContext(resolve) + awsServerlessExpress.forwardResponseToApiGateway( + server, response, context) + } + ).then(successResponse => expect(successResponse).toEqual({ + statusCode: 200, + body: body, + headers: { foo: 'bar,baz', 'SEt-Cookie': 'baz', 'set-Cookie': 'bar' }, + isBase64Encoded: false + })) + }) +}) + +describe('forwardResponseToApiGateway: content-type encoding', () => { + test('content-type header missing', () => { + const server = new MockServer() + const headers = {'foo': 'bar'} + const body = 'hello world' + const response = new MockResponse(200, headers, body) + return new Promise( + (resolve, reject) => { + const context = new MockContext(resolve) + awsServerlessExpress.forwardResponseToApiGateway( + server, response, context) + } + ).then(successResponse => expect(successResponse).toEqual({ + statusCode: 200, + body: body, + headers: headers, + isBase64Encoded: false + })) + }) + + test('content-type image/jpeg base64 encoded', () => { + const server = new MockServer(['image/jpeg']) + const headers = {'content-type': 'image/jpeg'} + const body = 'hello world' + const response = new MockResponse(200, headers, body) + return new Promise( + (resolve, reject) => { + const context = new MockContext(resolve) + awsServerlessExpress.forwardResponseToApiGateway( + server, response, context) + } + ).then(successResponse => expect(successResponse).toEqual({ + statusCode: 200, + body: Buffer.from(body).toString('base64'), + headers: headers, + isBase64Encoded: true + })) + }) + + test('content-type application/json', () => { + const server = new MockServer() + const headers = {'content-type': 'application/json'} + const body = JSON.stringify({'hello': 'world'}) + const response = new MockResponse(200, headers, body) + return new Promise( + (resolve, reject) => { + const context = new MockContext(resolve) + awsServerlessExpress.forwardResponseToApiGateway( + server, response, context) + } + ).then(successResponse => expect(successResponse).toEqual({ + statusCode: 200, + body: body, + headers: headers, + isBase64Encoded: false + })) + }) + + test('wildcards in binary types array', () => { + const server = new MockServer(['image/*']) + const headers = {'content-type': 'image/jpeg'} + const body = 'hello world' + const response = new MockResponse(200, headers, body) + return new Promise( + (resolve, reject) => { + const context = new MockContext(resolve) + awsServerlessExpress.forwardResponseToApiGateway( + server, response, context) + } + ).then(successResponse => expect(successResponse).toEqual({ + statusCode: 200, + body: Buffer.from(body).toString('base64'), + headers: headers, + isBase64Encoded: true + })) + }) + + test('extensions in binary types array', () => { + const server = new MockServer(['.png']) + const headers = {'content-type': 'image/png'} + const body = 'hello world' + const response = new MockResponse(200, headers, body) + return new Promise( + (resolve, reject) => { + const context = new MockContext(resolve) + awsServerlessExpress.forwardResponseToApiGateway( + server, response, context) + } + ).then(successResponse => expect(successResponse).toEqual({ + statusCode: 200, + body: Buffer.from(body).toString('base64'), + headers: headers, + isBase64Encoded: true + })) + }) +}) diff --git a/example/scripts/configure.js b/example/scripts/configure.js deleted file mode 100644 index 096457c3..00000000 --- a/example/scripts/configure.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -'use strict' - -const fs = require('fs') -const exec = require('child_process').execSync -const modifyFiles = require('./utils').modifyFiles - -let minimistHasBeenInstalled = false - -if (!fs.existsSync('./node_modules/minimist')) { - exec('npm install minimist --silent') - minimistHasBeenInstalled = true -} - -const args = require('minimist')(process.argv.slice(2), { - string: [ - 'account-id', - 'bucket-name', - 'function-name', - 'region' - ], - default: { - region: 'us-east-1', - 'function-name': 'AwsServerlessExpressFunction' - } -}) - -if (minimistHasBeenInstalled) { - exec('npm uninstall minimist --silent') -} - -const accountId = args['account-id'] -const bucketName = args['bucket-name'] -const functionName = args['function-name'] -const region = args.region -const availableRegions = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'eu-west-1', 'eu-west-2', 'eu-central-1', 'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ca-central-1'] - -if (!accountId || accountId.length !== 12) { - console.error('You must supply a 12 digit account id as --account-id=""') - return -} - -if (!bucketName) { - console.error('You must supply a bucket name as --bucket-name=""') - return -} - -if (availableRegions.indexOf(region) === -1) { - console.error(`Amazon API Gateway and Lambda are not available in the ${region} region. Available regions: us-east-1, us-west-2, eu-west-1, eu-central-1, ap-northeast-1, ap-northeast-2, ap-southeast-1, ap-southeast-2, ca-central-1`) - return -} - -modifyFiles(['./simple-proxy-api.yaml', './package.json', './cloudformation.yaml'], [{ - regexp: /YOUR_ACCOUNT_ID/g, - replacement: accountId -}, { - regexp: /YOUR_AWS_REGION/g, - replacement: region -}, { - regexp: /YOUR_UNIQUE_BUCKET_NAME/g, - replacement: bucketName -}, { - regexp: /YOUR_SERVERLESS_EXPRESS_LAMBDA_FUNCTION_NAME/g, - replacement: functionName -}]) diff --git a/example/scripts/deconfigure.js b/example/scripts/deconfigure.js deleted file mode 100644 index 969eff68..00000000 --- a/example/scripts/deconfigure.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -'use strict' - -const modifyFiles = require('./utils').modifyFiles -const packageJson = require('../package.json') -const config = packageJson.config - -modifyFiles(['./simple-proxy-api.yaml', './package.json', './cloudformation.yaml'], [{ - regexp: new RegExp(config.accountId, 'g'), - replacement: 'YOUR_ACCOUNT_ID' -}, { - regexp: new RegExp(config.region, 'g'), - replacement: 'YOUR_AWS_REGION' -}, { - regexp: new RegExp(config.s3BucketName, 'g'), - replacement: 'YOUR_UNIQUE_BUCKET_NAME' -}, { - regexp: new RegExp(config.functionName, 'g'), - replacement: 'YOUR_SERVERLESS_EXPRESS_LAMBDA_FUNCTION_NAME' -}]) diff --git a/example/vanilla-server.js b/example/vanilla-server.js deleted file mode 100644 index 3f6e8aaf..00000000 --- a/example/vanilla-server.js +++ /dev/null @@ -1,21 +0,0 @@ -const http = require('http') -const url = require('url') - -const app = function(req, res) { - const parsedUrl = url.parse(req.url, true) - - res.writeHead(200, { - 'Content-Type': 'text/plain charset=UTF-8' - }) - - switch (parsedUrl.pathname) { - case '/users': - return res.end('List of users') - default: - return res.end('No path match. Try /users') - } -} - -// http.createServer(app).listen(3000) - -module.exports = app diff --git a/example/.gitignore b/examples/basic-starter/.gitignore similarity index 100% rename from example/.gitignore rename to examples/basic-starter/.gitignore diff --git a/example/README.md b/examples/basic-starter/README.md similarity index 92% rename from example/README.md rename to examples/basic-starter/README.md index 896e0e9a..3ac33799 100644 --- a/example/README.md +++ b/examples/basic-starter/README.md @@ -5,7 +5,7 @@ In addition to a basic Lambda function and Express server, the `example` directo ### Steps for running the example This guide assumes you have already [set up an AWS account](http://docs.aws.amazon.com/AmazonSimpleDB/latest/DeveloperGuide/AboutAWSAccounts.html) and have the latest version of the [AWS CLI](https://aws.amazon.com/cli/) installed. -1. From your preferred project directory: `git clone https://github.com/awslabs/aws-serverless-express.git && cd aws-serverless-express/example`. +1. From your preferred project directory: `git clone https://github.com/awslabs/aws-serverless-express.git && cd aws-serverless-express/examples/basic-starter`. 2. Run `npm run config -- --account-id="" --bucket-name="" [--region="" --function-name=""]` to configure the example, eg. `npm run config -- --account-id="123456789012" --bucket-name="my-unique-bucket"`. This modifies `package.json`, `simple-proxy-api.yaml` and `cloudformation.yaml` with your account ID, bucket, region and function name (region defaults to `us-east-1` and function name defaults to `AwsServerlessExpressFunction`). If the bucket you specify does not yet exist, the next step will create it for you. This step modifies the existing files in-place; if you wish to make changes to these settings, you will need to modify `package.json`, `simple-proxy-api.yaml` and `cloudformation.yaml` manually. 3. Run `npm run setup` (Windows users: `npm run win-setup`) - this installs the node dependencies, creates an S3 bucket (if it does not already exist), packages and deploys your serverless Express application to AWS Lambda, and creates an API Gateway proxy API. 4. After the setup command completes, open the AWS CloudFormation console https://console.aws.amazon.com/cloudformation/home and switch to the region you specified. Select the `AwsServerlessExpressStack` stack, then click the `ApiUrl` value under the __Outputs__ section - this will open a new page with your running API. The API index lists the resources available in the example Express server (`app.js`), along with example `curl` commands. @@ -16,7 +16,7 @@ See the sections below for details on how to migrate an existing (or create a ne To use this example as a base for a new Node.js project: -1. Copy the files in the `example` directory into a new project directory (`cp -r ./example ~/projects/my-new-node-project`). If you have not already done so, follow the [steps for running the example](#steps-for-running-the-example) (you may want to first modify some of the resource names to something more project-specific, eg. the CloudFormation stack, Lambda function, and API Gateway API). +1. Copy the files in the `examples/basic-starter` directory into a new project directory (`cp -r ./examples/basic-starter ~/projects/my-new-node-project`). If you have not already done so, follow the [steps for running the example](#steps-for-running-the-example) (you may want to first modify some of the resource names to something more project-specific, eg. the CloudFormation stack, Lambda function, and API Gateway API). 2. After making updates to `app.js`, simply run `npm run package-deploy` (Windows users: `npm run win-package-deploy`). To migrate an existing Node server: diff --git a/example/api-gateway-event.json b/examples/basic-starter/api-gateway-event.json similarity index 100% rename from example/api-gateway-event.json rename to examples/basic-starter/api-gateway-event.json diff --git a/example/app.js b/examples/basic-starter/app.js similarity index 97% rename from example/app.js rename to examples/basic-starter/app.js index fb92da40..f002329b 100644 --- a/example/app.js +++ b/examples/basic-starter/app.js @@ -69,7 +69,7 @@ router.put('/users/:userId', (req, res) => { router.delete('/users/:userId', (req, res) => { const userIndex = getUserIndex(req.params.userId) - if(userIndex === -1) return res.status(404).json({}) + if (userIndex === -1) return res.status(404).json({}) users.splice(userIndex, 1) res.json(users) diff --git a/example/app.local.js b/examples/basic-starter/app.local.js similarity index 100% rename from example/app.local.js rename to examples/basic-starter/app.local.js diff --git a/example/cloudformation.yaml b/examples/basic-starter/cloudformation.yaml similarity index 100% rename from example/cloudformation.yaml rename to examples/basic-starter/cloudformation.yaml diff --git a/example/lambda.js b/examples/basic-starter/lambda.js similarity index 94% rename from example/lambda.js rename to examples/basic-starter/lambda.js index f63e384a..59476cfd 100644 --- a/example/lambda.js +++ b/examples/basic-starter/lambda.js @@ -1,5 +1,5 @@ 'use strict' -const awsServerlessExpress = require(process.env.NODE_ENV === 'test' ? '../index' : 'aws-serverless-express') +const awsServerlessExpress = require(process.env.NODE_ENV === 'test' ? '../src/index' : 'aws-serverless-express') const app = require('./app') // NOTE: If you get ERR_CONTENT_DECODING_FAILED in your browser, this is likely diff --git a/example/package-lock.json b/examples/basic-starter/package-lock.json similarity index 100% rename from example/package-lock.json rename to examples/basic-starter/package-lock.json diff --git a/example/package.json b/examples/basic-starter/package.json similarity index 100% rename from example/package.json rename to examples/basic-starter/package.json diff --git a/example/sam-logo.png b/examples/basic-starter/sam-logo.png similarity index 100% rename from example/sam-logo.png rename to examples/basic-starter/sam-logo.png diff --git a/examples/basic-starter/scripts/configure.js b/examples/basic-starter/scripts/configure.js new file mode 100644 index 00000000..7486be20 --- /dev/null +++ b/examples/basic-starter/scripts/configure.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +'use strict' + +const fs = require('fs') +const exec = require('child_process').execSync +const modifyFiles = require('./utils').modifyFiles + +let minimistHasBeenInstalled = false + +if (!fs.existsSync('./node_modules/minimist')) { + exec('npm install minimist --silent') + minimistHasBeenInstalled = true +} + +const args = require('minimist')(process.argv.slice(2), { + string: [ + 'account-id', + 'bucket-name', + 'function-name', + 'region' + ], + default: { + region: 'us-east-1', + 'function-name': 'AwsServerlessExpressFunction' + } +}) + +if (minimistHasBeenInstalled) { + exec('npm uninstall minimist --silent') +} + +const accountId = args['account-id'] +const bucketName = args['bucket-name'] +const functionName = args['function-name'] +const region = args.region + +if (!accountId || accountId.length !== 12) { + console.error('You must supply a 12 digit account id as --account-id=""') + process.exit(1) +} + +if (!bucketName) { + console.error('You must supply a bucket name as --bucket-name=""') + process.exit(1) +} + +modifyFiles(['./simple-proxy-api.yaml', './package.json', './cloudformation.yaml'], [{ + regexp: /YOUR_ACCOUNT_ID/g, + replacement: accountId +}, { + regexp: /YOUR_AWS_REGION/g, + replacement: region +}, { + regexp: /YOUR_UNIQUE_BUCKET_NAME/g, + replacement: bucketName +}, { + regexp: /YOUR_SERVERLESS_EXPRESS_LAMBDA_FUNCTION_NAME/g, + replacement: functionName +}]) diff --git a/examples/basic-starter/scripts/deconfigure.js b/examples/basic-starter/scripts/deconfigure.js new file mode 100644 index 00000000..0f2db4c6 --- /dev/null +++ b/examples/basic-starter/scripts/deconfigure.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +'use strict' + +const modifyFiles = require('./utils').modifyFiles +const packageJson = require('../package.json') +const config = packageJson.config + +modifyFiles(['./simple-proxy-api.yaml', './package.json', './cloudformation.yaml'], [{ + regexp: new RegExp(config.accountId, 'g'), + replacement: 'YOUR_ACCOUNT_ID' +}, { + regexp: new RegExp(config.region, 'g'), + replacement: 'YOUR_AWS_REGION' +}, { + regexp: new RegExp(config.s3BucketName, 'g'), + replacement: 'YOUR_UNIQUE_BUCKET_NAME' +}, { + regexp: new RegExp(config.functionName, 'g'), + replacement: 'YOUR_SERVERLESS_EXPRESS_LAMBDA_FUNCTION_NAME' +}]) diff --git a/example/scripts/local.js b/examples/basic-starter/scripts/local.js similarity index 75% rename from example/scripts/local.js rename to examples/basic-starter/scripts/local.js index 4e6068df..fc184c1e 100644 --- a/example/scripts/local.js +++ b/examples/basic-starter/scripts/local.js @@ -13,13 +13,13 @@ const server = lambdaFunction.handler(apiGatewayEvent, { process.stdin.resume() -function exitHandler(options, err) { - if (options.cleanup && server && server.close ) { - server.close() - } +function exitHandler (options, err) { + if (options.cleanup && server && server.close) { + server.close() + } - if (err) console.error(err.stack) - if (options.exit) process.exit() + if (err) console.error(err.stack) + if (options.exit) process.exit() } process.on('exit', exitHandler.bind(null, { cleanup: true })) diff --git a/example/scripts/utils.js b/examples/basic-starter/scripts/utils.js similarity index 81% rename from example/scripts/utils.js rename to examples/basic-starter/scripts/utils.js index 6c5cee55..81b36b80 100644 --- a/example/scripts/utils.js +++ b/examples/basic-starter/scripts/utils.js @@ -3,7 +3,7 @@ const fs = require('fs') -module.exports.modifyFiles = function modifyFiles(files, replacements) { +module.exports.modifyFiles = function modifyFiles (files, replacements) { files.forEach((file) => { let fileContentModified = fs.readFileSync(file, 'utf8') diff --git a/example/simple-proxy-api.yaml b/examples/basic-starter/simple-proxy-api.yaml similarity index 100% rename from example/simple-proxy-api.yaml rename to examples/basic-starter/simple-proxy-api.yaml diff --git a/example/views/index.pug b/examples/basic-starter/views/index.pug similarity index 100% rename from example/views/index.pug rename to examples/basic-starter/views/index.pug diff --git a/examples/vanilla-http/index.js b/examples/vanilla-http/index.js new file mode 100644 index 00000000..c24d791a --- /dev/null +++ b/examples/vanilla-http/index.js @@ -0,0 +1,20 @@ +const url = require('url') + +const app = function (req, res) { + const parsedUrl = url.parse(req.url, true) + + res.writeHead(200, { + 'Content-Type': 'text/plain charset=UTF-8' + }) + + switch (parsedUrl.pathname) { + case '/users': + return res.end('List of users') + default: + return res.end('No path match. Try /users') + } +} + +// require('http').createServer(app).listen(3000) + +module.exports = app diff --git a/index.js b/index.js deleted file mode 100644 index 99ce4398..00000000 --- a/index.js +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright 2016-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0/ - * - * or in the "license" file accompanying this file. - * This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ -'use strict' -const http = require('http') -const url = require('url') -const binarycase = require('binary-case') -const isType = require('type-is') - -function getPathWithQueryStringParams(event) { - return url.format({ pathname: event.path, query: event.queryStringParameters }) -} - -function getContentType(params) { - // only compare mime type; ignore encoding part - return params.contentTypeHeader ? params.contentTypeHeader.split(';')[0] : '' -} - -function isContentTypeBinaryMimeType(params) { - return params.binaryMimeTypes.length > 0 && !!isType.is(params.contentType, params.binaryMimeTypes) -} - -function mapApiGatewayEventToHttpRequest(event, context, socketPath) { - const headers = event.headers || {} // NOTE: Mutating event.headers; prefer deep clone of event.headers - const eventWithoutBody = Object.assign({}, event) - delete eventWithoutBody.body - - headers['x-apigateway-event'] = encodeURIComponent(JSON.stringify(eventWithoutBody)) - headers['x-apigateway-context'] = encodeURIComponent(JSON.stringify(context)) - - return { - method: event.httpMethod, - path: getPathWithQueryStringParams(event), - headers, - socketPath - // protocol: `${headers['X-Forwarded-Proto']}:`, - // host: headers.Host, - // hostname: headers.Host, // Alias for host - // port: headers['X-Forwarded-Port'] - } -} - -function forwardResponseToApiGateway(server, response, context) { - let buf = [] - - response - .on('data', (chunk) => buf.push(chunk)) - .on('end', () => { - const bodyBuffer = Buffer.concat(buf) - const statusCode = response.statusCode - const headers = response.headers - - // chunked transfer not currently supported by API Gateway - /* istanbul ignore else */ - if (headers['transfer-encoding'] === 'chunked') { - delete headers['transfer-encoding'] - } - - // HACK: modifies header casing to get around API Gateway's limitation of not allowing multiple - // headers with the same name, as discussed on the AWS Forum https://forums.aws.amazon.com/message.jspa?messageID=725953#725953 - Object.keys(headers) - .forEach(h => { - if(Array.isArray(headers[h])) { - if (h.toLowerCase() === 'set-cookie') { - headers[h].forEach((value, i) => { - headers[binarycase(h, i + 1)] = value - }) - delete headers[h] - } else { - headers[h] = headers[h].join(',') - } - } - }) - - const contentType = getContentType({ contentTypeHeader: headers['content-type'] }) - const isBase64Encoded = isContentTypeBinaryMimeType({ contentType, binaryMimeTypes: server._binaryTypes }) - const body = bodyBuffer.toString(isBase64Encoded ? 'base64' : 'utf8') - const successResponse = {statusCode, body, headers, isBase64Encoded} - - context.succeed(successResponse) - }) -} - -function forwardConnectionErrorResponseToApiGateway(server, error, context) { - console.log('ERROR: aws-serverless-express connection error') - console.error(error) - const errorResponse = { - statusCode: 502, // "DNS resolution, TCP level errors, or actual HTTP parse errors" - https://nodejs.org/api/http.html#http_http_request_options_callback - body: '', - headers: {} - } - - context.succeed(errorResponse) -} - -function forwardLibraryErrorResponseToApiGateway(server, error, context) { - console.log('ERROR: aws-serverless-express error') - console.error(error) - const errorResponse = { - statusCode: 500, - body: '', - headers: {} - } - - context.succeed(errorResponse) -} - -function forwardRequestToNodeServer(server, event, context) { - try { - const requestOptions = mapApiGatewayEventToHttpRequest(event, context, getSocketPath(server._socketPathSuffix)) - const req = http.request(requestOptions, (response, body) => forwardResponseToApiGateway(server, response, context)) - if (event.body) { - if (event.isBase64Encoded) { - event.body = new Buffer(event.body, 'base64') - } - - req.write(event.body) - } - - req.on('error', (error) => forwardConnectionErrorResponseToApiGateway(server, error, context)) - .end() - } catch (error) { - forwardLibraryErrorResponseToApiGateway(server, error, context) - return server - } -} - -function startServer(server) { - return server.listen(getSocketPath(server._socketPathSuffix)) -} - -function getSocketPath(socketPathSuffix) { - /* istanbul ignore if */ /* only running tests on Linux; Window support is for local dev only */ - if (/^win/.test(process.platform)) { - const path = require('path') - return path.join('\\\\?\\pipe', process.cwd(), `server-${socketPathSuffix}`) - } else { - return `/tmp/server-${socketPathSuffix}.sock` - } -} - -function getRandomString() { - return Math.random().toString(36).substring(2, 15) -} - -function createServer (requestListener, serverListenCallback, binaryTypes) { - const server = http.createServer(requestListener) - - server._socketPathSuffix = getRandomString() - server._binaryTypes = binaryTypes ? binaryTypes.slice() : [] - server.on('listening', () => { - server._isListening = true - - if (serverListenCallback) serverListenCallback() - }) - server.on('close', () => { - server._isListening = false - }) - .on('error', (error) => { - /* istanbul ignore else */ - if (error.code === 'EADDRINUSE') { - console.warn(`WARNING: Attempting to listen on socket ${getSocketPath(server._socketPathSuffix)}, but it is already in use. This is likely as a result of a previous invocation error or timeout. Check the logs for the invocation(s) immediately prior to this for root cause, and consider increasing the timeout and/or cpu/memory allocation if this is purely as a result of a timeout. aws-serverless-express will restart the Node.js server listening on a new port and continue with this request.`) - server._socketPathSuffix = getRandomString() - return server.close(() => startServer(server)) - } else { - console.log('ERROR: server error') - console.error(error) - } - }) - - return server -} - -function proxy(server, event, context) { - if (server._isListening) { - forwardRequestToNodeServer(server, event, context) - return server - } else { - return startServer(server) - .on('listening', () => proxy(server, event, context)) - } -} - -exports.createServer = createServer -exports.proxy = proxy - -/* istanbul ignore else */ -if (process.env.NODE_ENV === 'test') { - exports.getPathWithQueryStringParams = getPathWithQueryStringParams - exports.mapApiGatewayEventToHttpRequest = mapApiGatewayEventToHttpRequest - exports.forwardResponseToApiGateway = forwardResponseToApiGateway - exports.forwardConnectionErrorResponseToApiGateway = forwardConnectionErrorResponseToApiGateway - exports.forwardLibraryErrorResponseToApiGateway = forwardLibraryErrorResponseToApiGateway - exports.forwardRequestToNodeServer = forwardRequestToNodeServer - exports.startServer = startServer - exports.getSocketPath = getSocketPath -} diff --git a/middleware.js b/middleware.js deleted file mode 100644 index 9ffe8019..00000000 --- a/middleware.js +++ /dev/null @@ -1,23 +0,0 @@ -module.exports.eventContext = options => function apiGatewayEventParser (req, res, next) { - options = options || {} // defaults: {reqPropKey: 'apiGateway', deleteHeaders: true} - const reqPropKey = options.reqPropKey || 'apiGateway' - const deleteHeaders = options.deleteHeaders === undefined ? true : options.deleteHeaders - - if (!req.headers['x-apigateway-event'] || !req.headers['x-apigateway-context']) { - console.error('Missing x-apigateway-event or x-apigateway-context header(s)') - next() - return - } - - req[reqPropKey] = { - event: JSON.parse(decodeURIComponent(req.headers['x-apigateway-event'])), - context: JSON.parse(decodeURIComponent(req.headers['x-apigateway-context'])) - } - - if (deleteHeaders) { - delete req.headers['x-apigateway-event'] - delete req.headers['x-apigateway-context'] - } - - next() -} diff --git a/package.json b/package.json index a2b8bfac..3175def8 100644 --- a/package.json +++ b/package.json @@ -57,17 +57,26 @@ }, "husky": { "hooks": { - "pre-push": "npm test", + "pre-commit": "lint-staged && npm run install-example-dependencies && npm test", "commit-msg": "commitlint -e $GIT_PARAMS" } }, + "lint-staged": { + "*.js": ["eslint --fix", "git add"] + }, "commitlint": { "extends": [ "@commitlint/config-conventional" ] }, + "eslintIgnore": [ + "examples/*/node_modules" + ], "jest": { - "collectCoverageFrom": ["index.js", "middleware.js"] + "collectCoverageFrom": [ + "index.js", + "middleware.js" + ] }, "devDependencies": { "@commitlint/config-conventional": "^6.1.0", @@ -78,8 +87,15 @@ "commitizen": "^2.9.6", "commitlint": "^6.1.0", "cz-conventional-changelog": "^2.1.0", + "eslint": "^4.19.1", + "eslint-config-standard": "^11.0.0", + "eslint-plugin-import": "^2.12.0", + "eslint-plugin-node": "^6.0.1", + "eslint-plugin-promise": "^3.8.0", + "eslint-plugin-standard": "^3.1.0", "husky": "^0.15.0-rc.4", "jest": "^16.0.2", + "lint-staged": "^7.2.0", "nsp": "^3.1.0", "semantic-release": "^13.1.3" }, @@ -91,6 +107,8 @@ "release": "semantic-release", "release-local": "node -r dotenv/config node_modules/semantic-release/bin/semantic-release --no-ci --dry-run", "check-dependencies": "npx npm-check --skip-unused --update", + "lint": "eslint src examples", + "install-example-dependencies": "cd examples && npm install --prefix basic-starter basic-starter && cd ..", "security-scan": "nsp check" }, "dependencies": { diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..8463cc43 --- /dev/null +++ b/src/index.js @@ -0,0 +1,208 @@ +/* + * Copyright 2016-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. + * This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +'use strict' +const http = require('http') +const url = require('url') +const binarycase = require('binary-case') +const isType = require('type-is') + +function getPathWithQueryStringParams (event) { + return url.format({ pathname: event.path, query: event.queryStringParameters }) +} + +function getContentType (params) { + // only compare mime type; ignore encoding part + return params.contentTypeHeader ? params.contentTypeHeader.split(';')[0] : '' +} + +function isContentTypeBinaryMimeType (params) { + return params.binaryMimeTypes.length > 0 && !!isType.is(params.contentType, params.binaryMimeTypes) +} + +function mapApiGatewayEventToHttpRequest (event, context, socketPath) { + const headers = event.headers || {} // NOTE: Mutating event.headers; prefer deep clone of event.headers + const eventWithoutBody = Object.assign({}, event) + delete eventWithoutBody.body + + headers['x-apigateway-event'] = encodeURIComponent(JSON.stringify(eventWithoutBody)) + headers['x-apigateway-context'] = encodeURIComponent(JSON.stringify(context)) + + return { + method: event.httpMethod, + path: getPathWithQueryStringParams(event), + headers, + socketPath + // protocol: `${headers['X-Forwarded-Proto']}:`, + // host: headers.Host, + // hostname: headers.Host, // Alias for host + // port: headers['X-Forwarded-Port'] + } +} + +function forwardResponseToApiGateway (server, response, context) { + let buf = [] + + response + .on('data', (chunk) => buf.push(chunk)) + .on('end', () => { + const bodyBuffer = Buffer.concat(buf) + const statusCode = response.statusCode + const headers = response.headers + + // chunked transfer not currently supported by API Gateway + /* istanbul ignore else */ + if (headers['transfer-encoding'] === 'chunked') { + delete headers['transfer-encoding'] + } + + // HACK: modifies header casing to get around API Gateway's limitation of not allowing multiple + // headers with the same name, as discussed on the AWS Forum https://forums.aws.amazon.com/message.jspa?messageID=725953#725953 + Object.keys(headers) + .forEach(h => { + if (Array.isArray(headers[h])) { + if (h.toLowerCase() === 'set-cookie') { + headers[h].forEach((value, i) => { + headers[binarycase(h, i + 1)] = value + }) + delete headers[h] + } else { + headers[h] = headers[h].join(',') + } + } + }) + + const contentType = getContentType({ contentTypeHeader: headers['content-type'] }) + const isBase64Encoded = isContentTypeBinaryMimeType({ contentType, binaryMimeTypes: server._binaryTypes }) + const body = bodyBuffer.toString(isBase64Encoded ? 'base64' : 'utf8') + const successResponse = {statusCode, body, headers, isBase64Encoded} + + context.succeed(successResponse) + }) +} + +function forwardConnectionErrorResponseToApiGateway (server, error, context) { + console.log('ERROR: aws-serverless-express connection error') + console.error(error) + const errorResponse = { + statusCode: 502, // "DNS resolution, TCP level errors, or actual HTTP parse errors" - https://nodejs.org/api/http.html#http_http_request_options_callback + body: '', + headers: {} + } + + context.succeed(errorResponse) +} + +function forwardLibraryErrorResponseToApiGateway (server, error, context) { + console.log('ERROR: aws-serverless-express error') + console.error(error) + const errorResponse = { + statusCode: 500, + body: '', + headers: {} + } + + context.succeed(errorResponse) +} + +function forwardRequestToNodeServer (server, event, context) { + try { + const requestOptions = mapApiGatewayEventToHttpRequest(event, context, getSocketPath(server._socketPathSuffix)) + const req = http.request(requestOptions, (response, body) => forwardResponseToApiGateway(server, response, context)) + if (event.body) { + if (event.isBase64Encoded) { + event.body = Buffer.from(event.body, 'base64') + } + + req.write(event.body) + } + + req.on('error', (error) => forwardConnectionErrorResponseToApiGateway(server, error, context)) + .end() + } catch (error) { + forwardLibraryErrorResponseToApiGateway(server, error, context) + return server + } +} + +function startServer (server) { + return server.listen(getSocketPath(server._socketPathSuffix)) +} + +function getSocketPath (socketPathSuffix) { + /* istanbul ignore if */ /* only running tests on Linux; Window support is for local dev only */ + if (/^win/.test(process.platform)) { + const path = require('path') + return path.join('\\\\?\\pipe', process.cwd(), `server-${socketPathSuffix}`) + } else { + return `/tmp/server-${socketPathSuffix}.sock` + } +} + +function getRandomString () { + return Math.random().toString(36).substring(2, 15) +} + +function createServer (requestListener, serverListenCallback, binaryTypes) { + const server = http.createServer(requestListener) + + server._socketPathSuffix = getRandomString() + server._binaryTypes = binaryTypes ? binaryTypes.slice() : [] + server.on('listening', () => { + server._isListening = true + + if (serverListenCallback) serverListenCallback() + }) + server.on('close', () => { + server._isListening = false + }) + .on('error', (error) => { + /* istanbul ignore else */ + if (error.code === 'EADDRINUSE') { + console.warn(`WARNING: Attempting to listen on socket ${getSocketPath(server._socketPathSuffix)}, but it is already in use. This is likely as a result of a previous invocation error or timeout. Check the logs for the invocation(s) immediately prior to this for root cause, and consider increasing the timeout and/or cpu/memory allocation if this is purely as a result of a timeout. aws-serverless-express will restart the Node.js server listening on a new port and continue with this request.`) + server._socketPathSuffix = getRandomString() + return server.close(() => startServer(server)) + } else { + console.log('ERROR: server error') + console.error(error) + } + }) + + return server +} + +function proxy (server, event, context) { + if (server._isListening) { + forwardRequestToNodeServer(server, event, context) + return server + } else { + return startServer(server) + .on('listening', () => proxy(server, event, context)) + } +} + +exports.createServer = createServer +exports.proxy = proxy + +/* istanbul ignore else */ +if (process.env.NODE_ENV === 'test') { + exports.getPathWithQueryStringParams = getPathWithQueryStringParams + exports.mapApiGatewayEventToHttpRequest = mapApiGatewayEventToHttpRequest + exports.forwardResponseToApiGateway = forwardResponseToApiGateway + exports.forwardConnectionErrorResponseToApiGateway = forwardConnectionErrorResponseToApiGateway + exports.forwardLibraryErrorResponseToApiGateway = forwardLibraryErrorResponseToApiGateway + exports.forwardRequestToNodeServer = forwardRequestToNodeServer + exports.startServer = startServer + exports.getSocketPath = getSocketPath +} diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 00000000..6ec937f2 --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,23 @@ +module.exports.eventContext = options => function apiGatewayEventParser (req, res, next) { + options = options || {} // defaults: {reqPropKey: 'apiGateway', deleteHeaders: true} + const reqPropKey = options.reqPropKey || 'apiGateway' + const deleteHeaders = options.deleteHeaders === undefined ? true : options.deleteHeaders + + if (!req.headers['x-apigateway-event'] || !req.headers['x-apigateway-context']) { + console.error('Missing x-apigateway-event or x-apigateway-context header(s)') + next() + return + } + + req[reqPropKey] = { + event: JSON.parse(decodeURIComponent(req.headers['x-apigateway-event'])), + context: JSON.parse(decodeURIComponent(req.headers['x-apigateway-context'])) + } + + if (deleteHeaders) { + delete req.headers['x-apigateway-event'] + delete req.headers['x-apigateway-context'] + } + + next() +}