Repository: theburningmonk/manning-aws-lambda-in-motion Branch: master Commit: eab98a4e1824 Files: 50 Total size: 118.3 KB Directory structure: gitextract_0qv7lc5k/ ├── .gitignore ├── .vscode/ │ └── launch.json ├── README.md ├── build.sh ├── buildspec.yml ├── examples/ │ ├── create-alarm.json │ ├── get-index.json │ ├── notify-restaurant.json │ ├── place-order.json │ ├── retry-notify-restaurant.json │ └── search-restaurants.json ├── functions/ │ ├── accept-order.js │ ├── create-alarms.js │ ├── fulfill-order.js │ ├── get-index.js │ ├── get-restaurants.js │ ├── notify-restaurant.js │ ├── notify-user.js │ ├── place-order.js │ ├── retry-notify-restaurant.js │ ├── retry-notify-user.js │ └── search-restaurants.js ├── lib/ │ ├── aws4.js │ ├── awscred.js │ ├── cloudwatch.js │ ├── correlation-ids.js │ ├── http.js │ ├── kinesis.js │ ├── log.js │ ├── lru.js │ ├── notify.js │ ├── retry.js │ └── sns.js ├── middleware/ │ ├── capture-correlation-ids.js │ ├── flush-metrics.js │ ├── function-shield.js │ ├── sample-logging.js │ └── wrapper.js ├── package.json ├── seed-restaurants.js ├── serverless.yml ├── static/ │ └── index.html ├── template.yml └── tests/ ├── steps/ │ ├── given.js │ ├── init.js │ ├── tearDown.js │ └── when.js └── test_cases/ ├── get-index.js ├── get-restaurants.js └── search-restaurants.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # package directories node_modules jspm_packages # Serverless directories .serverless ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Attach to SAM Local", "type": "node", "request": "attach", "address": "localhost", "port": 5858, "localRoot": "${workspaceRoot}", "remoteRoot": "/var/task" }, { "type": "node", "request": "launch", "name": "get-index", "program": "${workspaceFolder}/node_modules/.bin/sls", "args": [ "invoke", "local", "-f", "get-index", "-p", "examples/get-index.json" ], "env": { "restaurants_api": "https://8kbasri6v6.execute-api.us-east-1.amazonaws.com/dev/restaurants", "cognito_user_pool_id": "us-east-1_DfuAwa0vB", "cognito_client_id": "49lunjf7j7vsgmq9lhtfn4q7ma", "SLS_DEBUG": "*", "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" } }, { "type": "node", "request": "launch", "name": "get-restaurants", "program": "${workspaceFolder}/node_modules/.bin/sls", "args": [ "invoke", "local", "-f", "get-restaurants", "-d", "{}" ], "env": { "restaurants_table": "restaurants", "SLS_DEBUG": "*", "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" } }, { "type": "node", "request": "launch", "name": "search-restaurants", "program": "${workspaceFolder}/node_modules/.bin/sls", "args": [ "invoke", "local", "-f", "search-restaurants", "-p", "examples/search-restaurants.json" ], "env": { "restaurants_table": "restaurants", "SLS_DEBUG": "*", "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" } }, { "type": "node", "request": "launch", "name": "create-alarm", "program": "${workspaceFolder}/node_modules/.bin/sls", "args": [ "invoke", "local", "-f", "auto-create-api-alarms", "-p", "examples/create-alarm.json" ] }, { "type": "node", "request": "launch", "name": "notify-restaurant", "program": "${workspaceFolder}/node_modules/.bin/sls", "args": [ "invoke", "local", "-f", "notify-restaurant", "-p", "examples/notify-restaurant.json" ], "env": { "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" } }, { "type": "node", "request": "launch", "name": "place-order", "program": "${workspaceFolder}/node_modules/.bin/sls", "args": [ "invoke", "local", "-f", "place-order", "-p", "examples/place-order.json" ], "env": { "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" } }, { "type": "node", "request": "launch", "name": "integration tests", "program": "${workspaceFolder}/node_modules/.bin/mocha", "env": { "TEST_MODE": "handler" }, "args": [ "tests/test_cases", "--reporter", "spec" ] }, { "type": "node", "request": "launch", "name": "acceptance tests", "program": "${workspaceFolder}/node_modules/.bin/mocha", "env": { "TEST_MODE": "http", "TEST_ROOT": "https://8kbasri6v6.execute-api.us-east-1.amazonaws.com/dev/" }, "args": [ "tests/test_cases", "-t", "2000", "--reporter", "spec" ] }, ] } ================================================ FILE: README.md ================================================ # manning-aws-lambda-operational-patterns-and-practices Code for the Manning video course "AWS Lambda: Operational Patterns and Practices" ================================================ FILE: build.sh ================================================ #!/bin/bash set -e set -o pipefail instruction() { echo "usage: ./build.sh deploy " echo "" echo "env: eg. int, staging, prod, ..." echo "" echo "for example: ./deploy.sh int" } if [ $# -eq 0 ]; then instruction exit 1 elif [ "$1" = "int-test" ] && [ $# -eq 1 ]; then npm install npm run integration-test elif [ "$1" = "acceptance-test" ] && [ $# -eq 1 ]; then npm install npm run acceptance-test elif [ "$1" = "deploy" ] && [ $# -eq 3 ]; then STAGE=$2 REGION=$3 npm install 'node_modules/.bin/sls' deploy -s $STAGE -r $REGION else instruction exit 1 fi ================================================ FILE: buildspec.yml ================================================ version: 0.2 phases: build: commands: - chmod +x build.sh - ./build.sh int-test - ./build.sh deploy dev - ./build.sh acceptance-test ================================================ FILE: examples/create-alarm.json ================================================ { "version": "0", "id": "dee9a69c-8166-1ad7-41d4-1dad201e29f6", "detail-type": "AWS API Call via CloudTrail", "source": "aws.apigateway", "account": "374852340823", "time": "2018-04-09T00:17:47Z", "region": "us-east-1", "resources": [], "detail": { "eventVersion": "1.05", "userIdentity": { "type": "IAMUser", "principalId": "AIDAIRMUZZEGPO27IPFYW", "arn": "arn:aws:iam::374852340823:user/yan.cui", "accountId": "374852340823", "accessKeyId": "ASIAJNZDKN26DXPZFYQA", "userName": "yan.cui", "sessionContext": { "attributes": { "mfaAuthenticated": "false", "creationDate": "2018-04-09T00:17:30Z" } }, "invokedBy": "cloudformation.amazonaws.com" }, "eventTime": "2018-04-09T00:17:47Z", "eventSource": "apigateway.amazonaws.com", "eventName": "CreateDeployment", "awsRegion": "us-east-1", "sourceIPAddress": "cloudformation.amazonaws.com", "userAgent": "cloudformation.amazonaws.com", "requestParameters": { "restApiId": "8kbasri6v6", "createDeploymentInput": { "stageName": "dev" }, "template": false }, "responseElements": { "id": "cj2y0f", "createdDate": "Apr 9, 2018 12:17:47 AM", "deploymentUpdate": { "restApiId": "8kbasri6v6", "deploymentId": "cj2y0f", "template": false }, "deploymentStages": { "deploymentId": "cj2y0f", "restApiId": "8kbasri6v6", "template": false, "templateSkipList": [ "position" ] }, "deploymentDelete": { "deploymentId": "cj2y0f", "restApiId": "8kbasri6v6", "template": false }, "self": { "deploymentId": "cj2y0f", "restApiId": "8kbasri6v6", "template": false } }, "requestID": "6e25bd56-3b8b-11e8-a351-e5e3d3161fe7", "eventID": "a150d941-7a54-4572-97b2-0614a81fd25b", "readOnly": false, "eventType": "AwsApiCall" } } ================================================ FILE: examples/get-index.json ================================================ { "body": null, "resource": "/", "requestContext": { "resourceId": "123456", "apiId": "1234567890", "resourcePath": "/", "httpMethod": "POST", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "accountId": "123456789012", "identity": { "apiKey": null, "userArn": null, "cognitoAuthenticationType": null, "caller": null, "userAgent": "Custom User Agent String", "user": null, "cognitoIdentityPoolId": null, "cognitoIdentityId": null, "cognitoAuthenticationProvider": null, "sourceIp": "127.0.0.1", "accountId": null }, "authorizer": { "claims": { "sub": "fef3b4c8-cb16-4eb0-9d7c-201ac4cb0905", "email_verified": "true", "iss": "https:\/\/cognito-idp.us-east-1.amazonaws.com\/us-east-1_DfuAwa0vB", "cognito:username": "theburningmonk", "given_name": "Yan", "aud": "1tf8pb1s1n53p7u608e7ic5ih8", "event_id": "e727e68e-1424-11e8-be95-8774da63618a", "token_use": "id", "auth_time": "1518986085", "exp": "Sun Feb 18 21:34:45 UTC 2018", "iat": "Sun Feb 18 20:34:45 UTC 2018", "family_name": "Cui", "email": "theburningmonk@gmail.com" } }, "stage": "prod" }, "queryStringParameters": { "foo": "bar" }, "headers": { "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", "Accept-Language": "en-US,en;q=0.8", "CloudFront-Is-Desktop-Viewer": "true", "CloudFront-Is-SmartTV-Viewer": "false", "CloudFront-Is-Mobile-Viewer": "false", "X-Forwarded-For": "127.0.0.1, 127.0.0.2", "CloudFront-Viewer-Country": "US", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", "X-Forwarded-Port": "443", "Host": "1234567890.execute-api.us-east-1.amazonaws.com", "X-Forwarded-Proto": "https", "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", "CloudFront-Is-Tablet-Viewer": "false", "Cache-Control": "max-age=0", "User-Agent": "Custom User Agent String", "CloudFront-Forwarded-Proto": "https", "Accept-Encoding": "gzip, deflate, sdch" }, "pathParameters": { }, "httpMethod": "GET", "stageVariables": { "baz": "qux" }, "path": "/" } ================================================ FILE: examples/notify-restaurant.json ================================================ { "Records":[ { "kinesis": { "kinesisSchemaVersion":"1.0", "partitionKey":"e4a5eae6-19f7-5284-9cb5-f97d94518e34", "sequenceNumber":"49584583598311915935008171659389880270421244691116720130", "data":"eyJvcmRlcklkIjoiZTRhNWVhZTYtMTlmNy01Mjg0LTljYjUtZjk3ZDk0NTE4ZTM0IiwidXNlckVtYWlsIjoidGhlYnVybmluZ21vbmtAZ21haWwuY29tIiwicmVzdGF1cmFudE5hbWUiOiJGYW5ndGFzaWEiLCJldmVudFR5cGUiOiJvcmRlcl9wbGFjZWQifQ==", "approximateArrivalTimestamp":1527507873.753 }, "eventSource":"aws:kinesis", "eventVersion":"1.0", "eventID":"shardId-000000000000:49584583598311915935008171659389880270421244691116720130", "eventName":"aws:kinesis:record", "invokeIdentityArn":"arn:aws:iam::374852340823:role/big-mouth-dev-notify-restaurant-us-east-1-lambdaRole", "awsRegion":"us-east-1", "eventSourceARN":"arn:aws:kinesis:us-east-1:374852340823:stream/order-events" } ] } ================================================ FILE: examples/place-order.json ================================================ { "resource": "\/orders", "path": "\/orders", "httpMethod": "POST", "headers": { "Accept": "*\/*", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "en-US,en;q=0.9,zh-TW;q=0.8,zh;q=0.7,zh-CN;q=0.6,it;q=0.5", "Authorization": "eyJraWQiOiJJTnFEZWM5a2crdGx3WnNISEhabjY3dWRyXC9Hc3BrSXE4QTMwNlwvV054VTg9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJmZWYzYjRjOC1jYjE2LTRlYjAtOWQ3Yy0yMDFhYzRjYjA5MDUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tXC91cy1lYXN0LTFfRGZ1QXdhMHZCIiwiY29nbml0bzp1c2VybmFtZSI6InRoZWJ1cm5pbmdtb25rIiwiZ2l2ZW5fbmFtZSI6IllhbiIsImF1ZCI6IjF0ZjhwYjFzMW41M3A3dTYwOGU3aWM1aWg4IiwiZXZlbnRfaWQiOiJhMTA0YmZmMi02MjBkLTExZTgtODI4My1mNzgxOGJkMzhhNWEiLCJ0b2tlbl91c2UiOiJpZCIsImF1dGhfdGltZSI6MTUyNzQ2NzEzMSwiZXhwIjoxNTI4NjgxNzA4LCJpYXQiOjE1Mjg2NzgxMDgsImZhbWlseV9uYW1lIjoiQ3VpIiwiZW1haWwiOiJ0aGVidXJuaW5nbW9ua0BnbWFpbC5jb20ifQ.ejKf8d6f9jTU72mTmpSkuj_oHIqf_ZA9dUk3_92Y-NvmkEf3AVGJWYdLm2_MoAZwYrzHNQD5cxqIN973pgem73bI9SH0pJqZcbIZ7hS0ieGPkfn_LuMnf4_M3XMPEkGxTcGObl8batYJPeW5ohPE-rfmDjOjUVZxbzemIFY6Pf9ulZTvpVLg6VweR9637GcVktnc8pEKIggG33asqj693MEr_0xX-ioBMWb7NouSD9lz_c-XgiP13LkcNeWuC1DqACRlb8IxB7EzVfFhb-80TTXdYhVKMCnH7e1y5icipdp_lfm1aV9NlhnrWSrVctTWUSnesOmELImFrDQWlH_slg", "CloudFront-Forwarded-Proto": "https", "CloudFront-Is-Desktop-Viewer": "true", "CloudFront-Is-Mobile-Viewer": "false", "CloudFront-Is-SmartTV-Viewer": "false", "CloudFront-Is-Tablet-Viewer": "false", "CloudFront-Viewer-Country": "GB", "content-type": "application\/json", "Host": "8kbasri6v6.execute-api.us-east-1.amazonaws.com", "origin": "https:\/\/8kbasri6v6.execute-api.us-east-1.amazonaws.com", "Referer": "https:\/\/8kbasri6v6.execute-api.us-east-1.amazonaws.com\/dev\/", "User-Agent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/67.0.3396.79 Safari\/537.36", "Via": "2.0 37b010671d329179b4de819b0a4d4f15.cloudfront.net (CloudFront)", "X-Amz-Cf-Id": "7cCnqSDjyqrFSTv3lxs-2EjD4gZfu-MowCk4xWkKmqXlDA6RxJZOCQ==", "X-Amzn-Trace-Id": "Root=1-5b1dc99e-a55a324c20164c503b94e858", "X-Forwarded-For": "88.98.204.234, 52.46.38.90", "X-Forwarded-Port": "443", "X-Forwarded-Proto": "https" }, "queryStringParameters": null, "pathParameters": null, "stageVariables": null, "requestContext": { "resourceId": "7wew6z", "authorizer": { "claims": { "sub": "fef3b4c8-cb16-4eb0-9d7c-201ac4cb0905", "email_verified": "true", "iss": "https:\/\/cognito-idp.us-east-1.amazonaws.com\/us-east-1_DfuAwa0vB", "cognito:username": "theburningmonk", "given_name": "Yan", "aud": "1tf8pb1s1n53p7u608e7ic5ih8", "event_id": "a104bff2-620d-11e8-8283-f7818bd38a5a", "token_use": "id", "auth_time": "1527467131", "exp": "Mon Jun 11 01:48:28 UTC 2018", "iat": "Mon Jun 11 00:48:28 UTC 2018", "family_name": "Cui", "email": "theburningmonk@gmail.com" } }, "resourcePath": "\/orders", "httpMethod": "POST", "extendedRequestId": "ISxwwGuhoAMFg-Q=", "requestTime": "11\/Jun\/2018:01:00:14 +0000", "path": "\/dev\/orders", "accountId": "374852340823", "protocol": "HTTP\/1.1", "stage": "dev", "requestTimeEpoch": 1528678814455, "requestId": "cc9e10de-6d12-11e8-ad56-9338b9713eb7", "identity": { "cognitoIdentityPoolId": null, "accountId": null, "cognitoIdentityId": null, "caller": null, "sourceIp": "88.98.204.234", "accessKey": null, "cognitoAuthenticationType": null, "cognitoAuthenticationProvider": null, "userArn": null, "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/67.0.3396.79 Safari\/537.36", "user": null }, "apiId": "8kbasri6v6" }, "body": "{\"restaurantName\":\"Fangtasia\"}", "isBase64Encoded": false } ================================================ FILE: examples/retry-notify-restaurant.json ================================================ { "Records": [ { "EventVersion": "1.0", "EventSubscriptionArn": "arn:aws:sns:EXAMPLE", "EventSource": "aws:sns", "Sns": { "SignatureVersion": "1", "Timestamp": "1970-01-01T00:00:00.000Z", "Signature": "EXAMPLE", "SigningCertUrl": "EXAMPLE", "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", "Message": "{\"orderId\":\"875ae3c4-c21c-59e6-9057-23694b75f5a2\",\"userEmail\":\"theburningmonk@gmail.com\",\"restaurantName\":\"Fangtasia\"}", "MessageAttributes": { }, "Type": "Notification", "UnsubscribeUrl": "EXAMPLE", "TopicArn": "arn:aws:sns:EXAMPLE", "Subject": "TestInvoke" } } ] } ================================================ FILE: examples/search-restaurants.json ================================================ { "body": "{\"theme\":\"cartoon\"}", "resource": "/{proxy+}", "requestContext": { "resourceId": "123456", "apiId": "1234567890", "resourcePath": "/{proxy+}", "httpMethod": "POST", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "accountId": "123456789012", "identity": { "apiKey": null, "userArn": null, "cognitoAuthenticationType": null, "caller": null, "userAgent": "Custom User Agent String", "user": null, "cognitoIdentityPoolId": null, "cognitoIdentityId": null, "cognitoAuthenticationProvider": null, "sourceIp": "127.0.0.1", "accountId": null }, "authorizer": { "claims": { "sub": "fef3b4c8-cb16-4eb0-9d7c-201ac4cb0905", "email_verified": "true", "iss": "https:\/\/cognito-idp.us-east-1.amazonaws.com\/us-east-1_DfuAwa0vB", "cognito:username": "theburningmonk", "given_name": "Yan", "aud": "1tf8pb1s1n53p7u608e7ic5ih8", "event_id": "e727e68e-1424-11e8-be95-8774da63618a", "token_use": "id", "auth_time": "1518986085", "exp": "Sun Feb 18 21:34:45 UTC 2018", "iat": "Sun Feb 18 20:34:45 UTC 2018", "family_name": "Cui", "email": "theburningmonk@gmail.com" } }, "stage": "prod" }, "queryStringParameters": { "foo": "bar" }, "headers": { "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", "Accept-Language": "en-US,en;q=0.8", "CloudFront-Is-Desktop-Viewer": "true", "CloudFront-Is-SmartTV-Viewer": "false", "CloudFront-Is-Mobile-Viewer": "false", "X-Forwarded-For": "127.0.0.1, 127.0.0.2", "CloudFront-Viewer-Country": "US", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", "X-Forwarded-Port": "443", "Host": "1234567890.execute-api.us-east-1.amazonaws.com", "X-Forwarded-Proto": "https", "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", "CloudFront-Is-Tablet-Viewer": "false", "Cache-Control": "max-age=0", "User-Agent": "Custom User Agent String", "CloudFront-Forwarded-Proto": "https", "Accept-Encoding": "gzip, deflate, sdch" }, "pathParameters": { "proxy": "path/to/resource" }, "httpMethod": "POST", "stageVariables": { "baz": "qux" }, "path": "/path/to/resource" } ================================================ FILE: functions/accept-order.js ================================================ 'use strict'; const co = require('co'); const kinesis = require('../lib/kinesis'); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const wrapper = require('../middleware/wrapper'); const streamName = process.env.order_events_stream; const handler = co.wrap(function* (event, context, cb) { let req = JSON.parse(event.body); log.debug(`request body is valid JSON`, { requestBody: event.body }); let restaurantName = req.restaurantName; let orderId = req.orderId; let userEmail = req.userEmail; correlationIds.set('order-id', orderId); correlationIds.set('restaurant-name', restaurantName); correlationIds.set('user-email', userEmail); log.debug('restaurant accepted order', { orderId, restaurantName, userEmail }); let data = { orderId, userEmail, restaurantName, eventType: 'order_accepted' } let kinesisReq = { Data: JSON.stringify(data), // the SDK would base64 encode this for us PartitionKey: orderId, StreamName: streamName }; yield cloudwatch.trackExecTime( "KinesisPutRecordLatency", () => kinesis.putRecord(kinesisReq).promise() ); log.debug(`published event into Kinesis`, { eventName: 'order_accepted' }); let response = { statusCode: 200, body: JSON.stringify({ orderId }) } cb(null, response); }); module.exports.handler = wrapper(handler); ================================================ FILE: functions/create-alarms.js ================================================ 'use strict'; const _ = require('lodash'); const co = require('co'); const AWS = require('aws-sdk'); const apigateway = new AWS.APIGateway(); const cloudwatch = new AWS.CloudWatch(); const log = require('../lib/log'); const alarmActions = (process.env.alarm_actions || '').split(','); const okAction = (process.env.ok_actions || '').split(','); let enableDetailedMetrics = co.wrap(function* (restApiId, stageName) { let getResp = yield apigateway.getStage({ restApiId, stageName }).promise(); log.debug('get stage settings', getResp.methodSettings); let isDetailedMetricsEnabled = _.get(getResp, 'methodSettings.*/*.metricsEnabled', false); if (isDetailedMetricsEnabled) { log.debug('detailed metrics already enabled', { restApiId, stageName }); } else { let updateReq = { restApiId, stageName, patchOperations: [ { path: "/*/*/metrics/enabled", value: "true", op: "replace" } ] }; yield apigateway.updateStage(updateReq).promise(); log.debug('enabled detailed metrics', { restApiId, stageName }); } }); let getRestEndpoints = co.wrap(function* (restApiId) { let resp = yield apigateway.getResources({ restApiId }).promise(); log.debug('got REST resources', { restApiId }); let resourceMethods = resp.items.map(x => { let methods = _.keys(x.resourceMethods); return methods.map(method => ({ resource: x.path, method })); }); return _.flattenDeep(resourceMethods); }); let getRestApiName = co.wrap(function* (restApiId) { let resp = yield apigateway.getRestApi({ restApiId }).promise(); log.debug('got REST api', { restApiId }); return resp.name; }); let createAlarmsForEndpoints = co.wrap(function* (restApiId, stageName) { let apiName = yield getRestApiName(restApiId); log.debug(`API name is ${apiName}`, { restApiId, stageName }); let restEndpoints = yield getRestEndpoints(restApiId); log.debug('got REST endpoints', { restApiId, stageName, restEndpoints }); for (let endpoint of restEndpoints) { let putReq = { AlarmName: `API [${apiName}] stage [${stageName}] ${endpoint.method} ${endpoint.resource} : p99 > 1s`, MetricName: 'Latency', Dimensions: [ { Name: 'ApiName', Value: apiName }, { Name: 'Resource', Value: endpoint.resource }, { Name: 'Method', Value: endpoint.method }, { Name: 'Stage', Value: stageName } ], Namespace: 'AWS/ApiGateway', Threshold: 1000, // 1s ComparisonOperator: 'GreaterThanThreshold', Period: 60, // per min EvaluationPeriods: 5, DatapointsToAlarm: 5, // 5 consecutive mins to trigger alarm ExtendedStatistic: 'p99', ActionsEnabled: true, AlarmActions: alarmActions, AlarmDescription: `auto-generated by Lambda [${process.env.AWS_LAMBDA_FUNCTION_NAME}]`, OKActions: okAction, Unit: 'Milliseconds' }; yield cloudwatch.putMetricAlarm(putReq).promise(); } log.debug('auto-created latency ALARMS for REST endpoints', { restApiId, stageName, restEndpoints }); }); module.exports.handler = co.wrap(function* (event, context, cb) { let restApiId = event.detail.requestParameters.restApiId; let stageName = event.detail.requestParameters.createDeploymentInput.stageName; yield enableDetailedMetrics(restApiId, stageName); yield createAlarmsForEndpoints(restApiId, stageName); cb(null, 'ok'); }); ================================================ FILE: functions/fulfill-order.js ================================================ 'use strict'; const co = require('co'); const kinesis = require('../lib/kinesis'); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const wrapper = require('../middleware/wrapper'); const streamName = process.env.order_events_stream; const handler = co.wrap(function* (event, context, cb) { let body = JSON.parse(event.body); log.debug(`request body is valid JSON`, { requestBody: event.body }); let restaurantName = body.restaurantName; let orderId = body.orderId; let userEmail = body.userEmail; correlationIds.set('order-id', orderId); correlationIds.set('restaurant-name', restaurantName); correlationIds.set('user-email', userEmail); log.debug('restaurant has fulfilled order', { orderId, restaurantName, userEmail }); let data = { orderId, userEmail, restaurantName, eventType: 'order_fulfilled' } let kinesisReq = { Data: JSON.stringify(data), // the SDK would base64 encode this for us PartitionKey: orderId, StreamName: streamName }; yield cloudwatch.trackExecTime( "KinesisPutRecordLatency", () => kinesis.putRecord(kinesisReq).promise() ); log.debug(`published event into Kinesis`, { eventName: 'order_fulfilled' }); let response = { statusCode: 200, body: JSON.stringify({ orderId }) } cb(null, response); }); module.exports.handler = wrapper(handler); ================================================ FILE: functions/get-index.js ================================================ 'use strict'; const co = require("co"); const Promise = require("bluebird"); const fs = Promise.promisifyAll(require("fs")); const Mustache = require('mustache'); const http = require('../lib/http'); const URL = require('url'); const aws4 = require('../lib/aws4'); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const AWSXRay = require('aws-xray-sdk'); const wrapper = require('../middleware/wrapper'); const { ssm, secretsManager } = require('middy/middlewares'); const STAGE = process.env.STAGE; const awsRegion = process.env.AWS_REGION; const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; var html; function* loadHtml() { if (!html) { html = yield fs.readFileAsync('static/index.html', 'utf-8'); } return html; } function* getRestaurants(restaurantsApiUrl) { let url = URL.parse(restaurantsApiUrl); let opts = { host: url.hostname, path: url.pathname }; aws4.sign(opts); let httpReq = http({ uri: restaurantsApiUrl, headers: opts.headers }); return new Promise((resolve, reject) => { let f = co.wrap(function* (subsegment) { if (subsegment) { subsegment.addMetadata('url', restaurantsApiUrl); } try { let body = (yield httpReq).body; if (subsegment) { subsegment.close(); } resolve(body); } catch (err) { if (subsegment) { subsegment.close(err); } reject(err); } }); // the current sub/segment let segment = AWSXRay.getSegment(); AWSXRay.captureAsyncFunc("getting restaurants", f, segment); }); } const handler = co.wrap(function* (event, context, callback) { yield aws4.init(); let template = yield loadHtml(); log.debug("loaded HTML template"); let restaurants = yield cloudwatch.trackExecTime( "GetRestaurantsLatency", () => getRestaurants(context.restaurants_api) ); log.debug(`loaded ${restaurants.length} restaurants`); let dayOfWeek = days[new Date().getDay()]; let view = { dayOfWeek, restaurants, awsRegion, cognitoUserPoolId: context.cognito.user_pool_id, cognitoClientId: context.cognito.client_id, searchUrl: `${context.restaurants_api}/search`, placeOrderUrl: `${context.orders_api}` }; let html = Mustache.render(template, view); log.debug(`rendered HTML [${html.length} bytes]`); // uncomment this to cause function to err // yield http({ uri: 'https://theburningmonk.com' }); cloudwatch.incrCount('RestaurantsReturned', restaurants.length); const response = { statusCode: 200, body: html, headers: { 'content-type': 'text/html; charset=UTF-8' } }; callback(null, response); }); module.exports.handler = wrapper(handler) .use(ssm({ cache: true, cacheExpiryInMillis: 3 * 60 * 1000, // 3 mins setToContext: true, names: { restaurants_api: `/bigmouth/${STAGE}/restaurants_api`, orders_api: `/bigmouth/${STAGE}/orders_api` } })) .use(secretsManager({ cache: true, cacheExpiryInMillis: 3 * 60 * 1000, // 3 mins secrets: { cognito: `/bigmouth/${STAGE}/cognito` } })); ================================================ FILE: functions/get-restaurants.js ================================================ 'use strict'; const co = require('co'); const AWSXRay = require('aws-xray-sdk'); const AWS = AWSXRay.captureAWS(require('aws-sdk')); const dynamodb = new AWS.DynamoDB.DocumentClient(); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const wrapper = require('../middleware/wrapper'); const defaultResults = process.env.defaultResults || 8; const tableName = process.env.restaurants_table; function* getRestaurants(count) { let req = { TableName: tableName, Limit: count }; let resp = yield cloudwatch.trackExecTime( "DynamoDBScanLatency", () => dynamodb.scan(req).promise() ); return resp.Items; } const handler = co.wrap(function* (event, context, cb) { let restaurants = yield getRestaurants(defaultResults); log.debug(`loaded ${restaurants.length} restaurants`); cloudwatch.incrCount("RestaurantsReturned", restaurants.length); let response = { statusCode: 200, body: JSON.stringify(restaurants) } cb(null, response); }); module.exports.handler = wrapper(handler); ================================================ FILE: functions/notify-restaurant.js ================================================ 'use strict'; const co = require('co'); const notify = require('../lib/notify'); const retry = require('../lib/retry'); const log = require('../lib/log'); const wrapper = require('../middleware/wrapper'); const flushMetrics = require('../middleware/flush-metrics'); const handler = co.wrap(function* (event, context, cb) { let events = context.parsedKinesisEvents; let orderPlaced = events.filter(r => r.eventType === 'order_placed'); log.debug(`found ${orderPlaced.length} 'order_placed' events`); for (let order of orderPlaced) { order.scopeToThis(); try { yield notify.restaurantOfOrder(order); } catch (err) { yield retry.restaurantNotification(order); let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail }; log.warn('failed to notify restaurant of new order', logContext, err); } order.unscope(); } cb(null, "all done"); }); module.exports.handler = wrapper(handler) .use(flushMetrics); ================================================ FILE: functions/notify-user.js ================================================ 'use strict'; const co = require('co'); const notify = require('../lib/notify'); const retry = require('../lib/retry'); const log = require('../lib/log'); const wrapper = require('../middleware/wrapper'); const flushMetrics = require('../middleware/flush-metrics'); const handler = co.wrap(function* (event, context, cb) { let events = context.parsedKinesisEvents; let orderAccepted = events.filter(r => r.eventType === 'order_accepted'); log.debug(`found ${orderAccepted.length} 'order_accepted' events`); for (let order of orderAccepted) { order.scopeToThis(); try { yield notify.userOfOrderAccepted(order); } catch (err) { yield retry.userNotification(order); let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail }; log.warn('failed to notify user of accepted order', logContext, err); } order.unscope(); } cb(null, "all done"); }); module.exports.handler = wrapper(handler) .use(flushMetrics); ================================================ FILE: functions/place-order.js ================================================ 'use strict'; const _ = require('lodash'); const co = require('co'); const kinesis = require('../lib/kinesis'); const chance = require('chance').Chance(); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const correlationIds = require('../lib/correlation-ids'); const wrapper = require('../middleware/wrapper'); const streamName = process.env.order_events_stream; const UNAUTHORIZED = { statusCode: 401, body: "unauthorized" } const handler = co.wrap(function* (event, context, cb) { let req = JSON.parse(event.body); log.debug(`request body is valid JSON`, { requestBody: event.body }); let userEmail = _.get(event, 'requestContext.authorizer.claims.email'); if (!userEmail) { cb(null, UNAUTHORIZED); log.error('unauthorized request, user email is not provided'); return; } let restaurantName = req.restaurantName; let orderId = chance.guid(); correlationIds.set('order-id', orderId); correlationIds.set('restaurant-name', restaurantName); correlationIds.set('user-email', userEmail); log.debug(`placing order...`, { orderId, restaurantName, userEmail }); let data = { orderId, userEmail, restaurantName, eventType: 'order_placed' } let kinesisReq = { Data: JSON.stringify(data), // the SDK would base64 encode this for us PartitionKey: orderId, StreamName: streamName }; yield cloudwatch.trackExecTime( "KinesisPutRecordLatency", () => kinesis.putRecord(kinesisReq).promise() ); log.debug(`published event into Kinesis`, { eventName: 'order_placed' }); let response = { statusCode: 200, body: JSON.stringify({ orderId }) } cb(null, response); }); module.exports.handler = wrapper(handler); ================================================ FILE: functions/retry-notify-restaurant.js ================================================ 'use strict'; const co = require('co'); const notify = require('../lib/notify'); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const wrapper = require('../middleware/wrapper'); const flushMetrics = require('../middleware/flush-metrics'); const handler = co.wrap(function* (event, context, cb) { let order = JSON.parse(event.Records[0].Sns.Message); order.retried = true; let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail, retry: true }; try { yield notify.restaurantOfOrder(order); cb(null, "all done"); } catch (err) { log.warn('failed to notify restaurant of new order', logContext, err); cb(err); } finally { cloudwatch.incrCount("NotifyRestaurantRetried"); } }); module.exports.handler = wrapper(handler) .use(flushMetrics); ================================================ FILE: functions/retry-notify-user.js ================================================ 'use strict'; const co = require('co'); const notify = require('../lib/notify'); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const wrapper = require('../middleware/wrapper'); const flushMetrics = require('../middleware/flush-metrics'); const handler = co.wrap(function* (event, context, cb) { let order = JSON.parse(event.Records[0].Sns.Message); order.retried = true; let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail, retry: true }; try { yield notify.userOfOrderAccepted(order); cb(null, "all done"); } catch (err) { log.warn('failed to notify user of accepted order', logContext, err); cb(err); } finally { cloudwatch.incrCount("NotifyUserRetried"); } }); module.exports.handler = wrapper(handler) .use(flushMetrics); ================================================ FILE: functions/search-restaurants.js ================================================ 'use strict'; const co = require('co'); const AWSXRay = require('aws-xray-sdk'); const AWS = AWSXRay.captureAWS(require('aws-sdk')); const dynamodb = new AWS.DynamoDB.DocumentClient(); const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); const wrapper = require('../middleware/wrapper'); const defaultResults = process.env.defaultResults || 8; const tableName = process.env.restaurants_table; function* findRestaurantsByTheme(theme, count) { let req = { TableName: tableName, Limit: count, FilterExpression: "contains(themes, :theme)", ExpressionAttributeValues: { ":theme": theme } }; let resp = yield cloudwatch.trackExecTime( "DynamoDBScanLatency", () => dynamodb.scan(req).promise() ); return resp.Items; } const handler = co.wrap(function* (event, context, cb) { let req = JSON.parse(event.body); log.debug(`request body is valid JSON`, { requestBody: event.body }); let restaurants = yield findRestaurantsByTheme(req.theme, defaultResults); log.debug(`found ${restaurants.length} restaurants`); cloudwatch.incrCount("RestaurantsFound", restaurants.length); let response = { statusCode: 200, body: JSON.stringify(restaurants) } cb(null, response); }); module.exports.handler = wrapper(handler); ================================================ FILE: lib/aws4.js ================================================ var aws4 = exports, url = require('url'), querystring = require('querystring'), crypto = require('crypto'), lru = require('./lru'), credentialsCache = lru(1000), awscred = require('./awscred') // http://docs.amazonwebservices.com/general/latest/gr/signature-version-4.html function hmac(key, string, encoding) { return crypto.createHmac('sha256', key).update(string, 'utf8').digest(encoding) } function hash(string, encoding) { return crypto.createHash('sha256').update(string, 'utf8').digest(encoding) } // This function assumes the string has already been percent encoded function encodeRfc3986(urlEncodedString) { return urlEncodedString.replace(/[!'()*]/g, function(c) { return '%' + c.charCodeAt(0).toString(16).toUpperCase() }) } // request: { path | body, [host], [method], [headers], [service], [region] } // credentials: { accessKeyId, secretAccessKey, [sessionToken] } function RequestSigner(request, credentials) { if (typeof request === 'string') request = url.parse(request) var headers = request.headers = (request.headers || {}), hostParts = this.matchHost(request.hostname || request.host || headers.Host || headers.host) this.request = request this.credentials = credentials || this.defaultCredentials() this.service = request.service || hostParts[0] || '' this.region = request.region || hostParts[1] || 'us-east-1' // SES uses a different domain from the service name if (this.service === 'email') this.service = 'ses' if (!request.method && request.body) request.method = 'POST' if (!headers.Host && !headers.host) { headers.Host = request.hostname || request.host || this.createHost() // If a port is specified explicitly, use it as is if (request.port) headers.Host += ':' + request.port } if (!request.hostname && !request.host) request.hostname = headers.Host || headers.host this.isCodeCommitGit = this.service === 'codecommit' && request.method === 'GIT' } RequestSigner.prototype.matchHost = function(host) { var match = (host || '').match(/([^\.]+)\.(?:([^\.]*)\.)?amazonaws\.com$/) var hostParts = (match || []).slice(1, 3) // ES's hostParts are sometimes the other way round, if the value that is expected // to be region equals ‘es’ switch them back // e.g. search-cluster-name-aaaa00aaaa0aaa0aaaaaaa0aaa.us-east-1.es.amazonaws.com if (hostParts[1] === 'es') hostParts = hostParts.reverse() return hostParts } // http://docs.aws.amazon.com/general/latest/gr/rande.html RequestSigner.prototype.isSingleRegion = function() { // Special case for S3 and SimpleDB in us-east-1 if (['s3', 'sdb'].indexOf(this.service) >= 0 && this.region === 'us-east-1') return true return ['cloudfront', 'ls', 'route53', 'iam', 'importexport', 'sts'] .indexOf(this.service) >= 0 } RequestSigner.prototype.createHost = function() { var region = this.isSingleRegion() ? '' : (this.service === 's3' && this.region !== 'us-east-1' ? '-' : '.') + this.region, service = this.service === 'ses' ? 'email' : this.service return service + region + '.amazonaws.com' } RequestSigner.prototype.prepareRequest = function() { this.parsePath() var request = this.request, headers = request.headers, query if (request.signQuery) { this.parsedPath.query = query = this.parsedPath.query || {} if (this.credentials.sessionToken) query['X-Amz-Security-Token'] = this.credentials.sessionToken if (this.service === 's3' && !query['X-Amz-Expires']) query['X-Amz-Expires'] = 86400 if (query['X-Amz-Date']) this.datetime = query['X-Amz-Date'] else query['X-Amz-Date'] = this.getDateTime() query['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256' query['X-Amz-Credential'] = this.credentials.accessKeyId + '/' + this.credentialString() query['X-Amz-SignedHeaders'] = this.signedHeaders() } else { if (!request.doNotModifyHeaders && !this.isCodeCommitGit) { if (request.body && !headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8' if (request.body && !headers['Content-Length'] && !headers['content-length']) headers['Content-Length'] = Buffer.byteLength(request.body) if (this.credentials.sessionToken && !headers['X-Amz-Security-Token'] && !headers['x-amz-security-token']) headers['X-Amz-Security-Token'] = this.credentials.sessionToken if (this.service === 's3' && !headers['X-Amz-Content-Sha256'] && !headers['x-amz-content-sha256']) headers['X-Amz-Content-Sha256'] = hash(this.request.body || '', 'hex') if (headers['X-Amz-Date'] || headers['x-amz-date']) this.datetime = headers['X-Amz-Date'] || headers['x-amz-date'] else headers['X-Amz-Date'] = this.getDateTime() } delete headers.Authorization delete headers.authorization } } RequestSigner.prototype.sign = function() { if (!this.parsedPath) this.prepareRequest() if (this.request.signQuery) { this.parsedPath.query['X-Amz-Signature'] = this.signature() } else { this.request.headers.Authorization = this.authHeader() } this.request.path = this.formatPath() return this.request } RequestSigner.prototype.getDateTime = function() { if (!this.datetime) { var headers = this.request.headers, date = new Date(headers.Date || headers.date || new Date) this.datetime = date.toISOString().replace(/[:\-]|\.\d{3}/g, '') // Remove the trailing 'Z' on the timestamp string for CodeCommit git access if (this.isCodeCommitGit) this.datetime = this.datetime.slice(0, -1) } return this.datetime } RequestSigner.prototype.getDate = function() { return this.getDateTime().substr(0, 8) } RequestSigner.prototype.authHeader = function() { return [ 'AWS4-HMAC-SHA256 Credential=' + this.credentials.accessKeyId + '/' + this.credentialString(), 'SignedHeaders=' + this.signedHeaders(), 'Signature=' + this.signature(), ].join(', ') } RequestSigner.prototype.signature = function() { var date = this.getDate(), cacheKey = [this.credentials.secretAccessKey, date, this.region, this.service].join(), kDate, kRegion, kService, kCredentials = credentialsCache.get(cacheKey) if (!kCredentials) { kDate = hmac('AWS4' + this.credentials.secretAccessKey, date) kRegion = hmac(kDate, this.region) kService = hmac(kRegion, this.service) kCredentials = hmac(kService, 'aws4_request') credentialsCache.set(cacheKey, kCredentials) } return hmac(kCredentials, this.stringToSign(), 'hex') } RequestSigner.prototype.stringToSign = function() { return [ 'AWS4-HMAC-SHA256', this.getDateTime(), this.credentialString(), hash(this.canonicalString(), 'hex'), ].join('\n') } RequestSigner.prototype.canonicalString = function() { if (!this.parsedPath) this.prepareRequest() var pathStr = this.parsedPath.path, query = this.parsedPath.query, headers = this.request.headers, queryStr = '', normalizePath = this.service !== 's3', decodePath = this.service === 's3' || this.request.doNotEncodePath, decodeSlashesInPath = this.service === 's3', firstValOnly = this.service === 's3', bodyHash if (this.service === 's3' && this.request.signQuery) { bodyHash = 'UNSIGNED-PAYLOAD' } else if (this.isCodeCommitGit) { bodyHash = '' } else { bodyHash = headers['X-Amz-Content-Sha256'] || headers['x-amz-content-sha256'] || hash(this.request.body || '', 'hex') } if (query) { queryStr = encodeRfc3986(querystring.stringify(Object.keys(query).sort().reduce(function(obj, key) { if (!key) return obj obj[key] = !Array.isArray(query[key]) ? query[key] : (firstValOnly ? query[key][0] : query[key].slice().sort()) return obj }, {}))) } if (pathStr !== '/') { if (normalizePath) pathStr = pathStr.replace(/\/{2,}/g, '/') pathStr = pathStr.split('/').reduce(function(path, piece) { if (normalizePath && piece === '..') { path.pop() } else if (!normalizePath || piece !== '.') { if (decodePath) piece = querystring.unescape(piece) path.push(encodeRfc3986(querystring.escape(piece))) } return path }, []).join('/') if (pathStr[0] !== '/') pathStr = '/' + pathStr if (decodeSlashesInPath) pathStr = pathStr.replace(/%2F/g, '/') } return [ this.request.method || 'GET', pathStr, queryStr, this.canonicalHeaders() + '\n', this.signedHeaders(), bodyHash, ].join('\n') } RequestSigner.prototype.canonicalHeaders = function() { var headers = this.request.headers function trimAll(header) { return header.toString().trim().replace(/\s+/g, ' ') } return Object.keys(headers) .sort(function(a, b) { return a.toLowerCase() < b.toLowerCase() ? -1 : 1 }) .map(function(key) { return key.toLowerCase() + ':' + trimAll(headers[key]) }) .join('\n') } RequestSigner.prototype.signedHeaders = function() { return Object.keys(this.request.headers) .map(function(key) { return key.toLowerCase() }) .sort() .join(';') } RequestSigner.prototype.credentialString = function() { return [ this.getDate(), this.region, this.service, 'aws4_request', ].join('/') } RequestSigner.prototype.defaultCredentials = function() { var env = process.env return { accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, sessionToken: env.AWS_SESSION_TOKEN, } } RequestSigner.prototype.parsePath = function() { var path = this.request.path || '/', queryIx = path.indexOf('?'), query = null if (queryIx >= 0) { query = querystring.parse(path.slice(queryIx + 1)) path = path.slice(0, queryIx) } // S3 doesn't always encode characters > 127 correctly and // all services don't encode characters > 255 correctly // So if there are non-reserved chars (and it's not already all % encoded), just encode them all if (/[^0-9A-Za-z!'()*\-._~%/]/.test(path)) { path = path.split('/').map(function(piece) { return querystring.escape(querystring.unescape(piece)) }).join('/') } this.parsedPath = { path: path, query: query, } } RequestSigner.prototype.formatPath = function() { var path = this.parsedPath.path, query = this.parsedPath.query if (!query) return path // Services don't support empty query string keys if (query[''] != null) delete query[''] return path + '?' + encodeRfc3986(querystring.stringify(query)) } aws4.RequestSigner = RequestSigner aws4.sign = function(request, credentials) { return new RequestSigner(request, credentials).sign() } var isInitialized = false; aws4.init = function() { return new Promise(function(resolve, reject) { if (isInitialized) { return resolve() } if (process.env.AWS_ACCESS_KEY_ID) { isInitialized = true; return resolve() } else { console.error("initializing AWS credentials...") awscred.load(function(error, result) { if (error) { reject(error) } else { let cred = result.credentials; process.env.AWS_ACCESS_KEY_ID = cred.accessKeyId; process.env.AWS_SECRET_ACCESS_KEY = cred.secretAccessKey; if (cred.sessionToken) { process.env.AWS_SESSION_TOKEN = cred.sessionToken; } resolve() isInitialized = true } }) } }) } ================================================ FILE: lib/awscred.js ================================================ var fs = require('fs'), path = require('path'), http = require('http'), env = process.env exports.credentialsCallChain = [ loadCredentialsFromEnv, loadCredentialsFromIniFile, loadCredentialsFromEc2Metadata, loadCredentialsFromEcs, ] exports.regionCallChain = [ loadRegionFromEnv, loadRegionFromIniFile, ] exports.load = exports.loadCredentialsAndRegion = loadCredentialsAndRegion exports.loadCredentials = loadCredentials exports.loadRegion = loadRegion exports.loadRegionSync = loadRegionSync exports.loadCredentialsFromEnv = loadCredentialsFromEnv exports.loadRegionFromEnv = loadRegionFromEnv exports.loadRegionFromEnvSync = loadRegionFromEnvSync exports.loadCredentialsFromIniFile = loadCredentialsFromIniFile exports.loadRegionFromIniFile = loadRegionFromIniFile exports.loadRegionFromIniFileSync = loadRegionFromIniFileSync exports.loadCredentialsFromEc2Metadata = loadCredentialsFromEc2Metadata exports.loadProfileFromIniFile = loadProfileFromIniFile exports.loadProfileFromIniFileSync = loadProfileFromIniFileSync exports.merge = merge function loadCredentialsAndRegion(options, cb) { if (!cb) { cb = options; options = {} } cb = once(cb) var out = {}, callsRemaining = 2 function checkDone(propName) { return function(err, data) { if (err) return cb(err) out[propName] = data if (!--callsRemaining) return cb(null, out) } } loadCredentials(options, checkDone('credentials')) loadRegion(options, checkDone('region')) } function loadCredentials(options, cb) { if (!cb) { cb = options; options = {} } var credentialsCallChain = options.credentialsCallChain || exports.credentialsCallChain function nextCall(i) { credentialsCallChain[i](options, function(err, credentials) { if (err) return cb(err) if (credentials.accessKeyId && credentials.secretAccessKey) return cb(null, credentials) if (i >= credentialsCallChain.length - 1) return cb(null, {}) nextCall(i + 1) }) } nextCall(0) } function loadRegion(options, cb) { if (!cb) { cb = options; options = {} } var regionCallChain = options.regionCallChain || exports.regionCallChain function nextCall(i) { regionCallChain[i](options, function(err, region) { if (err) return cb(err) if (region) return cb(null, region) if (i >= regionCallChain.length - 1) return cb(null, 'us-east-1') nextCall(i + 1) }) } nextCall(0) } function loadRegionSync(options) { return loadRegionFromEnvSync(options) || loadRegionFromIniFileSync(options) } function loadCredentialsFromEnv(options, cb) { if (!cb) { cb = options; options = {} } cb(null, { accessKeyId: env.AWS_ACCESS_KEY_ID || env.AMAZON_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AMAZON_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, sessionToken: env.AWS_SESSION_TOKEN || env.AMAZON_SESSION_TOKEN, }) } function loadRegionFromEnv(options, cb) { if (!cb) { cb = options; options = {} } cb(null, loadRegionFromEnvSync()) } function loadRegionFromEnvSync() { return env.AWS_REGION || env.AMAZON_REGION || env.AWS_DEFAULT_REGION } function loadCredentialsFromIniFile(options, cb) { if (!cb) { cb = options; options = {} } loadProfileFromIniFile(options, 'credentials', function(err, profile) { if (err) return cb(err) cb(null, { accessKeyId: profile.aws_access_key_id, secretAccessKey: profile.aws_secret_access_key, sessionToken: profile.aws_session_token, }) }) } function loadRegionFromIniFile(options, cb) { if (!cb) { cb = options; options = {} } loadProfileFromIniFile(options, 'config', function(err, profile) { if (err) return cb(err) cb(null, profile.region) }) } function loadRegionFromIniFileSync(options) { return loadProfileFromIniFileSync(options || {}, 'config').region } var TIMEOUT_CODES = ['ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'Unknown system errno 64'] var ec2Callbacks = [] var ecsCallbacks = [] function loadCredentialsFromEcs(options, cb) { if (!cb) { cb = options; options = {} } ecsCallbacks.push(cb) if (ecsCallbacks.length > 1) return // only want one caller at a time cb = function(err, credentials) { ecsCallbacks.forEach(function(cb) { cb(err, credentials) }) ecsCallbacks = [] } if (options.timeout == null) options.timeout = 5000 options.host = '169.254.170.2' options.path = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI return request(options, function(err, res, data) { if (err && ~TIMEOUT_CODES.indexOf(err.code)) return cb(null, {}) if (err) return cb(err) if (res.statusCode != 200) return cb(new Error('Failed to fetch IAM role: ' + res.statusCode + ' ' + data)) try { data = JSON.parse(data) } catch (e) { } if (res.statusCode != 200) return cb(new Error('Failed to fetch IAM credentials: ' + res.statusCode + ' ' + data)) cb(null, { accessKeyId: data.AccessKeyId, secretAccessKey: data.SecretAccessKey, sessionToken: data.Token, expiration: new Date(data.Expiration), }) }) } function loadCredentialsFromEc2Metadata(options, cb) { if (!cb) { cb = options; options = {} } ec2Callbacks.push(cb) if (ec2Callbacks.length > 1) return // only want one caller at a time cb = function(err, credentials) { ec2Callbacks.forEach(function(cb) { cb(err, credentials) }) ec2Callbacks = [] } if (options.timeout == null) options.timeout = 5000 options.host = '169.254.169.254' options.path = '/latest/meta-data/iam/security-credentials/' return request(options, function(err, res, data) { if (err && ~TIMEOUT_CODES.indexOf(err.code)) return cb(null, {}) if (err) return cb(err) if (res.statusCode != 200) return cb(new Error('Failed to fetch IAM role: ' + res.statusCode + ' ' + data)) options.path += data.split('\n')[0] request(options, function(err, res, data) { if (err) return cb(err) try { data = JSON.parse(data) } catch (e) { } if (res.statusCode != 200 || data.Code != 'Success') return cb(new Error('Failed to fetch IAM credentials: ' + res.statusCode + ' ' + data)) cb(null, { accessKeyId: data.AccessKeyId, secretAccessKey: data.SecretAccessKey, sessionToken: data.Token, expiration: new Date(data.Expiration), }) }) }) } function loadProfileFromIniFile(options, defaultFilename, cb) { var filename = options.filename || path.join(resolveHome(), '.aws', defaultFilename), profile = options.profile || resolveProfile() fs.readFile(filename, 'utf8', function(err, data) { if (err && err.code == 'ENOENT') return cb(null, {}) if (err) return cb(err) cb(null, parseAwsIni(data)[profile] || {}) }) } function loadProfileFromIniFileSync(options, defaultFilename) { var filename = options.filename || path.join(resolveHome(), '.aws', defaultFilename), profile = options.profile || resolveProfile(), data try { data = fs.readFileSync(filename, 'utf8') } catch (err) { if (err.code == 'ENOENT') return {} throw err } return parseAwsIni(data)[profile] || {} } function merge(obj, options, cb) { if (!cb) { cb = options; options = {} } var needRegion = !obj.region var needCreds = !obj.credentials || !obj.credentials.accessKeyId || !obj.credentials.secretAccessKey function loadCreds(cb) { if (needRegion && needCreds) { return loadCredentialsAndRegion(options, cb) } else if (needRegion) { return loadRegion(options, function(err, region) { cb(err, {region: region}) }) } else if (needCreds) { return loadCredentials(options, function(err, credentials) { cb(err, {credentials: credentials}) }) } cb(null, {}) } loadCreds(function(err, creds) { if (err) return cb(err) if (creds.region) obj.region = creds.region if (creds.credentials) { if (!obj.credentials) { obj.credentials = creds.credentials } else { Object.keys(creds.credentials).forEach(function(key) { if (!obj.credentials[key]) obj.credentials[key] = creds.credentials[key] }) } } cb() }) } function resolveProfile() { return env.AWS_PROFILE || env.AMAZON_PROFILE || 'default' } function resolveHome() { return env.HOME || env.USERPROFILE || ((env.HOMEDRIVE || 'C:') + env.HOMEPATH) } // Fairly strict INI parser – will only deal with alpha keys, must be within sections function parseAwsIni(ini) { var section, out = Object.create(null), re = /^\[([^\]]+)\]\s*$|^([a-z_]+)\s*=\s*(.+?)\s*$/, lines = ini.split(/\r?\n/) lines.forEach(function(line) { var match = line.match(re) if (!match) return if (match[1]) { section = match[1] if (out[section] == null) out[section] = Object.create(null) } else if (section) { out[section][match[2]] = match[3] } }) return out } function request(options, cb) { cb = once(cb) var req = http.request(options, function(res) { var data = '' res.setEncoding('utf8') res.on('error', cb) res.on('data', function(chunk) { data += chunk }) res.on('end', function() { cb(null, res, data) }) }).on('error', cb) if (options.timeout != null) { req.setTimeout(options.timeout) req.on('timeout', function() { req.abort() }) } req.end() } function once(cb) { var called = false return function() { if (called) return called = true cb.apply(this, arguments) } } ================================================ FILE: lib/cloudwatch.js ================================================ 'use strict'; const co = require('co'); const AWSXRay = require('aws-xray-sdk'); const AWS = AWSXRay.captureAWS(require('aws-sdk')); const log = require('./log'); const cloudwatch = new AWS.CloudWatch(); const namespace = 'big-mouth'; const async = (process.env.async_metrics || 'false') === 'true'; // the Lambda execution environment defines a number of env variables: // https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html // and the serverless framework also defines a STAGE env variable too const dimensions = [ { Name: 'Function', Value: process.env.AWS_LAMBDA_FUNCTION_NAME }, { Name: 'Version', Value: process.env.AWS_LAMBDA_FUNCTION_VERSION }, { Name: 'Stage', Value: process.env.STAGE } ] .filter(dim => dim.Value); let countMetrics = {}; let timeMetrics = {}; function getCountMetricData(name, value) { return { MetricName : name, Dimensions : dimensions, Unit : 'Count', Value : value }; } function getTimeMetricData(name, statsValues) { return { MetricName : name, Dimensions : dimensions, Unit : 'Milliseconds', StatisticValues : statsValues }; } function getCountMetricDatum() { let keys = Object.keys(countMetrics); if (keys.length === 0) { return []; } let metricDatum = keys.map(key => getCountMetricData(key, countMetrics[key])); countMetrics = {}; // zero out the recorded count metrics return metricDatum; } function getTimeMetricDatum() { let keys = Object.keys(timeMetrics); if (keys.length === 0) { return []; } let metricDatum = keys.map(key => getTimeMetricData(key, timeMetrics[key])); timeMetrics = {}; // zero out the recorded time metrics return metricDatum; } let flush = co.wrap(function* () { let countDatum = getCountMetricDatum(); let timeDatum = getTimeMetricDatum(); let allDatum = countDatum.concat(timeDatum); if (allDatum.length == 0) { return; } let metricNames = allDatum.map(x => x.MetricName).join(','); log.debug(`flushing [${allDatum.length}] metrics to CloudWatch: ${metricNames}`); var params = { MetricData: allDatum, Namespace: namespace }; try { yield cloudwatch.putMetricData(params).promise(); log.debug(`flushed [${allDatum.length}] metrics to CloudWatch: ${metricNames}`); } catch (err) { log.warn(`cloudn't flush [${allDatum.length}] CloudWatch metrics`, null, err); } }); function clear() { countMetrics = {}; timeMetrics = {}; } function incrCount(metricName, count) { count = count || 1; if (async) { console.log(`MONITORING|${count}|count|${metricName}|${namespace}`); } else { if (countMetrics[metricName]) { countMetrics[metricName] += count; } else { countMetrics[metricName] = count; } } } function recordTimeInMillis(metricName, ms) { if (!ms) { return; } log.debug(`new execution time for [${metricName}] : ${ms} milliseconds`); if (async) { console.log(`MONITORING|${ms}|milliseconds|${metricName}|${namespace}`); } else { if (timeMetrics[metricName]) { let metric = timeMetrics[metricName]; metric.Sum += ms; metric.Maximum = Math.max(metric.Maximum, ms); metric.Minimum = Math.min(metric.Minimum, ms); metric.SampleCount += 1; } else { let statsValues = { Maximum : ms, Minimum : ms, SampleCount : 1, Sum : ms }; timeMetrics[metricName] = statsValues; } } } function trackExecTime(metricName, f) { if (!f || typeof f !== "function") { throw new Error('cloudWatch.trackExecTime requires a function, eg. () => 42'); } if (!metricName) { throw new Error('cloudWatch.trackExecTime requires a metric name, eg. "CloudSearch-latency"'); } let start = new Date().getTime(), end; let res = f(); // anything with a 'then' function can be considered a Promise... // http://stackoverflow.com/a/27746324/55074 if (!res.hasOwnProperty('then')) { end = new Date().getTime(); recordTimeInMillis(metricName, end-start); return res; } else { return res.then(x => { end = new Date().getTime(); recordTimeInMillis(metricName, end-start); return x; }); } } module.exports = { flush, clear, incrCount, trackExecTime, recordTimeInMillis }; ================================================ FILE: lib/correlation-ids.js ================================================ 'use strict'; let clearAll = () => global.CONTEXT = undefined; let replaceAllWith = ctx => global.CONTEXT = ctx; let set = (key, value) => { if (!key.startsWith("x-correlation-")) { key = "x-correlation-" + key; } if (!global.CONTEXT) { global.CONTEXT = {}; } global.CONTEXT[key] = value; }; let get = () => global.CONTEXT || {}; module.exports = { clearAll: clearAll, replaceAllWith: replaceAllWith, set: set, get: get }; ================================================ FILE: lib/http.js ================================================ 'use strict'; const correlationIds = require('./correlation-ids'); const http = require('superagent-promise')(require('superagent'), Promise); function getRequest (options) { let uri = options.uri; let method = options.method || ''; switch (method.toLowerCase()) { case '': case 'get': return http.get(uri); case 'head': return http.head(uri); case 'post': return http.post(uri); case 'put': return http.put(uri); case 'delete': return http.del(uri); default: throw new Error(`unsupported method : ${method.toLowerCase()}`); } } function setHeaders (request, headers) { let headerNames = Object.keys(headers); headerNames.forEach(h => request = request.set(h, headers[h])); return request; } function setQueryStrings (request, qs) { if (!qs) { return request; } return request.query(qs); } function setBody (request, body) { if (!body) { return request; } return request.send(body); } // options: { // uri : string // method : GET (default) | POST | PUT | HEAD // headers : object // qs : object // body : object // } let Req = (options) => { if (!options) { throw new Error('no HTTP request options is provided'); } if (!options.uri) { throw new Error('no HTTP uri is specified'); } const context = correlationIds.get(); // copy the provided headers last so it overrides the values from the context let headers = Object.assign({}, context, options.headers); let request = getRequest(options); request = setHeaders(request, headers); request = setQueryStrings(request, options.qs); request = setBody(request, options.body); return request .catch(e => { if (e.response && e.response.error) { throw e.response.error; } throw e; }); }; module.exports = Req; ================================================ FILE: lib/kinesis.js ================================================ 'use strict'; const _ = require('lodash'); const AWSXRay = require('aws-xray-sdk'); const AWS = AWSXRay.captureAWS(require('aws-sdk')); const Kinesis = new AWS.Kinesis(); const log = require('./log'); const correlationIds = require('./correlation-ids'); function tryJsonParse(data) { if (!_.isString(data)) { return null; } try { return JSON.parse(data); } catch (err) { log.warn('only JSON string data can be modified to insert correlation IDs'); return null; } } function addCorrelationIds(data) { // only do with with JSON string data const payload = tryJsonParse(data); if (!payload) { return data; } const context = correlationIds.get(); const newData = Object.assign({ __context__: context }, payload); return JSON.stringify(newData); } function putRecord(params, cb) { const newData = addCorrelationIds(params.Data); params = Object.assign({}, params, { Data: newData }); return Kinesis.putRecord(params, cb); }; const client = Object.assign({}, Kinesis, { putRecord }); module.exports = client; ================================================ FILE: lib/log.js ================================================ 'use strict'; const correlationIds = require('./correlation-ids'); const LogLevels = { DEBUG : 0, INFO : 1, WARN : 2, ERROR : 3 }; // most of these are available through the Node.js execution environment for Lambda // see https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html const DEFAULT_CONTEXT = { awsRegion: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION, functionName: process.env.AWS_LAMBDA_FUNCTION_NAME, functionVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION, functionMemorySize: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE, stage: process.env.ENVIRONMENT || process.env.STAGE }; function getContext () { // if there's a global variable for all the current request context then use it const context = correlationIds.get(); if (context) { // note: this is a shallow copy, which is ok as we're not going to mutate anything return Object.assign({}, DEFAULT_CONTEXT, context); } return DEFAULT_CONTEXT; } // default to debug if not specified function logLevelName() { return process.env.log_level || 'DEBUG'; } function isEnabled (level) { return level >= LogLevels[logLevelName()]; } function appendError(params, err) { if (!err) { return params; } return Object.assign( {}, params || {}, { errorName: err.name, errorMessage: err.message, stackTrace: err.stack } ); } function log (levelName, message, params) { if (!isEnabled(LogLevels[levelName])) { return; } let context = getContext(); let logMsg = Object.assign({}, context, params); logMsg.level = levelName; logMsg.message = message; console.log(JSON.stringify(logMsg)); } module.exports.debug = (msg, params) => log('DEBUG', msg, params); module.exports.info = (msg, params) => log('INFO', msg, params); module.exports.warn = (msg, params, error) => log('WARN', msg, appendError(params, error)); module.exports.error = (msg, params, error) => log('ERROR', msg, appendError(params, error)); module.exports.enableDebug = () => { const oldLevel = process.env.log_level; process.env.log_level = 'DEBUG'; // return a function to perform the rollback return () => { process.env.log_level = oldLevel; } }; ================================================ FILE: lib/lru.js ================================================ module.exports = function(size) { return new LruCache(size) } function LruCache(size) { this.capacity = size | 0 this.map = Object.create(null) this.list = new DoublyLinkedList() } LruCache.prototype.get = function(key) { var node = this.map[key] if (node == null) return undefined this.used(node) return node.val } LruCache.prototype.set = function(key, val) { var node = this.map[key] if (node != null) { node.val = val } else { if (!this.capacity) this.prune() if (!this.capacity) return false node = new DoublyLinkedNode(key, val) this.map[key] = node this.capacity-- } this.used(node) return true } LruCache.prototype.used = function(node) { this.list.moveToFront(node) } LruCache.prototype.prune = function() { var node = this.list.pop() if (node != null) { delete this.map[node.key] this.capacity++ } } function DoublyLinkedList() { this.firstNode = null this.lastNode = null } DoublyLinkedList.prototype.moveToFront = function(node) { if (this.firstNode == node) return this.remove(node) if (this.firstNode == null) { this.firstNode = node this.lastNode = node node.prev = null node.next = null } else { node.prev = null node.next = this.firstNode node.next.prev = node this.firstNode = node } } DoublyLinkedList.prototype.pop = function() { var lastNode = this.lastNode if (lastNode != null) { this.remove(lastNode) } return lastNode } DoublyLinkedList.prototype.remove = function(node) { if (this.firstNode == node) { this.firstNode = node.next } else if (node.prev != null) { node.prev.next = node.next } if (this.lastNode == node) { this.lastNode = node.prev } else if (node.next != null) { node.next.prev = node.prev } } function DoublyLinkedNode(key, val) { this.key = key this.val = val this.prev = null this.next = null } ================================================ FILE: lib/notify.js ================================================ 'use strict'; const _ = require('lodash'); const co = require('co'); const sns = require('./sns'); const kinesis = require('./kinesis'); const chance = require('chance').Chance(); const log = require('./log'); const cloudwatch = require('./cloudwatch'); const streamName = process.env.order_events_stream; const restaurantTopicArn = process.env.restaurant_notification_topic; const userTopicArn = process.env.user_notification_topic; let notifyRestaurantOfOrder = co.wrap(function* (order) { try { if (chance.bool({likelihood: 75})) { // 75% chance of failure throw new Error("boom"); } let snsReq = { Message: JSON.stringify(order), TopicArn: restaurantTopicArn }; yield cloudwatch.trackExecTime( "SnsPublishLatency", () => sns.publish(snsReq).promise() ); let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail }; log.debug('notified restaurant of new order', logContext); let data = _.clone(order); data.eventType = 'restaurant_notified'; let kinesisReq = { Data: JSON.stringify(data), // the SDK would base64 encode this for us PartitionKey: order.orderId, StreamName: streamName }; yield cloudwatch.trackExecTime( "KinesisPutRecordLatency", () => kinesis.putRecord(kinesisReq).promise() ); log.debug('published event into Kinesis', { eventName: 'restaurant_notified' }); cloudwatch.incrCount('NotifyRestaurantSuccess'); } catch (err) { cloudwatch.incrCount('NotifyRestaurantFailed'); throw err; } }); let notifyUserOfOrderAccepted = co.wrap(function* (order) { try { if (chance.bool({likelihood: 75})) { // 75% chance of failure throw new Error("boom"); } let snsReq = { Message: JSON.stringify(order), TopicArn: userTopicArn }; yield cloudwatch.trackExecTime( "SnsPublishLatency", () => sns.publish(snsReq).promise() ); let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail }; log.debug('notified user of accepted order', logContext); let data = _.clone(order); data.eventType = 'user_notified'; let kinesisReq = { Data: JSON.stringify(data), // the SDK would base64 encode this for us PartitionKey: order.orderId, StreamName: streamName }; yield cloudwatch.trackExecTime( "KinesisPutRecordLatency", () => kinesis.putRecord(kinesisReq).promise() ); log.debug(`published event into Kinesis`, { eventName: 'user_notified' }); cloudwatch.incrCount('NotifyUserSuccess'); } catch (err) { cloudwatch.incrCount('NotifyUserFailed'); throw err; } }); module.exports = { restaurantOfOrder: notifyRestaurantOfOrder, userOfOrderAccepted: notifyUserOfOrderAccepted }; ================================================ FILE: lib/retry.js ================================================ 'use strict'; const co = require('co'); const sns = require('./sns'); const log = require('./log'); const cloudwatch = require('./cloudwatch'); const restaurantRetryTopicArn = process.env.restaurant_notification_retry_topic; const userRetryTopicArn = process.env.user_notification_retry_topic; let retryRestaurantNotification = co.wrap(function* (order) { let snsReq = { Message: JSON.stringify(order), TopicArn: restaurantRetryTopicArn }; yield cloudwatch.trackExecTime( "SnsPublishLatency", () => sns.publish(snsReq).promise() ); let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail }; log.debug('queued restaurant notification for retry', logContext); cloudwatch.incrCount("NotifyRestaurantQueued"); }); let retryUserNotification = co.wrap(function* (order) { let snsReq = { Message: JSON.stringify(order), TopicArn: userRetryTopicArn }; yield cloudwatch.trackExecTime( "SnsPublishLatency", () => sns.publish(snsReq).promise() ); let logContext = { orderId: order.orderId, restaurantName: order.restaurantName, userEmail: order.userEmail }; log.debug('queued user notification for retry', logContext); cloudwatch.incrCount("NotifyUserQueued"); }); module.exports = { restaurantNotification: retryRestaurantNotification, userNotification: retryUserNotification }; ================================================ FILE: lib/sns.js ================================================ 'use strict'; const AWSXRay = require('aws-xray-sdk'); const AWS = AWSXRay.captureAWS(require('aws-sdk')); const SNS = new AWS.SNS(); const correlationIds = require('./correlation-ids'); function addCorrelationIds(messageAttributes) { let attributes = {}; let context = correlationIds.get(); for (let key in context) { attributes[key] = { DataType: 'String', StringValue: context[key] }; } // use `attribtues` as base so if the user's message attributes would override // our correlation IDs return Object.assign(attributes, messageAttributes || {}); } function publish(params, cb) { const newMessageAttributes = addCorrelationIds(params.MessageAttributes); params = Object.assign(params, { MessageAttributes: newMessageAttributes }); return SNS.publish(params, cb); }; const client = Object.assign({}, SNS, { publish }); module.exports = client; ================================================ FILE: middleware/capture-correlation-ids.js ================================================ 'use strict'; const correlationIds = require('../lib/correlation-ids'); const log = require('../lib/log'); function captureHttp(headers, awsRequestId, sampleDebugLogRate) { if (!headers) { log.warn(`Request ${awsRequestId} is missing headers`); return; } let context = { awsRequestId }; for (const header in headers) { if (header.toLowerCase().startsWith('x-correlation-')) { context[header] = headers[header]; } } if (!context['x-correlation-id']) { context['x-correlation-id'] = awsRequestId; } // forward the original User-Agent on if (headers['User-Agent']) { context['User-Agent'] = headers['User-Agent']; } if (headers['Debug-Log-Enabled']) { context['Debug-Log-Enabled'] = headers['Debug-Log-Enabled']; } else { context['Debug-Log-Enabled'] = Math.random() < sampleDebugLogRate ? 'true' : 'false'; } correlationIds.replaceAllWith(context); } function parsePayload (record) { let json = new Buffer(record.kinesis.data, 'base64').toString('utf8'); return JSON.parse(json); } function captureKinesis(event, context, sampleDebugLogRate) { const awsRequestId = context.awsRequestId; const events = event .Records .map(parsePayload) .map(record => { // the wrapped kinesis client would put the correlation IDs as part of // the payload as a special __context__ property let recordContext = record.__context__ || {}; recordContext.awsRequestId = awsRequestId; delete record.__context__; if (!recordContext['x-correlation-id']) { recordContext['x-correlation-id'] = awsRequestId; } if (!recordContext['Debug-Log-Enabled']) { recordContext['Debug-Log-Enabled'] = Math.random() < sampleDebugLogRate ? 'true' : 'false'; } let debugLog = recordContext['Debug-Log-Enabled'] === 'true'; let oldContext = undefined; let debugLogRollback = undefined; // lets you add more correlation IDs for just this record record.addToScope = (key, value) => { if (!key.startsWith("x-correlation-")) { key = "x-correlation-" + key; } recordContext[key] = value; correlationIds.set(key, value); } record.scopeToThis = () => { if (!oldContext) { oldContext = correlationIds.get(); correlationIds.replaceAllWith(recordContext); } if (debugLog) { debugLogRollback = log.enableDebug(); } }; record.unscope = () => { if (oldContext) { correlationIds.replaceAllWith(oldContext); } if (debugLogRollback) { debugLogRollback(); } } return record; }); context.parsedKinesisEvents = events; correlationIds.replaceAllWith({ awsRequestId }); } function captureSns(records, awsRequestId, sampleDebugLogRate) { let context = { awsRequestId }; const snsRecord = records[0].Sns; const msgAttributes = snsRecord.MessageAttributes; for (var msgAttribute in msgAttributes) { if (msgAttribute.toLowerCase().startsWith('x-correlation-')) { context[msgAttribute] = msgAttributes[msgAttribute].Value; } if (msgAttribute === 'User-Agent') { context['User-Agent'] = msgAttributes['User-Agent'].Value; } if (msgAttribute === 'Debug-Log-Enabled') { context['Debug-Log-Enabled'] = msgAttributes['Debug-Log-Enabled'].Value; } } if (!context['x-correlation-id']) { context['x-correlation-id'] = awsRequestId; } if (!context['Debug-Log-Enabled']) { context['Debug-Log-Enabled'] = Math.random() < sampleDebugLogRate ? 'true' : 'false'; } correlationIds.replaceAllWith(context); } function isApiGatewayEvent(event) { return event.hasOwnProperty('httpMethod') } function isKinesisEvent(event) { if (!event.hasOwnProperty('Records')) { return false; } if (!Array.isArray(event.Records)) { return false; } return event.Records[0].eventSource === 'aws:kinesis'; } function isSnsEvent(event) { if (!event.hasOwnProperty('Records')) { return false; } if (!Array.isArray(event.Records)) { return false; } return event.Records[0].EventSource === 'aws:sns'; } module.exports = (config) => { const sampleDebugLogRate = config.sampleDebugLogRate || 0.01; return { before: (handler, next) => { correlationIds.clearAll(); if (isApiGatewayEvent(handler.event)) { captureHttp(handler.event.headers, handler.context.awsRequestId, sampleDebugLogRate); } else if (isKinesisEvent(handler.event)) { captureKinesis(handler.event, handler.context, sampleDebugLogRate); } else if (isSnsEvent(handler.event)) { captureSns(handler.event.Records, handler.context.awsRequestId, sampleDebugLogRate); } next() } }; }; ================================================ FILE: middleware/flush-metrics.js ================================================ 'use strict'; const log = require('../lib/log'); const cloudwatch = require('../lib/cloudwatch'); module.exports = { after: (handler, next) => { cloudwatch.flush().then(_ => next()); }, onError: (handler, next) => { cloudwatch.flush().then(_ => next(handler.error)); } }; ================================================ FILE: middleware/function-shield.js ================================================ 'use strict'; const FuncShield = require('@puresec/function-shield'); module.exports = () => { return { before: (handler, next) => { FuncShield.configure({ policy: { outbound_connectivity: "block", read_write_tmp: "block", create_child_process: "block" }, token: process.env.FUNCTION_SHIELD_TOKEN }); next(); } }; }; ================================================ FILE: middleware/sample-logging.js ================================================ 'use strict'; const correlationIds = require('../lib/correlation-ids'); const log = require('../lib/log'); // config should be { sampleRate: double } where sampleRate is between 0.0-1.0 module.exports = (config) => { let rollback = undefined; const isDebugEnabled = () => { const context = correlationIds.get(); if (context['Debug-Log-Enabled'] === 'true') { return true; } return config.sampleRate && Math.random() <= config.sampleRate; } return { before: (handler, next) => { if (isDebugEnabled()) { rollback = log.enableDebug(); } next(); }, after: (handler, next) => { if (rollback) { rollback(); } next(); }, onError: (handler, next) => { let awsRequestId = handler.context.awsRequestId; let invocationEvent = JSON.stringify(handler.event); log.error('invocation failed', { awsRequestId, invocationEvent }, handler.error); next(handler.error); } }; }; ================================================ FILE: middleware/wrapper.js ================================================ 'use strict'; const middy = require('middy'); const sampleLogging = require('./sample-logging'); const captureCorrelationIds = require('./capture-correlation-ids'); const functionShield = require('./function-shield'); module.exports = f => { return middy(f) .use(captureCorrelationIds({ sampleDebugLogRate: 0.01 })) .use(sampleLogging({ sampleRate: 0.01 })) .use(functionShield()); }; ================================================ FILE: package.json ================================================ { "name": "big-mouth", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "integration-test": "env TEST_MODE=handler ./node_modules/.bin/mocha tests/test_cases --reporter spec --retries 3 --timeout 10000", "acceptance-test": "env TEST_MODE=http TEST_ROOT=https://i3c5h755j0.execute-api.us-east-1.amazonaws.com/dev ./node_modules/.bin/mocha tests/test_cases --reporter spec --retries 3 --timeout 10000" }, "author": "", "license": "ISC", "dependencies": { "@puresec/function-shield": "^1.0.7", "aws-xray-sdk": "^1.2.0", "bluebird": "^3.5.1", "chance": "^1.0.13", "co": "^4.6.0", "middy": "^0.17.1", "mustache": "^2.3.0", "superagent": "^3.8.1", "superagent-promise": "^1.1.0" }, "devDependencies": { "aws-sdk": "^2.302.0", "chai": "^4.1.2", "cheerio": "^1.0.0-rc.2", "lodash": "^4.17.10", "mocha": "^4.0.1", "serverless": "^1.30.1", "serverless-iam-roles-per-function": "^0.1.5", "serverless-plugin-aws-alerts": "^1.2.4", "serverless-plugin-canary-deployments": "^0.4.3", "serverless-plugin-tracing": "^2.0.0", "serverless-pseudo-parameters": "^1.2.5", "serverless-sam": "0.0.3" } } ================================================ FILE: seed-restaurants.js ================================================ 'use strict'; const co = require('co'); const AWS = require('aws-sdk'); AWS.config.region = 'us-east-1'; const dynamodb = new AWS.DynamoDB.DocumentClient(); let restaurants = [ { name: "Fangtasia", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/fangtasia.png", themes: ["true blood"] }, { name: "Shoney's", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/shoney's.png", themes: ["cartoon", "rick and morty"] }, { name: "Freddy's BBQ Joint", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/freddy's+bbq+joint.png", themes: ["netflix", "house of cards"] }, { name: "Pizza Planet", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/pizza+planet.png", themes: ["netflix", "toy story"] }, { name: "Leaky Cauldron", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/leaky+cauldron.png", themes: ["movie", "harry potter"] }, { name: "Lil' Bits", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/lil+bits.png", themes: ["cartoon", "rick and morty"] }, { name: "Fancy Eats", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/fancy+eats.png", themes: ["cartoon", "rick and morty"] }, { name: "Don Cuco", image: "https://d2qt42rcwzspd6.cloudfront.net/manning/don%20cuco.png", themes: ["cartoon", "rick and morty"] }, ]; let putReqs = restaurants.map(x => ({ PutRequest: { Item: x } })); let req = { RequestItems: { 'restaurants': putReqs } }; dynamodb.batchWrite(req).promise().then(() => console.log("all done")); ================================================ FILE: serverless.yml ================================================ service: big-mouth plugins: - serverless-pseudo-parameters - serverless-sam - serverless-iam-roles-per-function - serverless-plugin-tracing - serverless-plugin-canary-deployments - serverless-plugin-aws-alerts custom: stage: ${opt:stage, self:provider.stage} region: ${opt:region} logLevel: prod: WARN default: DEBUG serverless-iam-roles-per-function: defaultInherit: true alerts: stages: - dev - staging - production dashboards: false alarms: - functionThrottles - functionErrors provider: name: aws runtime: nodejs6.10 tracing: true environment: log_level: ${self:custom.logLevel.${self:custom.stage}, self:custom.logLevel.default} STAGE: ${self:custom.stage} FUNCTION_SHIELD_TOKEN: ${ssm:/bigmouth/${self:custom.stage}/function_shield_token~true} iamRoleStatements: - Effect: Allow Action: cloudwatch:PutMetricData Resource: '*' - Effect: Allow Action: - 'xray:PutTraceSegments' - 'xray:PutTelemetryRecords' Resource: '*' - Effect: Allow Action: codedeploy:* Resource: '*' functions: get-index: handler: functions/get-index.handler events: - http: path: / method: get environment: async_metrics: true deploymentSettings: type: Linear10PercentEvery1Minute alias: Live alarms: - GetDashindexFunctionErrorsAlarm #iamRoleStatementsInherit: true #optionally inherit shared permissions iamRoleStatements: - Effect: Allow Action: execute-api:Invoke Resource: arn:aws:execute-api:#{AWS::Region}:#{AWS::AccountId}:*/*/GET/restaurants - Effect: Allow Action: ssm:GetParameters* Resource: arn:aws:ssm:#{AWS::Region}:#{AWS::AccountId}:parameter/bigmouth/${self:custom.stage}/* - Effect: Allow Action: secretsmanager:GetSecretValue Resource: arn:aws:secretsmanager:#{AWS::Region}:#{AWS::AccountId}:secret:/bigmouth/${self:custom.stage}/* get-restaurants: handler: functions/get-restaurants.handler events: - http: path: /restaurants/ method: get authorizer: aws_iam environment: restaurants_table: restaurants async_metrics: true iamRoleStatements: - Effect: Allow Action: dynamodb:scan Resource: arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants search-restaurants: handler: functions/search-restaurants.handler events: - http: path: /restaurants/search method: post authorizer: arn: arn:aws:cognito-idp:#{AWS::Region}:#{AWS::AccountId}:userpool/us-east-1_DfuAwa0vB environment: restaurants_table: restaurants async_metrics: true iamRoleStatements: - Effect: Allow Action: dynamodb:scan Resource: arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants place-order: handler: functions/place-order.handler events: - http: path: /orders method: post authorizer: arn: arn:aws:cognito-idp:#{AWS::Region}:#{AWS::AccountId}:userpool/us-east-1_DfuAwa0vB environment: order_events_stream: order-events async_metrics: true iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events notify-restaurant: handler: functions/notify-restaurant.handler events: - stream: arn: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events environment: order_events_stream: order-events restaurant_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification restaurant_notification_retry_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification-retry iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events - Effect: Allow Action: sns:Publish Resource: - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification-retry retry-notify-restaurant: handler: functions/retry-notify-restaurant.handler events: - sns: restaurant-notification-retry environment: order_events_stream: order-events restaurant_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification onError: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification-dlq iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events - Effect: Allow Action: sns:Publish Resource: - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification accept-order: handler: functions/accept-order.handler events: - http: path: /orders/accept method: post environment: order_events_stream: order-events async_metrics: true iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events notify-user: handler: functions/notify-user.handler events: - stream: arn: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events environment: order_events_stream: order-events user_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification user_notification_retry_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification-retry iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events - Effect: Allow Action: sns:Publish Resource: - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification-retry retry-notify-user: handler: functions/retry-notify-user.handler events: - sns: user-notification-retry environment: order_events_stream: order-events user_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification onError: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification-dlq iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events - Effect: Allow Action: sns:Publish Resource: - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification fulfill-order: handler: functions/fulfill-order.handler events: - http: path: /orders/complete method: post environment: order_events_stream: order-events async_metrics: true iamRoleStatements: - Effect: Allow Action: kinesis:PutRecord Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events auto-create-api-alarms: handler: functions/create-alarms.handler events: - cloudwatchEvent: event: source: - aws.apigateway detail-type: - AWS API Call via CloudTrail detail: eventSource: - apigateway.amazonaws.com eventName: - CreateDeployment environment: alarm_actions: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:NotifyMe ok_actions: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:NotifyMe iamRoleStatements: - Effect: Allow Action: apigateway:GET Resource: - arn:aws:apigateway:#{AWS::Region}::/restapis/* - arn:aws:apigateway:#{AWS::Region}::/restapis/*/stages/${self:custom.stage} - Effect: Allow Action: apigateway:PATCH Resource: arn:aws:apigateway:#{AWS::Region}::/restapis/*/stages/${self:custom.stage} - Effect: Allow Action: cloudwatch:PutMetricAlarm Resource: "*" resources: Resources: restaurantsTable: Type: AWS::DynamoDB::Table Properties: TableName: restaurants AttributeDefinitions: - AttributeName: name AttributeType: S KeySchema: - AttributeName: name KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 orderEventsStream: Type: AWS::Kinesis::Stream Properties: Name: order-events ShardCount: 1 restaurantNotificationTopic: Type: AWS::SNS::Topic Properties: DisplayName: restaurant-notification TopicName: restaurant-notification userNotificationTopic: Type: AWS::SNS::Topic Properties: DisplayName: user-notification TopicName: user-notification restaurantNotificationDLQTopic: Type: AWS::SNS::Topic Properties: DisplayName: restaurant-notification-dlq TopicName: restaurant-notification-dlq userNotificationDLQTopic: Type: AWS::SNS::Topic Properties: DisplayName: user-notification-dlq TopicName: user-notification-dlq ================================================ FILE: static/index.html ================================================ Big Mouth

You are now registered!

================================================ FILE: template.yml ================================================ AWSTemplateFormatVersion: '2010-09-09' Transform: 'AWS::Serverless-2016-10-31' Description: 'SAM template for Serverless framework service: ' Resources: restaurantsTable: Type: 'AWS::DynamoDB::Table' Properties: TableName: restaurants AttributeDefinitions: - AttributeName: name AttributeType: S KeySchema: - AttributeName: name KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 GetIndex: Type: 'AWS::Serverless::Function' Properties: Handler: functions/get-index.handler Runtime: nodejs6.10 CodeUri: >- /Users/yancui/SourceCode/Personal/manning-aws-lambda-operational-patterns-and-practices/.serverless/big-mouth.zip MemorySize: 128 Timeout: 3 Policies: - Effect: Allow Action: 'dynamodb:scan' Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants' Environment: Variables: restaurants_api: https://i3c5h755j0.execute-api.us-east-1.amazonaws.com/dev/restaurants cognito_user_pool_id: us-east-1_DfuAwa0vB cognito_client_id: 1tf8pb1s1n53p7u608e7ic5ih8 Events: Event1: Type: Api Properties: Path: / Method: get GetRestaurants: Type: 'AWS::Serverless::Function' Properties: Handler: functions/get-restaurants.handler Runtime: nodejs6.10 CodeUri: >- /Users/yancui/SourceCode/Personal/manning-aws-lambda-operational-patterns-and-practices/.serverless/big-mouth.zip MemorySize: 128 Timeout: 3 Policies: - Effect: Allow Action: 'dynamodb:scan' Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants' Environment: Variables: restaurants_table: restaurants Events: Event1: Type: Api Properties: Path: /restaurants/ Method: get Aws_iamResourcePolicy: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' FunctionName: 'Fn::GetAtt': - Aws_iam - Arn Principal: apigateway.amazonaws.com SourceAccount: Ref: 'AWS::AccountId' SearchRestaurants: Type: 'AWS::Serverless::Function' Properties: Handler: functions/search-restaurants.handler Runtime: nodejs6.10 CodeUri: >- /Users/yancui/SourceCode/Personal/manning-aws-lambda-operational-patterns-and-practices/.serverless/big-mouth.zip MemorySize: 128 Timeout: 3 Policies: - Effect: Allow Action: 'dynamodb:scan' Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants' Environment: Variables: restaurants_table: restaurants Events: Event1: Type: Api Properties: Path: /restaurants/search Method: post ================================================ FILE: tests/steps/given.js ================================================ 'use strict'; const AWS = require('aws-sdk'); AWS.config.region = 'us-east-1'; const cognito = new AWS.CognitoIdentityServiceProvider(); const chance = require('chance').Chance(); let random_password = () => { // needs number, special char, upper and lower case return `${chance.string({ length: 8})}B!gM0uth`; } let an_authenticated_user = function* () { let userpoolId = process.env.cognito_user_pool_id; let clientId = process.env.cognito_server_client_id; let firstName = chance.first(); let lastName = chance.last(); let username = `test-${firstName}-${lastName}-${chance.string({length: 8})}`; let password = random_password(); let email = `${firstName}-${lastName}@big-mouth.com`; let createReq = { UserPoolId : userpoolId, Username : username, MessageAction : 'SUPPRESS', TemporaryPassword : password, UserAttributes : [ { Name: "given_name", Value: firstName }, { Name: "family_name", Value: lastName }, { Name: "email", Value: email } ] }; yield cognito.adminCreateUser(createReq).promise(); console.log(`[${username}] - user is created`); let req = { AuthFlow : 'ADMIN_NO_SRP_AUTH', UserPoolId : userpoolId, ClientId : clientId, AuthParameters : { USERNAME: username, PASSWORD: password } }; let resp = yield cognito.adminInitiateAuth(req).promise(); console.log(`[${username}] - initialised auth flow`); let challengeReq = { UserPoolId : userpoolId, ClientId : clientId, ChallengeName : resp.ChallengeName, Session : resp.Session, ChallengeResponses : { USERNAME: username, NEW_PASSWORD: random_password() } }; let challengeResp = yield cognito.adminRespondToAuthChallenge(challengeReq).promise(); console.log(`[${username}] - responded to auth challenge`); return { username, firstName, lastName, idToken: challengeResp.AuthenticationResult.IdToken }; }; module.exports = { an_authenticated_user }; ================================================ FILE: tests/steps/init.js ================================================ 'use strict'; const _ = require('lodash'); const co = require('co'); const Promise = require('bluebird'); const aws4 = require('../../lib/aws4'); const AWS = require('aws-sdk'); AWS.config.region = 'us-east-1'; const SSM = new AWS.SSM(); let initialized = false; const getParameters = co.wrap(function* (keys) { const prefix = '/bigmouth/dev/'; const req = { Names: keys.map(key => `${prefix}${key}`) } const resp = yield SSM.getParameters(req).promise(); return _.reduce(resp.Parameters, function(obj, param) { obj[param.Name.substr(prefix.length)] = param.Value return obj; }, {}) }); let init = co.wrap(function* () { if (initialized) { return; } const params = yield getParameters([ 'cognito_client_id', 'cognito_user_pool_id', 'restaurants_api' ]); process.env.restaurants_api = params.restaurants_api; process.env.restaurants_table = "restaurants"; process.env.AWS_REGION = "us-east-1"; process.env.cognito_client_id = params.cognito_client_id; process.env.cognito_user_pool_id = params.cognito_user_pool_id; process.env.cognito_server_client_id = "niv7esuaibla0tj5q36b6mvnr"; process.env.AWS_XRAY_CONTEXT_MISSING = "LOG_ERROR"; process.env.STAGE = 'dev'; yield aws4.init(); initialized = true; }); module.exports.init = init; ================================================ FILE: tests/steps/tearDown.js ================================================ 'use strict'; const co = require('co'); const AWS = require('aws-sdk'); AWS.config.region = 'us-east-1'; const cognito = new AWS.CognitoIdentityServiceProvider(); let an_authenticated_user = function* (user) { let req = { UserPoolId: process.env.cognito_user_pool_id, Username: user.username }; yield cognito.adminDeleteUser(req).promise(); console.log(`[${user.username}] - user deleted`); }; module.exports = { an_authenticated_user }; ================================================ FILE: tests/steps/when.js ================================================ 'use strict'; const APP_ROOT = '../../'; const _ = require('lodash'); const co = require('co'); const Promise = require("bluebird"); const http = require('superagent-promise')(require('superagent'), Promise); const aws4 = require('../../lib/aws4'); const URL = require('url'); const mode = process.env.TEST_MODE; let respondFrom = function (httpRes) { let contentType = _.get(httpRes, 'headers.content-type', 'application/json'); let body = contentType === 'application/json' ? httpRes.body : httpRes.text; return { statusCode: httpRes.status, body: body, headers: httpRes.headers }; } let signHttpRequest = (url, httpReq) => { let urlData = URL.parse(url); let opts = { host: urlData.hostname, path: urlData.pathname }; aws4.sign(opts); httpReq .set('Host', opts.headers['Host']) .set('X-Amz-Date', opts.headers['X-Amz-Date']) .set('Authorization', opts.headers['Authorization']); if (opts.headers['X-Amz-Security-Token']) { httpReq.set('X-Amz-Security-Token', opts.headers['X-Amz-Security-Token']); } } let viaHttp = co.wrap(function* (relPath, method, opts) { let root = process.env.TEST_ROOT; let url = `${root}/${relPath}`; console.log(`invoking via HTTP ${method} ${url}`); try { let httpReq = http(method, url); let body = _.get(opts, "body"); if (body) { httpReq.send(body); } if (_.get(opts, "iam_auth", false) === true) { signHttpRequest(url, httpReq); } let authHeader = _.get(opts, "auth"); if (authHeader) { httpReq.set('Authorization', authHeader); } let res = yield httpReq; return respondFrom(res); } catch (err) { if (err.status) { return { statusCode: err.status, headers: err.response.headers }; } else { throw err; } } }) let viaHandler = (event, functionName) => { let handler = require(`${APP_ROOT}/functions/${functionName}`).handler; console.log(`invoking via handler function ${functionName}`); return new Promise((resolve, reject) => { let context = {}; let callback = function (err, response) { if (err) { reject(err); } else { let contentType = _.get(response, 'headers.content-type', 'application/json'); if (response.body && contentType === 'application/json') { response.body = JSON.parse(response.body); } resolve(response); } }; handler(event, context, callback); }); } let we_invoke_get_index = co.wrap(function* () { let res = mode === 'handler' ? yield viaHandler({}, 'get-index') : yield viaHttp('', 'GET'); return res; }); let we_invoke_get_restaurants = co.wrap(function* () { let res = mode === 'handler' ? yield viaHandler({}, 'get-restaurants') : yield viaHttp('restaurants', 'GET', { iam_auth: true }); return res; }); let we_invoke_search_restaurants = co.wrap(function* (user, theme) { let body = JSON.stringify({ theme }); let auth = user.idToken; let res = mode === 'handler' ? viaHandler({ body }, 'search-restaurants') : viaHttp('restaurants/search', 'POST', { body, auth }) return res; }); module.exports = { we_invoke_get_index, we_invoke_get_restaurants, we_invoke_search_restaurants }; ================================================ FILE: tests/test_cases/get-index.js ================================================ 'use strict'; const co = require('co'); const expect = require('chai').expect; const when = require('../steps/when'); const init = require('../steps/init').init; const cheerio = require('cheerio'); describe(`When we invoke the GET / endpoint`, co.wrap(function* () { before(co.wrap(function* () { yield init(); })); it(`Should return the index page with 8 restaurants`, co.wrap(function* () { let res = yield when.we_invoke_get_index(); expect(res.statusCode).to.equal(200); expect(res.headers['content-type']).to.equal('text/html; charset=UTF-8'); expect(res.body).to.not.be.null; let $ = cheerio.load(res.body); let restaurants = $('.restaurant', '#restaurantsUl'); expect(restaurants.length).to.equal(8); })); })); ================================================ FILE: tests/test_cases/get-restaurants.js ================================================ 'use strict'; const co = require('co'); const expect = require('chai').expect; const init = require('../steps/init').init; const when = require('../steps/when'); describe(`When we invoke the GET /restaurants endpoint`, co.wrap(function* () { before(co.wrap(function* () { yield init(); })); it(`Should return an array of 8 restaurants`, co.wrap(function* () { let res = yield when.we_invoke_get_restaurants(); expect(res.statusCode).to.equal(200); expect(res.body).to.have.lengthOf(8); for (let restaurant of res.body) { expect(restaurant).to.have.property('name'); expect(restaurant).to.have.property('image'); } })); })); ================================================ FILE: tests/test_cases/search-restaurants.js ================================================ 'use strict'; const co = require('co'); const expect = require('chai').expect; const init = require('../steps/init').init; const when = require('../steps/when'); const given = require('../steps/given'); const tearDown = require('../steps/tearDown'); describe(`Given an authenticated user`, co.wrap(function* () { let user; before(co.wrap(function* () { yield init(); user = yield given.an_authenticated_user(); })); after(co.wrap(function* () { yield tearDown.an_authenticated_user(user); })); describe(`When we invoke the POST /restaurants/search endpoint with theme 'cartoon'`, co.wrap(function* () { it(`Should return an array of 4 restaurants`, co.wrap(function* () { let res = yield when.we_invoke_search_restaurants(user, 'cartoon'); expect(res.statusCode).to.equal(200); expect(res.body).to.have.lengthOf(4); for (let restaurant of res.body) { expect(restaurant).to.have.property('name'); expect(restaurant).to.have.property('image'); } })); })); }));