├── .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-lock.json ├── package.json ├── seed-restaurants.js ├── serverless.yml ├── static ├── amazon-cognito-identity.min.js ├── aws-cognito-sdk.min.js ├── big-mouth.png └── 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 /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to SAM Local", 9 | "type": "node", 10 | "request": "attach", 11 | "address": "localhost", 12 | "port": 5858, 13 | "localRoot": "${workspaceRoot}", 14 | "remoteRoot": "/var/task" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "get-index", 20 | "program": "${workspaceFolder}/node_modules/.bin/sls", 21 | "args": [ 22 | "invoke", 23 | "local", 24 | "-f", 25 | "get-index", 26 | "-p", 27 | "examples/get-index.json" 28 | ], 29 | "env": { 30 | "restaurants_api": "https://8kbasri6v6.execute-api.us-east-1.amazonaws.com/dev/restaurants", 31 | "cognito_user_pool_id": "us-east-1_DfuAwa0vB", 32 | "cognito_client_id": "49lunjf7j7vsgmq9lhtfn4q7ma", 33 | "SLS_DEBUG": "*", 34 | "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" 35 | } 36 | }, 37 | { 38 | "type": "node", 39 | "request": "launch", 40 | "name": "get-restaurants", 41 | "program": "${workspaceFolder}/node_modules/.bin/sls", 42 | "args": [ 43 | "invoke", 44 | "local", 45 | "-f", 46 | "get-restaurants", 47 | "-d", 48 | "{}" 49 | ], 50 | "env": { 51 | "restaurants_table": "restaurants", 52 | "SLS_DEBUG": "*", 53 | "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" 54 | } 55 | }, 56 | { 57 | "type": "node", 58 | "request": "launch", 59 | "name": "search-restaurants", 60 | "program": "${workspaceFolder}/node_modules/.bin/sls", 61 | "args": [ 62 | "invoke", 63 | "local", 64 | "-f", 65 | "search-restaurants", 66 | "-p", 67 | "examples/search-restaurants.json" 68 | ], 69 | "env": { 70 | "restaurants_table": "restaurants", 71 | "SLS_DEBUG": "*", 72 | "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" 73 | } 74 | }, 75 | { 76 | "type": "node", 77 | "request": "launch", 78 | "name": "create-alarm", 79 | "program": "${workspaceFolder}/node_modules/.bin/sls", 80 | "args": [ 81 | "invoke", 82 | "local", 83 | "-f", 84 | "auto-create-api-alarms", 85 | "-p", 86 | "examples/create-alarm.json" 87 | ] 88 | }, 89 | { 90 | "type": "node", 91 | "request": "launch", 92 | "name": "notify-restaurant", 93 | "program": "${workspaceFolder}/node_modules/.bin/sls", 94 | "args": [ 95 | "invoke", 96 | "local", 97 | "-f", 98 | "notify-restaurant", 99 | "-p", 100 | "examples/notify-restaurant.json" 101 | ], 102 | "env": { 103 | "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" 104 | } 105 | }, 106 | { 107 | "type": "node", 108 | "request": "launch", 109 | "name": "place-order", 110 | "program": "${workspaceFolder}/node_modules/.bin/sls", 111 | "args": [ 112 | "invoke", 113 | "local", 114 | "-f", 115 | "place-order", 116 | "-p", 117 | "examples/place-order.json" 118 | ], 119 | "env": { 120 | "AWS_XRAY_CONTEXT_MISSING":"LOG_ERROR" 121 | } 122 | }, 123 | { 124 | "type": "node", 125 | "request": "launch", 126 | "name": "integration tests", 127 | "program": "${workspaceFolder}/node_modules/.bin/mocha", 128 | "env": { 129 | "TEST_MODE": "handler" 130 | }, 131 | "args": [ 132 | "tests/test_cases", 133 | "--reporter", 134 | "spec" 135 | ] 136 | }, 137 | { 138 | "type": "node", 139 | "request": "launch", 140 | "name": "acceptance tests", 141 | "program": "${workspaceFolder}/node_modules/.bin/mocha", 142 | "env": { 143 | "TEST_MODE": "http", 144 | "TEST_ROOT": "https://8kbasri6v6.execute-api.us-east-1.amazonaws.com/dev/" 145 | }, 146 | "args": [ 147 | "tests/test_cases", 148 | "-t", 149 | "2000", 150 | "--reporter", 151 | "spec" 152 | ] 153 | }, 154 | ] 155 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # manning-aws-lambda-operational-patterns-and-practices 2 | Code for the Manning video course "AWS Lambda: Operational Patterns and Practices" 3 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | instruction() 6 | { 7 | echo "usage: ./build.sh deploy " 8 | echo "" 9 | echo "env: eg. int, staging, prod, ..." 10 | echo "" 11 | echo "for example: ./deploy.sh int" 12 | } 13 | 14 | if [ $# -eq 0 ]; then 15 | instruction 16 | exit 1 17 | elif [ "$1" = "int-test" ] && [ $# -eq 1 ]; then 18 | npm install 19 | 20 | npm run integration-test 21 | elif [ "$1" = "acceptance-test" ] && [ $# -eq 1 ]; then 22 | npm install 23 | 24 | npm run acceptance-test 25 | elif [ "$1" = "deploy" ] && [ $# -eq 3 ]; then 26 | STAGE=$2 27 | REGION=$3 28 | 29 | npm install 30 | 'node_modules/.bin/sls' deploy -s $STAGE -r $REGION 31 | else 32 | instruction 33 | exit 1 34 | fi -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | build: 5 | commands: 6 | - chmod +x build.sh 7 | - ./build.sh int-test 8 | - ./build.sh deploy dev 9 | - ./build.sh acceptance-test -------------------------------------------------------------------------------- /examples/create-alarm.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0", 3 | "id": "dee9a69c-8166-1ad7-41d4-1dad201e29f6", 4 | "detail-type": "AWS API Call via CloudTrail", 5 | "source": "aws.apigateway", 6 | "account": "374852340823", 7 | "time": "2018-04-09T00:17:47Z", 8 | "region": "us-east-1", 9 | "resources": [], 10 | "detail": { 11 | "eventVersion": "1.05", 12 | "userIdentity": { 13 | "type": "IAMUser", 14 | "principalId": "AIDAIRMUZZEGPO27IPFYW", 15 | "arn": "arn:aws:iam::374852340823:user/yan.cui", 16 | "accountId": "374852340823", 17 | "accessKeyId": "ASIAJNZDKN26DXPZFYQA", 18 | "userName": "yan.cui", 19 | "sessionContext": { 20 | "attributes": { 21 | "mfaAuthenticated": "false", 22 | "creationDate": "2018-04-09T00:17:30Z" 23 | } 24 | }, 25 | "invokedBy": "cloudformation.amazonaws.com" 26 | }, 27 | "eventTime": "2018-04-09T00:17:47Z", 28 | "eventSource": "apigateway.amazonaws.com", 29 | "eventName": "CreateDeployment", 30 | "awsRegion": "us-east-1", 31 | "sourceIPAddress": "cloudformation.amazonaws.com", 32 | "userAgent": "cloudformation.amazonaws.com", 33 | "requestParameters": { 34 | "restApiId": "8kbasri6v6", 35 | "createDeploymentInput": { 36 | "stageName": "dev" 37 | }, 38 | "template": false 39 | }, 40 | "responseElements": { 41 | "id": "cj2y0f", 42 | "createdDate": "Apr 9, 2018 12:17:47 AM", 43 | "deploymentUpdate": { 44 | "restApiId": "8kbasri6v6", 45 | "deploymentId": "cj2y0f", 46 | "template": false 47 | }, 48 | "deploymentStages": { 49 | "deploymentId": "cj2y0f", 50 | "restApiId": "8kbasri6v6", 51 | "template": false, 52 | "templateSkipList": [ 53 | "position" 54 | ] 55 | }, 56 | "deploymentDelete": { 57 | "deploymentId": "cj2y0f", 58 | "restApiId": "8kbasri6v6", 59 | "template": false 60 | }, 61 | "self": { 62 | "deploymentId": "cj2y0f", 63 | "restApiId": "8kbasri6v6", 64 | "template": false 65 | } 66 | }, 67 | "requestID": "6e25bd56-3b8b-11e8-a351-e5e3d3161fe7", 68 | "eventID": "a150d941-7a54-4572-97b2-0614a81fd25b", 69 | "readOnly": false, 70 | "eventType": "AwsApiCall" 71 | } 72 | } -------------------------------------------------------------------------------- /examples/get-index.json: -------------------------------------------------------------------------------- 1 | { 2 | "body": null, 3 | "resource": "/", 4 | "requestContext": { 5 | "resourceId": "123456", 6 | "apiId": "1234567890", 7 | "resourcePath": "/", 8 | "httpMethod": "POST", 9 | "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", 10 | "accountId": "123456789012", 11 | "identity": { 12 | "apiKey": null, 13 | "userArn": null, 14 | "cognitoAuthenticationType": null, 15 | "caller": null, 16 | "userAgent": "Custom User Agent String", 17 | "user": null, 18 | "cognitoIdentityPoolId": null, 19 | "cognitoIdentityId": null, 20 | "cognitoAuthenticationProvider": null, 21 | "sourceIp": "127.0.0.1", 22 | "accountId": null 23 | }, 24 | "authorizer": { 25 | "claims": { 26 | "sub": "fef3b4c8-cb16-4eb0-9d7c-201ac4cb0905", 27 | "email_verified": "true", 28 | "iss": "https:\/\/cognito-idp.us-east-1.amazonaws.com\/us-east-1_DfuAwa0vB", 29 | "cognito:username": "theburningmonk", 30 | "given_name": "Yan", 31 | "aud": "1tf8pb1s1n53p7u608e7ic5ih8", 32 | "event_id": "e727e68e-1424-11e8-be95-8774da63618a", 33 | "token_use": "id", 34 | "auth_time": "1518986085", 35 | "exp": "Sun Feb 18 21:34:45 UTC 2018", 36 | "iat": "Sun Feb 18 20:34:45 UTC 2018", 37 | "family_name": "Cui", 38 | "email": "theburningmonk@gmail.com" 39 | } 40 | }, 41 | "stage": "prod" 42 | }, 43 | "queryStringParameters": { 44 | "foo": "bar" 45 | }, 46 | "headers": { 47 | "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", 48 | "Accept-Language": "en-US,en;q=0.8", 49 | "CloudFront-Is-Desktop-Viewer": "true", 50 | "CloudFront-Is-SmartTV-Viewer": "false", 51 | "CloudFront-Is-Mobile-Viewer": "false", 52 | "X-Forwarded-For": "127.0.0.1, 127.0.0.2", 53 | "CloudFront-Viewer-Country": "US", 54 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 55 | "Upgrade-Insecure-Requests": "1", 56 | "X-Forwarded-Port": "443", 57 | "Host": "1234567890.execute-api.us-east-1.amazonaws.com", 58 | "X-Forwarded-Proto": "https", 59 | "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", 60 | "CloudFront-Is-Tablet-Viewer": "false", 61 | "Cache-Control": "max-age=0", 62 | "User-Agent": "Custom User Agent String", 63 | "CloudFront-Forwarded-Proto": "https", 64 | "Accept-Encoding": "gzip, deflate, sdch" 65 | }, 66 | "pathParameters": { 67 | }, 68 | "httpMethod": "GET", 69 | "stageVariables": { 70 | "baz": "qux" 71 | }, 72 | "path": "/" 73 | } -------------------------------------------------------------------------------- /examples/notify-restaurant.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records":[ 3 | { 4 | "kinesis": { 5 | "kinesisSchemaVersion":"1.0", 6 | "partitionKey":"e4a5eae6-19f7-5284-9cb5-f97d94518e34", 7 | "sequenceNumber":"49584583598311915935008171659389880270421244691116720130", 8 | "data":"eyJvcmRlcklkIjoiZTRhNWVhZTYtMTlmNy01Mjg0LTljYjUtZjk3ZDk0NTE4ZTM0IiwidXNlckVtYWlsIjoidGhlYnVybmluZ21vbmtAZ21haWwuY29tIiwicmVzdGF1cmFudE5hbWUiOiJGYW5ndGFzaWEiLCJldmVudFR5cGUiOiJvcmRlcl9wbGFjZWQifQ==", 9 | "approximateArrivalTimestamp":1527507873.753 10 | }, 11 | "eventSource":"aws:kinesis", 12 | "eventVersion":"1.0", 13 | "eventID":"shardId-000000000000:49584583598311915935008171659389880270421244691116720130", 14 | "eventName":"aws:kinesis:record", 15 | "invokeIdentityArn":"arn:aws:iam::374852340823:role/big-mouth-dev-notify-restaurant-us-east-1-lambdaRole", 16 | "awsRegion":"us-east-1", 17 | "eventSourceARN":"arn:aws:kinesis:us-east-1:374852340823:stream/order-events" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /examples/place-order.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "\/orders", 3 | "path": "\/orders", 4 | "httpMethod": "POST", 5 | "headers": { 6 | "Accept": "*\/*", 7 | "Accept-Encoding": "gzip, deflate, br", 8 | "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", 9 | "Authorization": "eyJraWQiOiJJTnFEZWM5a2crdGx3WnNISEhabjY3dWRyXC9Hc3BrSXE4QTMwNlwvV054VTg9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJmZWYzYjRjOC1jYjE2LTRlYjAtOWQ3Yy0yMDFhYzRjYjA5MDUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tXC91cy1lYXN0LTFfRGZ1QXdhMHZCIiwiY29nbml0bzp1c2VybmFtZSI6InRoZWJ1cm5pbmdtb25rIiwiZ2l2ZW5fbmFtZSI6IllhbiIsImF1ZCI6IjF0ZjhwYjFzMW41M3A3dTYwOGU3aWM1aWg4IiwiZXZlbnRfaWQiOiJhMTA0YmZmMi02MjBkLTExZTgtODI4My1mNzgxOGJkMzhhNWEiLCJ0b2tlbl91c2UiOiJpZCIsImF1dGhfdGltZSI6MTUyNzQ2NzEzMSwiZXhwIjoxNTI4NjgxNzA4LCJpYXQiOjE1Mjg2NzgxMDgsImZhbWlseV9uYW1lIjoiQ3VpIiwiZW1haWwiOiJ0aGVidXJuaW5nbW9ua0BnbWFpbC5jb20ifQ.ejKf8d6f9jTU72mTmpSkuj_oHIqf_ZA9dUk3_92Y-NvmkEf3AVGJWYdLm2_MoAZwYrzHNQD5cxqIN973pgem73bI9SH0pJqZcbIZ7hS0ieGPkfn_LuMnf4_M3XMPEkGxTcGObl8batYJPeW5ohPE-rfmDjOjUVZxbzemIFY6Pf9ulZTvpVLg6VweR9637GcVktnc8pEKIggG33asqj693MEr_0xX-ioBMWb7NouSD9lz_c-XgiP13LkcNeWuC1DqACRlb8IxB7EzVfFhb-80TTXdYhVKMCnH7e1y5icipdp_lfm1aV9NlhnrWSrVctTWUSnesOmELImFrDQWlH_slg", 10 | "CloudFront-Forwarded-Proto": "https", 11 | "CloudFront-Is-Desktop-Viewer": "true", 12 | "CloudFront-Is-Mobile-Viewer": "false", 13 | "CloudFront-Is-SmartTV-Viewer": "false", 14 | "CloudFront-Is-Tablet-Viewer": "false", 15 | "CloudFront-Viewer-Country": "GB", 16 | "content-type": "application\/json", 17 | "Host": "8kbasri6v6.execute-api.us-east-1.amazonaws.com", 18 | "origin": "https:\/\/8kbasri6v6.execute-api.us-east-1.amazonaws.com", 19 | "Referer": "https:\/\/8kbasri6v6.execute-api.us-east-1.amazonaws.com\/dev\/", 20 | "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", 21 | "Via": "2.0 37b010671d329179b4de819b0a4d4f15.cloudfront.net (CloudFront)", 22 | "X-Amz-Cf-Id": "7cCnqSDjyqrFSTv3lxs-2EjD4gZfu-MowCk4xWkKmqXlDA6RxJZOCQ==", 23 | "X-Amzn-Trace-Id": "Root=1-5b1dc99e-a55a324c20164c503b94e858", 24 | "X-Forwarded-For": "88.98.204.234, 52.46.38.90", 25 | "X-Forwarded-Port": "443", 26 | "X-Forwarded-Proto": "https" 27 | }, 28 | "queryStringParameters": null, 29 | "pathParameters": null, 30 | "stageVariables": null, 31 | "requestContext": { 32 | "resourceId": "7wew6z", 33 | "authorizer": { 34 | "claims": { 35 | "sub": "fef3b4c8-cb16-4eb0-9d7c-201ac4cb0905", 36 | "email_verified": "true", 37 | "iss": "https:\/\/cognito-idp.us-east-1.amazonaws.com\/us-east-1_DfuAwa0vB", 38 | "cognito:username": "theburningmonk", 39 | "given_name": "Yan", 40 | "aud": "1tf8pb1s1n53p7u608e7ic5ih8", 41 | "event_id": "a104bff2-620d-11e8-8283-f7818bd38a5a", 42 | "token_use": "id", 43 | "auth_time": "1527467131", 44 | "exp": "Mon Jun 11 01:48:28 UTC 2018", 45 | "iat": "Mon Jun 11 00:48:28 UTC 2018", 46 | "family_name": "Cui", 47 | "email": "theburningmonk@gmail.com" 48 | } 49 | }, 50 | "resourcePath": "\/orders", 51 | "httpMethod": "POST", 52 | "extendedRequestId": "ISxwwGuhoAMFg-Q=", 53 | "requestTime": "11\/Jun\/2018:01:00:14 +0000", 54 | "path": "\/dev\/orders", 55 | "accountId": "374852340823", 56 | "protocol": "HTTP\/1.1", 57 | "stage": "dev", 58 | "requestTimeEpoch": 1528678814455, 59 | "requestId": "cc9e10de-6d12-11e8-ad56-9338b9713eb7", 60 | "identity": { 61 | "cognitoIdentityPoolId": null, 62 | "accountId": null, 63 | "cognitoIdentityId": null, 64 | "caller": null, 65 | "sourceIp": "88.98.204.234", 66 | "accessKey": null, 67 | "cognitoAuthenticationType": null, 68 | "cognitoAuthenticationProvider": null, 69 | "userArn": null, 70 | "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", 71 | "user": null 72 | }, 73 | "apiId": "8kbasri6v6" 74 | }, 75 | "body": "{\"restaurantName\":\"Fangtasia\"}", 76 | "isBase64Encoded": false 77 | } -------------------------------------------------------------------------------- /examples/retry-notify-restaurant.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "EventVersion": "1.0", 5 | "EventSubscriptionArn": "arn:aws:sns:EXAMPLE", 6 | "EventSource": "aws:sns", 7 | "Sns": { 8 | "SignatureVersion": "1", 9 | "Timestamp": "1970-01-01T00:00:00.000Z", 10 | "Signature": "EXAMPLE", 11 | "SigningCertUrl": "EXAMPLE", 12 | "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", 13 | "Message": "{\"orderId\":\"875ae3c4-c21c-59e6-9057-23694b75f5a2\",\"userEmail\":\"theburningmonk@gmail.com\",\"restaurantName\":\"Fangtasia\"}", 14 | "MessageAttributes": { 15 | }, 16 | "Type": "Notification", 17 | "UnsubscribeUrl": "EXAMPLE", 18 | "TopicArn": "arn:aws:sns:EXAMPLE", 19 | "Subject": "TestInvoke" 20 | } 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /examples/search-restaurants.json: -------------------------------------------------------------------------------- 1 | { 2 | "body": "{\"theme\":\"cartoon\"}", 3 | "resource": "/{proxy+}", 4 | "requestContext": { 5 | "resourceId": "123456", 6 | "apiId": "1234567890", 7 | "resourcePath": "/{proxy+}", 8 | "httpMethod": "POST", 9 | "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", 10 | "accountId": "123456789012", 11 | "identity": { 12 | "apiKey": null, 13 | "userArn": null, 14 | "cognitoAuthenticationType": null, 15 | "caller": null, 16 | "userAgent": "Custom User Agent String", 17 | "user": null, 18 | "cognitoIdentityPoolId": null, 19 | "cognitoIdentityId": null, 20 | "cognitoAuthenticationProvider": null, 21 | "sourceIp": "127.0.0.1", 22 | "accountId": null 23 | }, 24 | "authorizer": { 25 | "claims": { 26 | "sub": "fef3b4c8-cb16-4eb0-9d7c-201ac4cb0905", 27 | "email_verified": "true", 28 | "iss": "https:\/\/cognito-idp.us-east-1.amazonaws.com\/us-east-1_DfuAwa0vB", 29 | "cognito:username": "theburningmonk", 30 | "given_name": "Yan", 31 | "aud": "1tf8pb1s1n53p7u608e7ic5ih8", 32 | "event_id": "e727e68e-1424-11e8-be95-8774da63618a", 33 | "token_use": "id", 34 | "auth_time": "1518986085", 35 | "exp": "Sun Feb 18 21:34:45 UTC 2018", 36 | "iat": "Sun Feb 18 20:34:45 UTC 2018", 37 | "family_name": "Cui", 38 | "email": "theburningmonk@gmail.com" 39 | } 40 | }, 41 | "stage": "prod" 42 | }, 43 | "queryStringParameters": { 44 | "foo": "bar" 45 | }, 46 | "headers": { 47 | "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", 48 | "Accept-Language": "en-US,en;q=0.8", 49 | "CloudFront-Is-Desktop-Viewer": "true", 50 | "CloudFront-Is-SmartTV-Viewer": "false", 51 | "CloudFront-Is-Mobile-Viewer": "false", 52 | "X-Forwarded-For": "127.0.0.1, 127.0.0.2", 53 | "CloudFront-Viewer-Country": "US", 54 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 55 | "Upgrade-Insecure-Requests": "1", 56 | "X-Forwarded-Port": "443", 57 | "Host": "1234567890.execute-api.us-east-1.amazonaws.com", 58 | "X-Forwarded-Proto": "https", 59 | "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", 60 | "CloudFront-Is-Tablet-Viewer": "false", 61 | "Cache-Control": "max-age=0", 62 | "User-Agent": "Custom User Agent String", 63 | "CloudFront-Forwarded-Proto": "https", 64 | "Accept-Encoding": "gzip, deflate, sdch" 65 | }, 66 | "pathParameters": { 67 | "proxy": "path/to/resource" 68 | }, 69 | "httpMethod": "POST", 70 | "stageVariables": { 71 | "baz": "qux" 72 | }, 73 | "path": "/path/to/resource" 74 | } -------------------------------------------------------------------------------- /functions/accept-order.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const kinesis = require('../lib/kinesis'); 5 | const log = require('../lib/log'); 6 | const cloudwatch = require('../lib/cloudwatch'); 7 | const wrapper = require('../middleware/wrapper'); 8 | 9 | const streamName = process.env.order_events_stream; 10 | 11 | const handler = co.wrap(function* (event, context, cb) { 12 | let req = JSON.parse(event.body); 13 | log.debug(`request body is valid JSON`, { requestBody: event.body }); 14 | 15 | let restaurantName = req.restaurantName; 16 | let orderId = req.orderId; 17 | let userEmail = req.userEmail; 18 | 19 | correlationIds.set('order-id', orderId); 20 | correlationIds.set('restaurant-name', restaurantName); 21 | correlationIds.set('user-email', userEmail); 22 | 23 | log.debug('restaurant accepted order', { orderId, restaurantName, userEmail }); 24 | 25 | let data = { 26 | orderId, 27 | userEmail, 28 | restaurantName, 29 | eventType: 'order_accepted' 30 | } 31 | 32 | let kinesisReq = { 33 | Data: JSON.stringify(data), // the SDK would base64 encode this for us 34 | PartitionKey: orderId, 35 | StreamName: streamName 36 | }; 37 | 38 | yield cloudwatch.trackExecTime( 39 | "KinesisPutRecordLatency", 40 | () => kinesis.putRecord(kinesisReq).promise() 41 | ); 42 | 43 | log.debug(`published event into Kinesis`, { eventName: 'order_accepted' }); 44 | 45 | let response = { 46 | statusCode: 200, 47 | body: JSON.stringify({ orderId }) 48 | } 49 | 50 | cb(null, response); 51 | }); 52 | 53 | module.exports.handler = wrapper(handler); -------------------------------------------------------------------------------- /functions/create-alarms.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const co = require('co'); 5 | const AWS = require('aws-sdk'); 6 | const apigateway = new AWS.APIGateway(); 7 | const cloudwatch = new AWS.CloudWatch(); 8 | const log = require('../lib/log'); 9 | 10 | const alarmActions = (process.env.alarm_actions || '').split(','); 11 | const okAction = (process.env.ok_actions || '').split(','); 12 | 13 | let enableDetailedMetrics = co.wrap(function* (restApiId, stageName) { 14 | let getResp = yield apigateway.getStage({ restApiId, stageName }).promise(); 15 | log.debug('get stage settings', getResp.methodSettings); 16 | 17 | let isDetailedMetricsEnabled = _.get(getResp, 'methodSettings.*/*.metricsEnabled', false); 18 | if (isDetailedMetricsEnabled) { 19 | log.debug('detailed metrics already enabled', { restApiId, stageName }); 20 | } else { 21 | let updateReq = { 22 | restApiId, 23 | stageName, 24 | patchOperations: [ 25 | { 26 | path: "/*/*/metrics/enabled", 27 | value: "true", 28 | op: "replace" 29 | } 30 | ] 31 | }; 32 | yield apigateway.updateStage(updateReq).promise(); 33 | log.debug('enabled detailed metrics', { restApiId, stageName }); 34 | } 35 | }); 36 | 37 | let getRestEndpoints = co.wrap(function* (restApiId) { 38 | let resp = yield apigateway.getResources({ restApiId }).promise(); 39 | log.debug('got REST resources', { restApiId }); 40 | 41 | let resourceMethods = resp.items.map(x => { 42 | let methods = _.keys(x.resourceMethods); 43 | return methods.map(method => ({ resource: x.path, method })); 44 | }); 45 | 46 | return _.flattenDeep(resourceMethods); 47 | }); 48 | 49 | let getRestApiName = co.wrap(function* (restApiId) { 50 | let resp = yield apigateway.getRestApi({ restApiId }).promise(); 51 | log.debug('got REST api', { restApiId }); 52 | 53 | return resp.name; 54 | }); 55 | 56 | let createAlarmsForEndpoints = co.wrap(function* (restApiId, stageName) { 57 | let apiName = yield getRestApiName(restApiId); 58 | log.debug(`API name is ${apiName}`, { restApiId, stageName }); 59 | 60 | let restEndpoints = yield getRestEndpoints(restApiId); 61 | log.debug('got REST endpoints', { restApiId, stageName, restEndpoints }); 62 | 63 | for (let endpoint of restEndpoints) { 64 | let putReq = { 65 | AlarmName: `API [${apiName}] stage [${stageName}] ${endpoint.method} ${endpoint.resource} : p99 > 1s`, 66 | MetricName: 'Latency', 67 | Dimensions: [ 68 | { Name: 'ApiName', Value: apiName }, 69 | { Name: 'Resource', Value: endpoint.resource }, 70 | { Name: 'Method', Value: endpoint.method }, 71 | { Name: 'Stage', Value: stageName } 72 | ], 73 | Namespace: 'AWS/ApiGateway', 74 | Threshold: 1000, // 1s 75 | ComparisonOperator: 'GreaterThanThreshold', 76 | Period: 60, // per min 77 | EvaluationPeriods: 5, 78 | DatapointsToAlarm: 5, // 5 consecutive mins to trigger alarm 79 | ExtendedStatistic: 'p99', 80 | ActionsEnabled: true, 81 | AlarmActions: alarmActions, 82 | AlarmDescription: `auto-generated by Lambda [${process.env.AWS_LAMBDA_FUNCTION_NAME}]`, 83 | OKActions: okAction, 84 | Unit: 'Milliseconds' 85 | }; 86 | yield cloudwatch.putMetricAlarm(putReq).promise(); 87 | } 88 | 89 | log.debug('auto-created latency ALARMS for REST endpoints', { restApiId, stageName, restEndpoints }); 90 | }); 91 | 92 | module.exports.handler = co.wrap(function* (event, context, cb) { 93 | let restApiId = event.detail.requestParameters.restApiId; 94 | let stageName = event.detail.requestParameters.createDeploymentInput.stageName; 95 | 96 | yield enableDetailedMetrics(restApiId, stageName); 97 | 98 | yield createAlarmsForEndpoints(restApiId, stageName); 99 | 100 | cb(null, 'ok'); 101 | }); -------------------------------------------------------------------------------- /functions/fulfill-order.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const kinesis = require('../lib/kinesis'); 5 | const log = require('../lib/log'); 6 | const cloudwatch = require('../lib/cloudwatch'); 7 | const wrapper = require('../middleware/wrapper'); 8 | 9 | const streamName = process.env.order_events_stream; 10 | 11 | const handler = co.wrap(function* (event, context, cb) { 12 | let body = JSON.parse(event.body); 13 | log.debug(`request body is valid JSON`, { requestBody: event.body }); 14 | 15 | let restaurantName = body.restaurantName; 16 | let orderId = body.orderId; 17 | let userEmail = body.userEmail; 18 | 19 | correlationIds.set('order-id', orderId); 20 | correlationIds.set('restaurant-name', restaurantName); 21 | correlationIds.set('user-email', userEmail); 22 | 23 | log.debug('restaurant has fulfilled order', { orderId, restaurantName, userEmail }); 24 | 25 | let data = { 26 | orderId, 27 | userEmail, 28 | restaurantName, 29 | eventType: 'order_fulfilled' 30 | } 31 | 32 | let kinesisReq = { 33 | Data: JSON.stringify(data), // the SDK would base64 encode this for us 34 | PartitionKey: orderId, 35 | StreamName: streamName 36 | }; 37 | 38 | yield cloudwatch.trackExecTime( 39 | "KinesisPutRecordLatency", 40 | () => kinesis.putRecord(kinesisReq).promise() 41 | ); 42 | 43 | log.debug(`published event into Kinesis`, { eventName: 'order_fulfilled' }); 44 | 45 | let response = { 46 | statusCode: 200, 47 | body: JSON.stringify({ orderId }) 48 | } 49 | 50 | cb(null, response); 51 | }); 52 | 53 | module.exports.handler = wrapper(handler); -------------------------------------------------------------------------------- /functions/get-index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require("co"); 4 | const Promise = require("bluebird"); 5 | const fs = Promise.promisifyAll(require("fs")); 6 | const Mustache = require('mustache'); 7 | const http = require('../lib/http'); 8 | const URL = require('url'); 9 | const aws4 = require('../lib/aws4'); 10 | const log = require('../lib/log'); 11 | const cloudwatch = require('../lib/cloudwatch'); 12 | const AWSXRay = require('aws-xray-sdk'); 13 | const wrapper = require('../middleware/wrapper'); 14 | const { ssm, secretsManager } = require('middy/middlewares'); 15 | 16 | const STAGE = process.env.STAGE; 17 | const awsRegion = process.env.AWS_REGION; 18 | 19 | const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 20 | 21 | var html; 22 | 23 | function* loadHtml() { 24 | if (!html) { 25 | html = yield fs.readFileAsync('static/index.html', 'utf-8'); 26 | } 27 | 28 | return html; 29 | } 30 | 31 | function* getRestaurants(restaurantsApiUrl) { 32 | let url = URL.parse(restaurantsApiUrl); 33 | let opts = { 34 | host: url.hostname, 35 | path: url.pathname 36 | }; 37 | 38 | aws4.sign(opts); 39 | 40 | let httpReq = http({ 41 | uri: restaurantsApiUrl, 42 | headers: opts.headers 43 | }); 44 | 45 | return new Promise((resolve, reject) => { 46 | let f = co.wrap(function* (subsegment) { 47 | if (subsegment) { 48 | subsegment.addMetadata('url', restaurantsApiUrl); 49 | } 50 | 51 | try { 52 | let body = (yield httpReq).body; 53 | if (subsegment) { 54 | subsegment.close(); 55 | } 56 | resolve(body); 57 | } catch (err) { 58 | if (subsegment) { 59 | subsegment.close(err); 60 | } 61 | reject(err); 62 | } 63 | }); 64 | 65 | // the current sub/segment 66 | let segment = AWSXRay.getSegment(); 67 | 68 | AWSXRay.captureAsyncFunc("getting restaurants", f, segment); 69 | }); 70 | } 71 | 72 | const handler = co.wrap(function* (event, context, callback) { 73 | yield aws4.init(); 74 | 75 | let template = yield loadHtml(); 76 | log.debug("loaded HTML template"); 77 | 78 | let restaurants = yield cloudwatch.trackExecTime( 79 | "GetRestaurantsLatency", 80 | () => getRestaurants(context.restaurants_api) 81 | ); 82 | log.debug(`loaded ${restaurants.length} restaurants`); 83 | 84 | let dayOfWeek = days[new Date().getDay()]; 85 | let view = { 86 | dayOfWeek, 87 | restaurants, 88 | awsRegion, 89 | cognitoUserPoolId: context.cognito.user_pool_id, 90 | cognitoClientId: context.cognito.client_id, 91 | searchUrl: `${context.restaurants_api}/search`, 92 | placeOrderUrl: `${context.orders_api}` 93 | }; 94 | let html = Mustache.render(template, view); 95 | log.debug(`rendered HTML [${html.length} bytes]`); 96 | 97 | // uncomment this to cause function to err 98 | // yield http({ uri: 'https://theburningmonk.com' }); 99 | 100 | cloudwatch.incrCount('RestaurantsReturned', restaurants.length); 101 | 102 | const response = { 103 | statusCode: 200, 104 | body: html, 105 | headers: { 106 | 'content-type': 'text/html; charset=UTF-8' 107 | } 108 | }; 109 | 110 | callback(null, response); 111 | }); 112 | 113 | module.exports.handler = wrapper(handler) 114 | .use(ssm({ 115 | cache: true, 116 | cacheExpiryInMillis: 3 * 60 * 1000, // 3 mins 117 | setToContext: true, 118 | names: { 119 | restaurants_api: `/bigmouth/${STAGE}/restaurants_api`, 120 | orders_api: `/bigmouth/${STAGE}/orders_api` 121 | } 122 | })) 123 | .use(secretsManager({ 124 | cache: true, 125 | cacheExpiryInMillis: 3 * 60 * 1000, // 3 mins 126 | secrets: { 127 | cognito: `/bigmouth/${STAGE}/cognito` 128 | } 129 | })); -------------------------------------------------------------------------------- /functions/get-restaurants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const AWSXRay = require('aws-xray-sdk'); 5 | const AWS = AWSXRay.captureAWS(require('aws-sdk')); 6 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 7 | const log = require('../lib/log'); 8 | const cloudwatch = require('../lib/cloudwatch'); 9 | const wrapper = require('../middleware/wrapper'); 10 | 11 | const defaultResults = process.env.defaultResults || 8; 12 | const tableName = process.env.restaurants_table; 13 | 14 | function* getRestaurants(count) { 15 | let req = { 16 | TableName: tableName, 17 | Limit: count 18 | }; 19 | 20 | let resp = yield cloudwatch.trackExecTime( 21 | "DynamoDBScanLatency", 22 | () => dynamodb.scan(req).promise() 23 | ); 24 | return resp.Items; 25 | } 26 | 27 | const handler = co.wrap(function* (event, context, cb) { 28 | let restaurants = yield getRestaurants(defaultResults); 29 | log.debug(`loaded ${restaurants.length} restaurants`); 30 | 31 | cloudwatch.incrCount("RestaurantsReturned", restaurants.length); 32 | 33 | let response = { 34 | statusCode: 200, 35 | body: JSON.stringify(restaurants) 36 | } 37 | 38 | cb(null, response); 39 | }); 40 | 41 | module.exports.handler = wrapper(handler); -------------------------------------------------------------------------------- /functions/notify-restaurant.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const notify = require('../lib/notify'); 5 | const retry = require('../lib/retry'); 6 | const log = require('../lib/log'); 7 | const wrapper = require('../middleware/wrapper'); 8 | const flushMetrics = require('../middleware/flush-metrics'); 9 | 10 | const handler = co.wrap(function* (event, context, cb) { 11 | let events = context.parsedKinesisEvents; 12 | let orderPlaced = events.filter(r => r.eventType === 'order_placed'); 13 | log.debug(`found ${orderPlaced.length} 'order_placed' events`); 14 | 15 | for (let order of orderPlaced) { 16 | order.scopeToThis(); 17 | 18 | try { 19 | yield notify.restaurantOfOrder(order); 20 | } catch (err) { 21 | yield retry.restaurantNotification(order); 22 | 23 | let logContext = { 24 | orderId: order.orderId, 25 | restaurantName: order.restaurantName, 26 | userEmail: order.userEmail 27 | }; 28 | log.warn('failed to notify restaurant of new order', logContext, err); 29 | } 30 | 31 | order.unscope(); 32 | } 33 | 34 | cb(null, "all done"); 35 | }); 36 | 37 | module.exports.handler = wrapper(handler) 38 | .use(flushMetrics); -------------------------------------------------------------------------------- /functions/notify-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const notify = require('../lib/notify'); 5 | const retry = require('../lib/retry'); 6 | const log = require('../lib/log'); 7 | const wrapper = require('../middleware/wrapper'); 8 | 9 | const flushMetrics = require('../middleware/flush-metrics'); 10 | 11 | const handler = co.wrap(function* (event, context, cb) { 12 | let events = context.parsedKinesisEvents; 13 | let orderAccepted = events.filter(r => r.eventType === 'order_accepted'); 14 | log.debug(`found ${orderAccepted.length} 'order_accepted' events`); 15 | 16 | for (let order of orderAccepted) { 17 | order.scopeToThis(); 18 | 19 | try { 20 | yield notify.userOfOrderAccepted(order); 21 | } catch (err) { 22 | yield retry.userNotification(order); 23 | 24 | let logContext = { 25 | orderId: order.orderId, 26 | restaurantName: order.restaurantName, 27 | userEmail: order.userEmail 28 | }; 29 | log.warn('failed to notify user of accepted order', logContext, err); 30 | } 31 | 32 | order.unscope(); 33 | } 34 | 35 | cb(null, "all done"); 36 | }); 37 | 38 | module.exports.handler = wrapper(handler) 39 | .use(flushMetrics); -------------------------------------------------------------------------------- /functions/place-order.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const co = require('co'); 5 | const kinesis = require('../lib/kinesis'); 6 | const chance = require('chance').Chance(); 7 | const log = require('../lib/log'); 8 | const cloudwatch = require('../lib/cloudwatch'); 9 | const correlationIds = require('../lib/correlation-ids'); 10 | const wrapper = require('../middleware/wrapper'); 11 | 12 | const streamName = process.env.order_events_stream; 13 | 14 | const UNAUTHORIZED = { 15 | statusCode: 401, 16 | body: "unauthorized" 17 | } 18 | 19 | const handler = co.wrap(function* (event, context, cb) { 20 | let req = JSON.parse(event.body); 21 | log.debug(`request body is valid JSON`, { requestBody: event.body }); 22 | 23 | let userEmail = _.get(event, 'requestContext.authorizer.claims.email'); 24 | if (!userEmail) { 25 | cb(null, UNAUTHORIZED); 26 | log.error('unauthorized request, user email is not provided'); 27 | 28 | return; 29 | } 30 | 31 | let restaurantName = req.restaurantName; 32 | let orderId = chance.guid(); 33 | 34 | correlationIds.set('order-id', orderId); 35 | correlationIds.set('restaurant-name', restaurantName); 36 | correlationIds.set('user-email', userEmail); 37 | 38 | log.debug(`placing order...`, { orderId, restaurantName, userEmail }); 39 | 40 | let data = { 41 | orderId, 42 | userEmail, 43 | restaurantName, 44 | eventType: 'order_placed' 45 | } 46 | 47 | let kinesisReq = { 48 | Data: JSON.stringify(data), // the SDK would base64 encode this for us 49 | PartitionKey: orderId, 50 | StreamName: streamName 51 | }; 52 | 53 | yield cloudwatch.trackExecTime( 54 | "KinesisPutRecordLatency", 55 | () => kinesis.putRecord(kinesisReq).promise() 56 | ); 57 | 58 | log.debug(`published event into Kinesis`, { eventName: 'order_placed' }); 59 | 60 | let response = { 61 | statusCode: 200, 62 | body: JSON.stringify({ orderId }) 63 | } 64 | 65 | cb(null, response); 66 | }); 67 | 68 | module.exports.handler = wrapper(handler); -------------------------------------------------------------------------------- /functions/retry-notify-restaurant.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const notify = require('../lib/notify'); 5 | const log = require('../lib/log'); 6 | const cloudwatch = require('../lib/cloudwatch'); 7 | const wrapper = require('../middleware/wrapper'); 8 | 9 | const flushMetrics = require('../middleware/flush-metrics'); 10 | 11 | const handler = co.wrap(function* (event, context, cb) { 12 | let order = JSON.parse(event.Records[0].Sns.Message); 13 | order.retried = true; 14 | 15 | let logContext = { 16 | orderId: order.orderId, 17 | restaurantName: order.restaurantName, 18 | userEmail: order.userEmail, 19 | retry: true 20 | }; 21 | 22 | try { 23 | yield notify.restaurantOfOrder(order); 24 | cb(null, "all done"); 25 | } catch (err) { 26 | log.warn('failed to notify restaurant of new order', logContext, err); 27 | 28 | cb(err); 29 | } finally { 30 | cloudwatch.incrCount("NotifyRestaurantRetried"); 31 | } 32 | }); 33 | 34 | module.exports.handler = wrapper(handler) 35 | .use(flushMetrics); -------------------------------------------------------------------------------- /functions/retry-notify-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const notify = require('../lib/notify'); 5 | const log = require('../lib/log'); 6 | const cloudwatch = require('../lib/cloudwatch'); 7 | const wrapper = require('../middleware/wrapper'); 8 | 9 | const flushMetrics = require('../middleware/flush-metrics'); 10 | 11 | const handler = co.wrap(function* (event, context, cb) { 12 | let order = JSON.parse(event.Records[0].Sns.Message); 13 | order.retried = true; 14 | 15 | let logContext = { 16 | orderId: order.orderId, 17 | restaurantName: order.restaurantName, 18 | userEmail: order.userEmail, 19 | retry: true 20 | }; 21 | 22 | try { 23 | yield notify.userOfOrderAccepted(order); 24 | cb(null, "all done"); 25 | } catch (err) { 26 | log.warn('failed to notify user of accepted order', logContext, err); 27 | 28 | cb(err); 29 | } finally { 30 | cloudwatch.incrCount("NotifyUserRetried"); 31 | } 32 | }); 33 | 34 | module.exports.handler = wrapper(handler) 35 | .use(flushMetrics); -------------------------------------------------------------------------------- /functions/search-restaurants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const AWSXRay = require('aws-xray-sdk'); 5 | const AWS = AWSXRay.captureAWS(require('aws-sdk')); 6 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 7 | const log = require('../lib/log'); 8 | const cloudwatch = require('../lib/cloudwatch'); 9 | const wrapper = require('../middleware/wrapper'); 10 | 11 | const defaultResults = process.env.defaultResults || 8; 12 | const tableName = process.env.restaurants_table; 13 | 14 | function* findRestaurantsByTheme(theme, count) { 15 | let req = { 16 | TableName: tableName, 17 | Limit: count, 18 | FilterExpression: "contains(themes, :theme)", 19 | ExpressionAttributeValues: { ":theme": theme } 20 | }; 21 | 22 | let resp = yield cloudwatch.trackExecTime( 23 | "DynamoDBScanLatency", 24 | () => dynamodb.scan(req).promise() 25 | ); 26 | return resp.Items; 27 | } 28 | 29 | const handler = co.wrap(function* (event, context, cb) { 30 | let req = JSON.parse(event.body); 31 | log.debug(`request body is valid JSON`, { requestBody: event.body }); 32 | 33 | let restaurants = yield findRestaurantsByTheme(req.theme, defaultResults); 34 | log.debug(`found ${restaurants.length} restaurants`); 35 | 36 | cloudwatch.incrCount("RestaurantsFound", restaurants.length); 37 | 38 | let response = { 39 | statusCode: 200, 40 | body: JSON.stringify(restaurants) 41 | } 42 | 43 | cb(null, response); 44 | }); 45 | 46 | module.exports.handler = wrapper(handler); -------------------------------------------------------------------------------- /lib/aws4.js: -------------------------------------------------------------------------------- 1 | var aws4 = exports, 2 | url = require('url'), 3 | querystring = require('querystring'), 4 | crypto = require('crypto'), 5 | lru = require('./lru'), 6 | credentialsCache = lru(1000), 7 | awscred = require('./awscred') 8 | 9 | // http://docs.amazonwebservices.com/general/latest/gr/signature-version-4.html 10 | 11 | function hmac(key, string, encoding) { 12 | return crypto.createHmac('sha256', key).update(string, 'utf8').digest(encoding) 13 | } 14 | 15 | function hash(string, encoding) { 16 | return crypto.createHash('sha256').update(string, 'utf8').digest(encoding) 17 | } 18 | 19 | // This function assumes the string has already been percent encoded 20 | function encodeRfc3986(urlEncodedString) { 21 | return urlEncodedString.replace(/[!'()*]/g, function(c) { 22 | return '%' + c.charCodeAt(0).toString(16).toUpperCase() 23 | }) 24 | } 25 | 26 | // request: { path | body, [host], [method], [headers], [service], [region] } 27 | // credentials: { accessKeyId, secretAccessKey, [sessionToken] } 28 | function RequestSigner(request, credentials) { 29 | 30 | if (typeof request === 'string') request = url.parse(request) 31 | 32 | var headers = request.headers = (request.headers || {}), 33 | hostParts = this.matchHost(request.hostname || request.host || headers.Host || headers.host) 34 | 35 | this.request = request 36 | this.credentials = credentials || this.defaultCredentials() 37 | 38 | this.service = request.service || hostParts[0] || '' 39 | this.region = request.region || hostParts[1] || 'us-east-1' 40 | 41 | // SES uses a different domain from the service name 42 | if (this.service === 'email') this.service = 'ses' 43 | 44 | if (!request.method && request.body) 45 | request.method = 'POST' 46 | 47 | if (!headers.Host && !headers.host) { 48 | headers.Host = request.hostname || request.host || this.createHost() 49 | 50 | // If a port is specified explicitly, use it as is 51 | if (request.port) 52 | headers.Host += ':' + request.port 53 | } 54 | if (!request.hostname && !request.host) 55 | request.hostname = headers.Host || headers.host 56 | 57 | this.isCodeCommitGit = this.service === 'codecommit' && request.method === 'GIT' 58 | } 59 | 60 | RequestSigner.prototype.matchHost = function(host) { 61 | var match = (host || '').match(/([^\.]+)\.(?:([^\.]*)\.)?amazonaws\.com$/) 62 | var hostParts = (match || []).slice(1, 3) 63 | 64 | // ES's hostParts are sometimes the other way round, if the value that is expected 65 | // to be region equals ‘es’ switch them back 66 | // e.g. search-cluster-name-aaaa00aaaa0aaa0aaaaaaa0aaa.us-east-1.es.amazonaws.com 67 | if (hostParts[1] === 'es') 68 | hostParts = hostParts.reverse() 69 | 70 | return hostParts 71 | } 72 | 73 | // http://docs.aws.amazon.com/general/latest/gr/rande.html 74 | RequestSigner.prototype.isSingleRegion = function() { 75 | // Special case for S3 and SimpleDB in us-east-1 76 | if (['s3', 'sdb'].indexOf(this.service) >= 0 && this.region === 'us-east-1') return true 77 | 78 | return ['cloudfront', 'ls', 'route53', 'iam', 'importexport', 'sts'] 79 | .indexOf(this.service) >= 0 80 | } 81 | 82 | RequestSigner.prototype.createHost = function() { 83 | var region = this.isSingleRegion() ? '' : 84 | (this.service === 's3' && this.region !== 'us-east-1' ? '-' : '.') + this.region, 85 | service = this.service === 'ses' ? 'email' : this.service 86 | return service + region + '.amazonaws.com' 87 | } 88 | 89 | RequestSigner.prototype.prepareRequest = function() { 90 | this.parsePath() 91 | 92 | var request = this.request, headers = request.headers, query 93 | 94 | if (request.signQuery) { 95 | 96 | this.parsedPath.query = query = this.parsedPath.query || {} 97 | 98 | if (this.credentials.sessionToken) 99 | query['X-Amz-Security-Token'] = this.credentials.sessionToken 100 | 101 | if (this.service === 's3' && !query['X-Amz-Expires']) 102 | query['X-Amz-Expires'] = 86400 103 | 104 | if (query['X-Amz-Date']) 105 | this.datetime = query['X-Amz-Date'] 106 | else 107 | query['X-Amz-Date'] = this.getDateTime() 108 | 109 | query['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256' 110 | query['X-Amz-Credential'] = this.credentials.accessKeyId + '/' + this.credentialString() 111 | query['X-Amz-SignedHeaders'] = this.signedHeaders() 112 | 113 | } else { 114 | 115 | if (!request.doNotModifyHeaders && !this.isCodeCommitGit) { 116 | if (request.body && !headers['Content-Type'] && !headers['content-type']) 117 | headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8' 118 | 119 | if (request.body && !headers['Content-Length'] && !headers['content-length']) 120 | headers['Content-Length'] = Buffer.byteLength(request.body) 121 | 122 | if (this.credentials.sessionToken && !headers['X-Amz-Security-Token'] && !headers['x-amz-security-token']) 123 | headers['X-Amz-Security-Token'] = this.credentials.sessionToken 124 | 125 | if (this.service === 's3' && !headers['X-Amz-Content-Sha256'] && !headers['x-amz-content-sha256']) 126 | headers['X-Amz-Content-Sha256'] = hash(this.request.body || '', 'hex') 127 | 128 | if (headers['X-Amz-Date'] || headers['x-amz-date']) 129 | this.datetime = headers['X-Amz-Date'] || headers['x-amz-date'] 130 | else 131 | headers['X-Amz-Date'] = this.getDateTime() 132 | } 133 | 134 | delete headers.Authorization 135 | delete headers.authorization 136 | } 137 | } 138 | 139 | RequestSigner.prototype.sign = function() { 140 | if (!this.parsedPath) this.prepareRequest() 141 | 142 | if (this.request.signQuery) { 143 | this.parsedPath.query['X-Amz-Signature'] = this.signature() 144 | } else { 145 | this.request.headers.Authorization = this.authHeader() 146 | } 147 | 148 | this.request.path = this.formatPath() 149 | 150 | return this.request 151 | } 152 | 153 | RequestSigner.prototype.getDateTime = function() { 154 | if (!this.datetime) { 155 | var headers = this.request.headers, 156 | date = new Date(headers.Date || headers.date || new Date) 157 | 158 | this.datetime = date.toISOString().replace(/[:\-]|\.\d{3}/g, '') 159 | 160 | // Remove the trailing 'Z' on the timestamp string for CodeCommit git access 161 | if (this.isCodeCommitGit) this.datetime = this.datetime.slice(0, -1) 162 | } 163 | return this.datetime 164 | } 165 | 166 | RequestSigner.prototype.getDate = function() { 167 | return this.getDateTime().substr(0, 8) 168 | } 169 | 170 | RequestSigner.prototype.authHeader = function() { 171 | return [ 172 | 'AWS4-HMAC-SHA256 Credential=' + this.credentials.accessKeyId + '/' + this.credentialString(), 173 | 'SignedHeaders=' + this.signedHeaders(), 174 | 'Signature=' + this.signature(), 175 | ].join(', ') 176 | } 177 | 178 | RequestSigner.prototype.signature = function() { 179 | var date = this.getDate(), 180 | cacheKey = [this.credentials.secretAccessKey, date, this.region, this.service].join(), 181 | kDate, kRegion, kService, kCredentials = credentialsCache.get(cacheKey) 182 | if (!kCredentials) { 183 | kDate = hmac('AWS4' + this.credentials.secretAccessKey, date) 184 | kRegion = hmac(kDate, this.region) 185 | kService = hmac(kRegion, this.service) 186 | kCredentials = hmac(kService, 'aws4_request') 187 | credentialsCache.set(cacheKey, kCredentials) 188 | } 189 | return hmac(kCredentials, this.stringToSign(), 'hex') 190 | } 191 | 192 | RequestSigner.prototype.stringToSign = function() { 193 | return [ 194 | 'AWS4-HMAC-SHA256', 195 | this.getDateTime(), 196 | this.credentialString(), 197 | hash(this.canonicalString(), 'hex'), 198 | ].join('\n') 199 | } 200 | 201 | RequestSigner.prototype.canonicalString = function() { 202 | if (!this.parsedPath) this.prepareRequest() 203 | 204 | var pathStr = this.parsedPath.path, 205 | query = this.parsedPath.query, 206 | headers = this.request.headers, 207 | queryStr = '', 208 | normalizePath = this.service !== 's3', 209 | decodePath = this.service === 's3' || this.request.doNotEncodePath, 210 | decodeSlashesInPath = this.service === 's3', 211 | firstValOnly = this.service === 's3', 212 | bodyHash 213 | 214 | if (this.service === 's3' && this.request.signQuery) { 215 | bodyHash = 'UNSIGNED-PAYLOAD' 216 | } else if (this.isCodeCommitGit) { 217 | bodyHash = '' 218 | } else { 219 | bodyHash = headers['X-Amz-Content-Sha256'] || headers['x-amz-content-sha256'] || 220 | hash(this.request.body || '', 'hex') 221 | } 222 | 223 | if (query) { 224 | queryStr = encodeRfc3986(querystring.stringify(Object.keys(query).sort().reduce(function(obj, key) { 225 | if (!key) return obj 226 | obj[key] = !Array.isArray(query[key]) ? query[key] : 227 | (firstValOnly ? query[key][0] : query[key].slice().sort()) 228 | return obj 229 | }, {}))) 230 | } 231 | if (pathStr !== '/') { 232 | if (normalizePath) pathStr = pathStr.replace(/\/{2,}/g, '/') 233 | pathStr = pathStr.split('/').reduce(function(path, piece) { 234 | if (normalizePath && piece === '..') { 235 | path.pop() 236 | } else if (!normalizePath || piece !== '.') { 237 | if (decodePath) piece = querystring.unescape(piece) 238 | path.push(encodeRfc3986(querystring.escape(piece))) 239 | } 240 | return path 241 | }, []).join('/') 242 | if (pathStr[0] !== '/') pathStr = '/' + pathStr 243 | if (decodeSlashesInPath) pathStr = pathStr.replace(/%2F/g, '/') 244 | } 245 | 246 | return [ 247 | this.request.method || 'GET', 248 | pathStr, 249 | queryStr, 250 | this.canonicalHeaders() + '\n', 251 | this.signedHeaders(), 252 | bodyHash, 253 | ].join('\n') 254 | } 255 | 256 | RequestSigner.prototype.canonicalHeaders = function() { 257 | var headers = this.request.headers 258 | function trimAll(header) { 259 | return header.toString().trim().replace(/\s+/g, ' ') 260 | } 261 | return Object.keys(headers) 262 | .sort(function(a, b) { return a.toLowerCase() < b.toLowerCase() ? -1 : 1 }) 263 | .map(function(key) { return key.toLowerCase() + ':' + trimAll(headers[key]) }) 264 | .join('\n') 265 | } 266 | 267 | RequestSigner.prototype.signedHeaders = function() { 268 | return Object.keys(this.request.headers) 269 | .map(function(key) { return key.toLowerCase() }) 270 | .sort() 271 | .join(';') 272 | } 273 | 274 | RequestSigner.prototype.credentialString = function() { 275 | return [ 276 | this.getDate(), 277 | this.region, 278 | this.service, 279 | 'aws4_request', 280 | ].join('/') 281 | } 282 | 283 | RequestSigner.prototype.defaultCredentials = function() { 284 | var env = process.env 285 | 286 | return { 287 | accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, 288 | secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, 289 | sessionToken: env.AWS_SESSION_TOKEN, 290 | } 291 | } 292 | 293 | RequestSigner.prototype.parsePath = function() { 294 | var path = this.request.path || '/', 295 | queryIx = path.indexOf('?'), 296 | query = null 297 | 298 | if (queryIx >= 0) { 299 | query = querystring.parse(path.slice(queryIx + 1)) 300 | path = path.slice(0, queryIx) 301 | } 302 | 303 | // S3 doesn't always encode characters > 127 correctly and 304 | // all services don't encode characters > 255 correctly 305 | // So if there are non-reserved chars (and it's not already all % encoded), just encode them all 306 | if (/[^0-9A-Za-z!'()*\-._~%/]/.test(path)) { 307 | path = path.split('/').map(function(piece) { 308 | return querystring.escape(querystring.unescape(piece)) 309 | }).join('/') 310 | } 311 | 312 | this.parsedPath = { 313 | path: path, 314 | query: query, 315 | } 316 | } 317 | 318 | RequestSigner.prototype.formatPath = function() { 319 | var path = this.parsedPath.path, 320 | query = this.parsedPath.query 321 | 322 | if (!query) return path 323 | 324 | // Services don't support empty query string keys 325 | if (query[''] != null) delete query[''] 326 | 327 | return path + '?' + encodeRfc3986(querystring.stringify(query)) 328 | } 329 | 330 | aws4.RequestSigner = RequestSigner 331 | 332 | aws4.sign = function(request, credentials) { 333 | return new RequestSigner(request, credentials).sign() 334 | } 335 | 336 | var isInitialized = false; 337 | aws4.init = function() { 338 | return new Promise(function(resolve, reject) { 339 | if (isInitialized) { 340 | return resolve() 341 | } 342 | 343 | if (process.env.AWS_ACCESS_KEY_ID) { 344 | isInitialized = true; 345 | return resolve() 346 | } else { 347 | console.error("initializing AWS credentials...") 348 | 349 | awscred.load(function(error, result) { 350 | if (error) { 351 | reject(error) 352 | } else { 353 | let cred = result.credentials; 354 | process.env.AWS_ACCESS_KEY_ID = cred.accessKeyId; 355 | process.env.AWS_SECRET_ACCESS_KEY = cred.secretAccessKey; 356 | 357 | if (cred.sessionToken) { 358 | process.env.AWS_SESSION_TOKEN = cred.sessionToken; 359 | } 360 | 361 | resolve() 362 | isInitialized = true 363 | } 364 | }) 365 | } 366 | }) 367 | } -------------------------------------------------------------------------------- /lib/awscred.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | path = require('path'), 3 | http = require('http'), 4 | env = process.env 5 | 6 | exports.credentialsCallChain = [ 7 | loadCredentialsFromEnv, 8 | loadCredentialsFromIniFile, 9 | loadCredentialsFromEc2Metadata, 10 | loadCredentialsFromEcs, 11 | ] 12 | 13 | exports.regionCallChain = [ 14 | loadRegionFromEnv, 15 | loadRegionFromIniFile, 16 | ] 17 | 18 | exports.load = exports.loadCredentialsAndRegion = loadCredentialsAndRegion 19 | exports.loadCredentials = loadCredentials 20 | exports.loadRegion = loadRegion 21 | exports.loadRegionSync = loadRegionSync 22 | exports.loadCredentialsFromEnv = loadCredentialsFromEnv 23 | exports.loadRegionFromEnv = loadRegionFromEnv 24 | exports.loadRegionFromEnvSync = loadRegionFromEnvSync 25 | exports.loadCredentialsFromIniFile = loadCredentialsFromIniFile 26 | exports.loadRegionFromIniFile = loadRegionFromIniFile 27 | exports.loadRegionFromIniFileSync = loadRegionFromIniFileSync 28 | exports.loadCredentialsFromEc2Metadata = loadCredentialsFromEc2Metadata 29 | exports.loadProfileFromIniFile = loadProfileFromIniFile 30 | exports.loadProfileFromIniFileSync = loadProfileFromIniFileSync 31 | exports.merge = merge 32 | 33 | function loadCredentialsAndRegion(options, cb) { 34 | if (!cb) { cb = options; options = {} } 35 | cb = once(cb) 36 | 37 | var out = {}, callsRemaining = 2 38 | 39 | function checkDone(propName) { 40 | return function(err, data) { 41 | if (err) return cb(err) 42 | out[propName] = data 43 | if (!--callsRemaining) return cb(null, out) 44 | } 45 | } 46 | 47 | loadCredentials(options, checkDone('credentials')) 48 | 49 | loadRegion(options, checkDone('region')) 50 | } 51 | 52 | function loadCredentials(options, cb) { 53 | if (!cb) { cb = options; options = {} } 54 | var credentialsCallChain = options.credentialsCallChain || exports.credentialsCallChain 55 | 56 | function nextCall(i) { 57 | credentialsCallChain[i](options, function(err, credentials) { 58 | if (err) return cb(err) 59 | 60 | if (credentials.accessKeyId && credentials.secretAccessKey) 61 | return cb(null, credentials) 62 | 63 | if (i >= credentialsCallChain.length - 1) 64 | return cb(null, {}) 65 | 66 | nextCall(i + 1) 67 | }) 68 | } 69 | nextCall(0) 70 | } 71 | 72 | function loadRegion(options, cb) { 73 | if (!cb) { cb = options; options = {} } 74 | var regionCallChain = options.regionCallChain || exports.regionCallChain 75 | 76 | function nextCall(i) { 77 | regionCallChain[i](options, function(err, region) { 78 | if (err) return cb(err) 79 | 80 | if (region) 81 | return cb(null, region) 82 | 83 | if (i >= regionCallChain.length - 1) 84 | return cb(null, 'us-east-1') 85 | 86 | nextCall(i + 1) 87 | }) 88 | } 89 | nextCall(0) 90 | } 91 | 92 | function loadRegionSync(options) { 93 | return loadRegionFromEnvSync(options) || loadRegionFromIniFileSync(options) 94 | } 95 | 96 | function loadCredentialsFromEnv(options, cb) { 97 | if (!cb) { cb = options; options = {} } 98 | 99 | cb(null, { 100 | accessKeyId: env.AWS_ACCESS_KEY_ID || env.AMAZON_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, 101 | secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AMAZON_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, 102 | sessionToken: env.AWS_SESSION_TOKEN || env.AMAZON_SESSION_TOKEN, 103 | }) 104 | } 105 | 106 | function loadRegionFromEnv(options, cb) { 107 | if (!cb) { cb = options; options = {} } 108 | 109 | cb(null, loadRegionFromEnvSync()) 110 | } 111 | 112 | function loadRegionFromEnvSync() { 113 | return env.AWS_REGION || env.AMAZON_REGION || env.AWS_DEFAULT_REGION 114 | } 115 | 116 | function loadCredentialsFromIniFile(options, cb) { 117 | if (!cb) { cb = options; options = {} } 118 | 119 | loadProfileFromIniFile(options, 'credentials', function(err, profile) { 120 | if (err) return cb(err) 121 | cb(null, { 122 | accessKeyId: profile.aws_access_key_id, 123 | secretAccessKey: profile.aws_secret_access_key, 124 | sessionToken: profile.aws_session_token, 125 | }) 126 | }) 127 | } 128 | 129 | function loadRegionFromIniFile(options, cb) { 130 | if (!cb) { cb = options; options = {} } 131 | 132 | loadProfileFromIniFile(options, 'config', function(err, profile) { 133 | if (err) return cb(err) 134 | cb(null, profile.region) 135 | }) 136 | } 137 | 138 | function loadRegionFromIniFileSync(options) { 139 | return loadProfileFromIniFileSync(options || {}, 'config').region 140 | } 141 | 142 | var TIMEOUT_CODES = ['ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'Unknown system errno 64'] 143 | var ec2Callbacks = [] 144 | var ecsCallbacks = [] 145 | 146 | function loadCredentialsFromEcs(options, cb) { 147 | if (!cb) { cb = options; options = {} } 148 | 149 | ecsCallbacks.push(cb) 150 | if (ecsCallbacks.length > 1) return // only want one caller at a time 151 | 152 | cb = function(err, credentials) { 153 | ecsCallbacks.forEach(function(cb) { cb(err, credentials) }) 154 | ecsCallbacks = [] 155 | } 156 | 157 | if (options.timeout == null) options.timeout = 5000 158 | options.host = '169.254.170.2' 159 | options.path = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI 160 | 161 | return request(options, function(err, res, data) { 162 | if (err && ~TIMEOUT_CODES.indexOf(err.code)) return cb(null, {}) 163 | if (err) return cb(err) 164 | 165 | if (res.statusCode != 200) 166 | return cb(new Error('Failed to fetch IAM role: ' + res.statusCode + ' ' + data)) 167 | 168 | try { data = JSON.parse(data) } catch (e) { } 169 | 170 | if (res.statusCode != 200) 171 | return cb(new Error('Failed to fetch IAM credentials: ' + res.statusCode + ' ' + data)) 172 | 173 | cb(null, { 174 | accessKeyId: data.AccessKeyId, 175 | secretAccessKey: data.SecretAccessKey, 176 | sessionToken: data.Token, 177 | expiration: new Date(data.Expiration), 178 | }) 179 | }) 180 | } 181 | 182 | function loadCredentialsFromEc2Metadata(options, cb) { 183 | if (!cb) { cb = options; options = {} } 184 | 185 | ec2Callbacks.push(cb) 186 | if (ec2Callbacks.length > 1) return // only want one caller at a time 187 | 188 | cb = function(err, credentials) { 189 | ec2Callbacks.forEach(function(cb) { cb(err, credentials) }) 190 | ec2Callbacks = [] 191 | } 192 | 193 | if (options.timeout == null) options.timeout = 5000 194 | options.host = '169.254.169.254' 195 | options.path = '/latest/meta-data/iam/security-credentials/' 196 | 197 | return request(options, function(err, res, data) { 198 | if (err && ~TIMEOUT_CODES.indexOf(err.code)) return cb(null, {}) 199 | if (err) return cb(err) 200 | 201 | if (res.statusCode != 200) 202 | return cb(new Error('Failed to fetch IAM role: ' + res.statusCode + ' ' + data)) 203 | 204 | options.path += data.split('\n')[0] 205 | request(options, function(err, res, data) { 206 | if (err) return cb(err) 207 | 208 | try { data = JSON.parse(data) } catch (e) { } 209 | 210 | if (res.statusCode != 200 || data.Code != 'Success') 211 | return cb(new Error('Failed to fetch IAM credentials: ' + res.statusCode + ' ' + data)) 212 | 213 | cb(null, { 214 | accessKeyId: data.AccessKeyId, 215 | secretAccessKey: data.SecretAccessKey, 216 | sessionToken: data.Token, 217 | expiration: new Date(data.Expiration), 218 | }) 219 | }) 220 | }) 221 | } 222 | 223 | function loadProfileFromIniFile(options, defaultFilename, cb) { 224 | var filename = options.filename || path.join(resolveHome(), '.aws', defaultFilename), 225 | profile = options.profile || resolveProfile() 226 | 227 | fs.readFile(filename, 'utf8', function(err, data) { 228 | if (err && err.code == 'ENOENT') return cb(null, {}) 229 | if (err) return cb(err) 230 | cb(null, parseAwsIni(data)[profile] || {}) 231 | }) 232 | } 233 | 234 | function loadProfileFromIniFileSync(options, defaultFilename) { 235 | var filename = options.filename || path.join(resolveHome(), '.aws', defaultFilename), 236 | profile = options.profile || resolveProfile(), 237 | data 238 | 239 | try { 240 | data = fs.readFileSync(filename, 'utf8') 241 | } catch (err) { 242 | if (err.code == 'ENOENT') return {} 243 | throw err 244 | } 245 | 246 | return parseAwsIni(data)[profile] || {} 247 | } 248 | 249 | function merge(obj, options, cb) { 250 | if (!cb) { cb = options; options = {} } 251 | 252 | var needRegion = !obj.region 253 | var needCreds = !obj.credentials || !obj.credentials.accessKeyId || !obj.credentials.secretAccessKey 254 | 255 | function loadCreds(cb) { 256 | if (needRegion && needCreds) { 257 | return loadCredentialsAndRegion(options, cb) 258 | } else if (needRegion) { 259 | return loadRegion(options, function(err, region) { cb(err, {region: region}) }) 260 | } else if (needCreds) { 261 | return loadCredentials(options, function(err, credentials) { cb(err, {credentials: credentials}) }) 262 | } 263 | cb(null, {}) 264 | } 265 | 266 | loadCreds(function(err, creds) { 267 | if (err) return cb(err) 268 | 269 | if (creds.region) obj.region = creds.region 270 | if (creds.credentials) { 271 | if (!obj.credentials) { 272 | obj.credentials = creds.credentials 273 | } else { 274 | Object.keys(creds.credentials).forEach(function(key) { 275 | if (!obj.credentials[key]) obj.credentials[key] = creds.credentials[key] 276 | }) 277 | } 278 | } 279 | 280 | cb() 281 | }) 282 | } 283 | 284 | function resolveProfile() { 285 | return env.AWS_PROFILE || env.AMAZON_PROFILE || 'default' 286 | } 287 | 288 | function resolveHome() { 289 | return env.HOME || env.USERPROFILE || ((env.HOMEDRIVE || 'C:') + env.HOMEPATH) 290 | } 291 | 292 | // Fairly strict INI parser – will only deal with alpha keys, must be within sections 293 | function parseAwsIni(ini) { 294 | var section, 295 | out = Object.create(null), 296 | re = /^\[([^\]]+)\]\s*$|^([a-z_]+)\s*=\s*(.+?)\s*$/, 297 | lines = ini.split(/\r?\n/) 298 | 299 | lines.forEach(function(line) { 300 | var match = line.match(re) 301 | if (!match) return 302 | if (match[1]) { 303 | section = match[1] 304 | if (out[section] == null) out[section] = Object.create(null) 305 | } else if (section) { 306 | out[section][match[2]] = match[3] 307 | } 308 | }) 309 | 310 | return out 311 | } 312 | 313 | function request(options, cb) { 314 | cb = once(cb) 315 | 316 | var req = http.request(options, function(res) { 317 | var data = '' 318 | res.setEncoding('utf8') 319 | res.on('error', cb) 320 | res.on('data', function(chunk) { data += chunk }) 321 | res.on('end', function() { cb(null, res, data) }) 322 | }).on('error', cb) 323 | 324 | if (options.timeout != null) { 325 | req.setTimeout(options.timeout) 326 | req.on('timeout', function() { req.abort() }) 327 | } 328 | 329 | req.end() 330 | } 331 | 332 | function once(cb) { 333 | var called = false 334 | return function() { 335 | if (called) return 336 | called = true 337 | cb.apply(this, arguments) 338 | } 339 | } -------------------------------------------------------------------------------- /lib/cloudwatch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const AWSXRay = require('aws-xray-sdk'); 5 | const AWS = AWSXRay.captureAWS(require('aws-sdk')); 6 | const log = require('./log'); 7 | const cloudwatch = new AWS.CloudWatch(); 8 | 9 | const namespace = 'big-mouth'; 10 | const async = (process.env.async_metrics || 'false') === 'true'; 11 | 12 | // the Lambda execution environment defines a number of env variables: 13 | // https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html 14 | // and the serverless framework also defines a STAGE env variable too 15 | const dimensions = 16 | [ 17 | { Name: 'Function', Value: process.env.AWS_LAMBDA_FUNCTION_NAME }, 18 | { Name: 'Version', Value: process.env.AWS_LAMBDA_FUNCTION_VERSION }, 19 | { Name: 'Stage', Value: process.env.STAGE } 20 | ] 21 | .filter(dim => dim.Value); 22 | 23 | let countMetrics = {}; 24 | let timeMetrics = {}; 25 | 26 | function getCountMetricData(name, value) { 27 | return { 28 | MetricName : name, 29 | Dimensions : dimensions, 30 | Unit : 'Count', 31 | Value : value 32 | }; 33 | } 34 | 35 | function getTimeMetricData(name, statsValues) { 36 | return { 37 | MetricName : name, 38 | Dimensions : dimensions, 39 | Unit : 'Milliseconds', 40 | StatisticValues : statsValues 41 | }; 42 | } 43 | 44 | function getCountMetricDatum() { 45 | let keys = Object.keys(countMetrics); 46 | if (keys.length === 0) { 47 | return []; 48 | } 49 | 50 | let metricDatum = keys.map(key => getCountMetricData(key, countMetrics[key])); 51 | countMetrics = {}; // zero out the recorded count metrics 52 | return metricDatum; 53 | } 54 | 55 | function getTimeMetricDatum() { 56 | let keys = Object.keys(timeMetrics); 57 | if (keys.length === 0) { 58 | return []; 59 | } 60 | 61 | let metricDatum = keys.map(key => getTimeMetricData(key, timeMetrics[key])); 62 | timeMetrics = {}; // zero out the recorded time metrics 63 | return metricDatum; 64 | } 65 | 66 | let flush = co.wrap(function* () { 67 | let countDatum = getCountMetricDatum(); 68 | let timeDatum = getTimeMetricDatum(); 69 | let allDatum = countDatum.concat(timeDatum); 70 | 71 | if (allDatum.length == 0) { return; } 72 | 73 | let metricNames = allDatum.map(x => x.MetricName).join(','); 74 | log.debug(`flushing [${allDatum.length}] metrics to CloudWatch: ${metricNames}`); 75 | 76 | var params = { 77 | MetricData: allDatum, 78 | Namespace: namespace 79 | }; 80 | 81 | try { 82 | yield cloudwatch.putMetricData(params).promise(); 83 | log.debug(`flushed [${allDatum.length}] metrics to CloudWatch: ${metricNames}`); 84 | } catch (err) { 85 | log.warn(`cloudn't flush [${allDatum.length}] CloudWatch metrics`, null, err); 86 | } 87 | }); 88 | 89 | function clear() { 90 | countMetrics = {}; 91 | timeMetrics = {}; 92 | } 93 | 94 | function incrCount(metricName, count) { 95 | count = count || 1; 96 | 97 | if (async) { 98 | console.log(`MONITORING|${count}|count|${metricName}|${namespace}`); 99 | } else { 100 | if (countMetrics[metricName]) { 101 | countMetrics[metricName] += count; 102 | } else { 103 | countMetrics[metricName] = count; 104 | } 105 | } 106 | } 107 | 108 | function recordTimeInMillis(metricName, ms) { 109 | if (!ms) { 110 | return; 111 | } 112 | 113 | log.debug(`new execution time for [${metricName}] : ${ms} milliseconds`); 114 | 115 | if (async) { 116 | console.log(`MONITORING|${ms}|milliseconds|${metricName}|${namespace}`); 117 | } else { 118 | if (timeMetrics[metricName]) { 119 | let metric = timeMetrics[metricName]; 120 | metric.Sum += ms; 121 | metric.Maximum = Math.max(metric.Maximum, ms); 122 | metric.Minimum = Math.min(metric.Minimum, ms); 123 | metric.SampleCount += 1; 124 | } else { 125 | let statsValues = { 126 | Maximum : ms, 127 | Minimum : ms, 128 | SampleCount : 1, 129 | Sum : ms 130 | }; 131 | timeMetrics[metricName] = statsValues; 132 | } 133 | } 134 | } 135 | 136 | function trackExecTime(metricName, f) { 137 | if (!f || typeof f !== "function") { 138 | throw new Error('cloudWatch.trackExecTime requires a function, eg. () => 42'); 139 | } 140 | 141 | if (!metricName) { 142 | throw new Error('cloudWatch.trackExecTime requires a metric name, eg. "CloudSearch-latency"'); 143 | } 144 | 145 | let start = new Date().getTime(), end; 146 | let res = f(); 147 | 148 | // anything with a 'then' function can be considered a Promise... 149 | // http://stackoverflow.com/a/27746324/55074 150 | if (!res.hasOwnProperty('then')) { 151 | end = new Date().getTime(); 152 | recordTimeInMillis(metricName, end-start); 153 | return res; 154 | } else { 155 | return res.then(x => { 156 | end = new Date().getTime(); 157 | recordTimeInMillis(metricName, end-start); 158 | return x; 159 | }); 160 | } 161 | } 162 | 163 | module.exports = { 164 | flush, 165 | clear, 166 | incrCount, 167 | trackExecTime, 168 | recordTimeInMillis 169 | }; -------------------------------------------------------------------------------- /lib/correlation-ids.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let clearAll = () => global.CONTEXT = undefined; 4 | 5 | let replaceAllWith = ctx => global.CONTEXT = ctx; 6 | 7 | let set = (key, value) => { 8 | if (!key.startsWith("x-correlation-")) { 9 | key = "x-correlation-" + key; 10 | } 11 | 12 | if (!global.CONTEXT) { 13 | global.CONTEXT = {}; 14 | } 15 | 16 | global.CONTEXT[key] = value; 17 | }; 18 | 19 | let get = () => global.CONTEXT || {}; 20 | 21 | module.exports = { 22 | clearAll: clearAll, 23 | replaceAllWith: replaceAllWith, 24 | set: set, 25 | get: get 26 | }; -------------------------------------------------------------------------------- /lib/http.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const correlationIds = require('./correlation-ids'); 4 | const http = require('superagent-promise')(require('superagent'), Promise); 5 | 6 | function getRequest (options) { 7 | let uri = options.uri; 8 | let method = options.method || ''; 9 | 10 | switch (method.toLowerCase()) { 11 | case '': 12 | case 'get': 13 | return http.get(uri); 14 | case 'head': 15 | return http.head(uri); 16 | case 'post': 17 | return http.post(uri); 18 | case 'put': 19 | return http.put(uri); 20 | case 'delete': 21 | return http.del(uri); 22 | default: 23 | throw new Error(`unsupported method : ${method.toLowerCase()}`); 24 | } 25 | } 26 | 27 | function setHeaders (request, headers) { 28 | let headerNames = Object.keys(headers); 29 | headerNames.forEach(h => request = request.set(h, headers[h])); 30 | 31 | return request; 32 | } 33 | 34 | function setQueryStrings (request, qs) { 35 | if (!qs) { 36 | return request; 37 | } 38 | 39 | return request.query(qs); 40 | } 41 | 42 | function setBody (request, body) { 43 | if (!body) { 44 | return request; 45 | } 46 | 47 | return request.send(body); 48 | } 49 | 50 | // options: { 51 | // uri : string 52 | // method : GET (default) | POST | PUT | HEAD 53 | // headers : object 54 | // qs : object 55 | // body : object 56 | // } 57 | let Req = (options) => { 58 | if (!options) { 59 | throw new Error('no HTTP request options is provided'); 60 | } 61 | 62 | if (!options.uri) { 63 | throw new Error('no HTTP uri is specified'); 64 | } 65 | 66 | const context = correlationIds.get(); 67 | 68 | // copy the provided headers last so it overrides the values from the context 69 | let headers = Object.assign({}, context, options.headers); 70 | 71 | let request = getRequest(options); 72 | 73 | request = setHeaders(request, headers); 74 | request = setQueryStrings(request, options.qs); 75 | request = setBody(request, options.body); 76 | 77 | return request 78 | .catch(e => { 79 | if (e.response && e.response.error) { 80 | throw e.response.error; 81 | } 82 | 83 | throw e; 84 | }); 85 | }; 86 | 87 | module.exports = Req; -------------------------------------------------------------------------------- /lib/kinesis.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const AWSXRay = require('aws-xray-sdk'); 5 | const AWS = AWSXRay.captureAWS(require('aws-sdk')); 6 | const Kinesis = new AWS.Kinesis(); 7 | const log = require('./log'); 8 | const correlationIds = require('./correlation-ids'); 9 | 10 | function tryJsonParse(data) { 11 | if (!_.isString(data)) { 12 | return null; 13 | } 14 | 15 | try { 16 | return JSON.parse(data); 17 | } catch (err) { 18 | log.warn('only JSON string data can be modified to insert correlation IDs'); 19 | return null; 20 | } 21 | } 22 | 23 | function addCorrelationIds(data) { 24 | // only do with with JSON string data 25 | const payload = tryJsonParse(data); 26 | if (!payload) { 27 | return data; 28 | } 29 | 30 | const context = correlationIds.get(); 31 | const newData = Object.assign({ __context__: context }, payload); 32 | return JSON.stringify(newData); 33 | } 34 | 35 | function putRecord(params, cb) { 36 | const newData = addCorrelationIds(params.Data); 37 | params = Object.assign({}, params, { Data: newData }); 38 | 39 | return Kinesis.putRecord(params, cb); 40 | }; 41 | 42 | const client = Object.assign({}, Kinesis, { putRecord }); 43 | 44 | module.exports = client; -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const correlationIds = require('./correlation-ids'); 4 | 5 | const LogLevels = { 6 | DEBUG : 0, 7 | INFO : 1, 8 | WARN : 2, 9 | ERROR : 3 10 | }; 11 | 12 | // most of these are available through the Node.js execution environment for Lambda 13 | // see https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html 14 | const DEFAULT_CONTEXT = { 15 | awsRegion: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION, 16 | functionName: process.env.AWS_LAMBDA_FUNCTION_NAME, 17 | functionVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION, 18 | functionMemorySize: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE, 19 | stage: process.env.ENVIRONMENT || process.env.STAGE 20 | }; 21 | 22 | function getContext () { 23 | // if there's a global variable for all the current request context then use it 24 | const context = correlationIds.get(); 25 | if (context) { 26 | // note: this is a shallow copy, which is ok as we're not going to mutate anything 27 | return Object.assign({}, DEFAULT_CONTEXT, context); 28 | } 29 | 30 | return DEFAULT_CONTEXT; 31 | } 32 | 33 | // default to debug if not specified 34 | function logLevelName() { 35 | return process.env.log_level || 'DEBUG'; 36 | } 37 | 38 | function isEnabled (level) { 39 | return level >= LogLevels[logLevelName()]; 40 | } 41 | 42 | function appendError(params, err) { 43 | if (!err) { 44 | return params; 45 | } 46 | 47 | return Object.assign( 48 | {}, 49 | params || {}, 50 | { errorName: err.name, errorMessage: err.message, stackTrace: err.stack } 51 | ); 52 | } 53 | 54 | function log (levelName, message, params) { 55 | if (!isEnabled(LogLevels[levelName])) { 56 | return; 57 | } 58 | 59 | let context = getContext(); 60 | let logMsg = Object.assign({}, context, params); 61 | logMsg.level = levelName; 62 | logMsg.message = message; 63 | 64 | console.log(JSON.stringify(logMsg)); 65 | } 66 | 67 | module.exports.debug = (msg, params) => log('DEBUG', msg, params); 68 | module.exports.info = (msg, params) => log('INFO', msg, params); 69 | module.exports.warn = (msg, params, error) => log('WARN', msg, appendError(params, error)); 70 | module.exports.error = (msg, params, error) => log('ERROR', msg, appendError(params, error)); 71 | 72 | module.exports.enableDebug = () => { 73 | const oldLevel = process.env.log_level; 74 | process.env.log_level = 'DEBUG'; 75 | 76 | // return a function to perform the rollback 77 | return () => { 78 | process.env.log_level = oldLevel; 79 | } 80 | }; -------------------------------------------------------------------------------- /lib/lru.js: -------------------------------------------------------------------------------- 1 | module.exports = function(size) { 2 | return new LruCache(size) 3 | } 4 | 5 | function LruCache(size) { 6 | this.capacity = size | 0 7 | this.map = Object.create(null) 8 | this.list = new DoublyLinkedList() 9 | } 10 | 11 | LruCache.prototype.get = function(key) { 12 | var node = this.map[key] 13 | if (node == null) return undefined 14 | this.used(node) 15 | return node.val 16 | } 17 | 18 | LruCache.prototype.set = function(key, val) { 19 | var node = this.map[key] 20 | if (node != null) { 21 | node.val = val 22 | } else { 23 | if (!this.capacity) this.prune() 24 | if (!this.capacity) return false 25 | node = new DoublyLinkedNode(key, val) 26 | this.map[key] = node 27 | this.capacity-- 28 | } 29 | this.used(node) 30 | return true 31 | } 32 | 33 | LruCache.prototype.used = function(node) { 34 | this.list.moveToFront(node) 35 | } 36 | 37 | LruCache.prototype.prune = function() { 38 | var node = this.list.pop() 39 | if (node != null) { 40 | delete this.map[node.key] 41 | this.capacity++ 42 | } 43 | } 44 | 45 | 46 | function DoublyLinkedList() { 47 | this.firstNode = null 48 | this.lastNode = null 49 | } 50 | 51 | DoublyLinkedList.prototype.moveToFront = function(node) { 52 | if (this.firstNode == node) return 53 | 54 | this.remove(node) 55 | 56 | if (this.firstNode == null) { 57 | this.firstNode = node 58 | this.lastNode = node 59 | node.prev = null 60 | node.next = null 61 | } else { 62 | node.prev = null 63 | node.next = this.firstNode 64 | node.next.prev = node 65 | this.firstNode = node 66 | } 67 | } 68 | 69 | DoublyLinkedList.prototype.pop = function() { 70 | var lastNode = this.lastNode 71 | if (lastNode != null) { 72 | this.remove(lastNode) 73 | } 74 | return lastNode 75 | } 76 | 77 | DoublyLinkedList.prototype.remove = function(node) { 78 | if (this.firstNode == node) { 79 | this.firstNode = node.next 80 | } else if (node.prev != null) { 81 | node.prev.next = node.next 82 | } 83 | if (this.lastNode == node) { 84 | this.lastNode = node.prev 85 | } else if (node.next != null) { 86 | node.next.prev = node.prev 87 | } 88 | } 89 | 90 | 91 | function DoublyLinkedNode(key, val) { 92 | this.key = key 93 | this.val = val 94 | this.prev = null 95 | this.next = null 96 | } -------------------------------------------------------------------------------- /lib/notify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const co = require('co'); 5 | const sns = require('./sns'); 6 | const kinesis = require('./kinesis'); 7 | const chance = require('chance').Chance(); 8 | const log = require('./log'); 9 | const cloudwatch = require('./cloudwatch'); 10 | 11 | const streamName = process.env.order_events_stream; 12 | const restaurantTopicArn = process.env.restaurant_notification_topic; 13 | const userTopicArn = process.env.user_notification_topic; 14 | 15 | let notifyRestaurantOfOrder = co.wrap(function* (order) { 16 | try { 17 | if (chance.bool({likelihood: 75})) { // 75% chance of failure 18 | throw new Error("boom"); 19 | } 20 | 21 | let snsReq = { 22 | Message: JSON.stringify(order), 23 | TopicArn: restaurantTopicArn 24 | }; 25 | yield cloudwatch.trackExecTime( 26 | "SnsPublishLatency", 27 | () => sns.publish(snsReq).promise() 28 | ); 29 | 30 | let logContext = { 31 | orderId: order.orderId, 32 | restaurantName: order.restaurantName, 33 | userEmail: order.userEmail 34 | }; 35 | log.debug('notified restaurant of new order', logContext); 36 | 37 | let data = _.clone(order); 38 | data.eventType = 'restaurant_notified'; 39 | 40 | let kinesisReq = { 41 | Data: JSON.stringify(data), // the SDK would base64 encode this for us 42 | PartitionKey: order.orderId, 43 | StreamName: streamName 44 | }; 45 | yield cloudwatch.trackExecTime( 46 | "KinesisPutRecordLatency", 47 | () => kinesis.putRecord(kinesisReq).promise() 48 | ); 49 | log.debug('published event into Kinesis', { eventName: 'restaurant_notified' }); 50 | 51 | cloudwatch.incrCount('NotifyRestaurantSuccess'); 52 | } catch (err) { 53 | cloudwatch.incrCount('NotifyRestaurantFailed'); 54 | throw err; 55 | } 56 | }); 57 | 58 | let notifyUserOfOrderAccepted = co.wrap(function* (order) { 59 | try { 60 | if (chance.bool({likelihood: 75})) { // 75% chance of failure 61 | throw new Error("boom"); 62 | } 63 | 64 | let snsReq = { 65 | Message: JSON.stringify(order), 66 | TopicArn: userTopicArn 67 | }; 68 | yield cloudwatch.trackExecTime( 69 | "SnsPublishLatency", 70 | () => sns.publish(snsReq).promise() 71 | ); 72 | 73 | let logContext = { 74 | orderId: order.orderId, 75 | restaurantName: order.restaurantName, 76 | userEmail: order.userEmail 77 | }; 78 | log.debug('notified user of accepted order', logContext); 79 | 80 | let data = _.clone(order); 81 | data.eventType = 'user_notified'; 82 | 83 | let kinesisReq = { 84 | Data: JSON.stringify(data), // the SDK would base64 encode this for us 85 | PartitionKey: order.orderId, 86 | StreamName: streamName 87 | }; 88 | yield cloudwatch.trackExecTime( 89 | "KinesisPutRecordLatency", 90 | () => kinesis.putRecord(kinesisReq).promise() 91 | ); 92 | log.debug(`published event into Kinesis`, { eventName: 'user_notified' }); 93 | 94 | cloudwatch.incrCount('NotifyUserSuccess'); 95 | } catch (err) { 96 | cloudwatch.incrCount('NotifyUserFailed'); 97 | throw err; 98 | } 99 | }); 100 | 101 | module.exports = { 102 | restaurantOfOrder: notifyRestaurantOfOrder, 103 | userOfOrderAccepted: notifyUserOfOrderAccepted 104 | }; -------------------------------------------------------------------------------- /lib/retry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const sns = require('./sns'); 5 | const log = require('./log'); 6 | const cloudwatch = require('./cloudwatch'); 7 | 8 | const restaurantRetryTopicArn = process.env.restaurant_notification_retry_topic; 9 | const userRetryTopicArn = process.env.user_notification_retry_topic; 10 | 11 | let retryRestaurantNotification = co.wrap(function* (order) { 12 | let snsReq = { 13 | Message: JSON.stringify(order), 14 | TopicArn: restaurantRetryTopicArn 15 | }; 16 | yield cloudwatch.trackExecTime( 17 | "SnsPublishLatency", 18 | () => sns.publish(snsReq).promise() 19 | ); 20 | 21 | let logContext = { 22 | orderId: order.orderId, 23 | restaurantName: order.restaurantName, 24 | userEmail: order.userEmail 25 | }; 26 | log.debug('queued restaurant notification for retry', logContext); 27 | 28 | cloudwatch.incrCount("NotifyRestaurantQueued"); 29 | }); 30 | 31 | let retryUserNotification = co.wrap(function* (order) { 32 | let snsReq = { 33 | Message: JSON.stringify(order), 34 | TopicArn: userRetryTopicArn 35 | }; 36 | yield cloudwatch.trackExecTime( 37 | "SnsPublishLatency", 38 | () => sns.publish(snsReq).promise() 39 | ); 40 | 41 | let logContext = { 42 | orderId: order.orderId, 43 | restaurantName: order.restaurantName, 44 | userEmail: order.userEmail 45 | }; 46 | log.debug('queued user notification for retry', logContext); 47 | 48 | cloudwatch.incrCount("NotifyUserQueued"); 49 | }); 50 | 51 | module.exports = { 52 | restaurantNotification: retryRestaurantNotification, 53 | userNotification: retryUserNotification 54 | }; -------------------------------------------------------------------------------- /lib/sns.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AWSXRay = require('aws-xray-sdk'); 4 | const AWS = AWSXRay.captureAWS(require('aws-sdk')); 5 | const SNS = new AWS.SNS(); 6 | const correlationIds = require('./correlation-ids'); 7 | 8 | function addCorrelationIds(messageAttributes) { 9 | let attributes = {}; 10 | let context = correlationIds.get(); 11 | for (let key in context) { 12 | attributes[key] = { 13 | DataType: 'String', 14 | StringValue: context[key] 15 | }; 16 | } 17 | 18 | // use `attribtues` as base so if the user's message attributes would override 19 | // our correlation IDs 20 | return Object.assign(attributes, messageAttributes || {}); 21 | } 22 | 23 | function publish(params, cb) { 24 | const newMessageAttributes = addCorrelationIds(params.MessageAttributes); 25 | params = Object.assign(params, { MessageAttributes: newMessageAttributes }); 26 | 27 | return SNS.publish(params, cb); 28 | }; 29 | 30 | const client = Object.assign({}, SNS, { publish }); 31 | 32 | module.exports = client; -------------------------------------------------------------------------------- /middleware/capture-correlation-ids.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const correlationIds = require('../lib/correlation-ids'); 4 | const log = require('../lib/log'); 5 | 6 | function captureHttp(headers, awsRequestId, sampleDebugLogRate) { 7 | if (!headers) { 8 | log.warn(`Request ${awsRequestId} is missing headers`); 9 | return; 10 | } 11 | 12 | let context = { awsRequestId }; 13 | for (const header in headers) { 14 | if (header.toLowerCase().startsWith('x-correlation-')) { 15 | context[header] = headers[header]; 16 | } 17 | } 18 | 19 | if (!context['x-correlation-id']) { 20 | context['x-correlation-id'] = awsRequestId; 21 | } 22 | 23 | // forward the original User-Agent on 24 | if (headers['User-Agent']) { 25 | context['User-Agent'] = headers['User-Agent']; 26 | } 27 | 28 | if (headers['Debug-Log-Enabled']) { 29 | context['Debug-Log-Enabled'] = headers['Debug-Log-Enabled']; 30 | } else { 31 | context['Debug-Log-Enabled'] = Math.random() < sampleDebugLogRate ? 'true' : 'false'; 32 | } 33 | 34 | correlationIds.replaceAllWith(context); 35 | } 36 | 37 | function parsePayload (record) { 38 | let json = new Buffer(record.kinesis.data, 'base64').toString('utf8'); 39 | return JSON.parse(json); 40 | } 41 | 42 | function captureKinesis(event, context, sampleDebugLogRate) { 43 | const awsRequestId = context.awsRequestId; 44 | const events = event 45 | .Records 46 | .map(parsePayload) 47 | .map(record => { 48 | // the wrapped kinesis client would put the correlation IDs as part of 49 | // the payload as a special __context__ property 50 | let recordContext = record.__context__ || {}; 51 | recordContext.awsRequestId = awsRequestId; 52 | 53 | delete record.__context__; 54 | 55 | if (!recordContext['x-correlation-id']) { 56 | recordContext['x-correlation-id'] = awsRequestId; 57 | } 58 | 59 | if (!recordContext['Debug-Log-Enabled']) { 60 | recordContext['Debug-Log-Enabled'] = Math.random() < sampleDebugLogRate ? 'true' : 'false'; 61 | } 62 | 63 | let debugLog = recordContext['Debug-Log-Enabled'] === 'true'; 64 | 65 | let oldContext = undefined; 66 | let debugLogRollback = undefined; 67 | 68 | // lets you add more correlation IDs for just this record 69 | record.addToScope = (key, value) => { 70 | if (!key.startsWith("x-correlation-")) { 71 | key = "x-correlation-" + key; 72 | } 73 | 74 | recordContext[key] = value; 75 | correlationIds.set(key, value); 76 | } 77 | 78 | record.scopeToThis = () => { 79 | if (!oldContext) { 80 | oldContext = correlationIds.get(); 81 | correlationIds.replaceAllWith(recordContext); 82 | } 83 | 84 | if (debugLog) { 85 | debugLogRollback = log.enableDebug(); 86 | } 87 | }; 88 | 89 | record.unscope = () => { 90 | if (oldContext) { 91 | correlationIds.replaceAllWith(oldContext); 92 | } 93 | 94 | if (debugLogRollback) { 95 | debugLogRollback(); 96 | } 97 | } 98 | 99 | return record; 100 | }); 101 | 102 | context.parsedKinesisEvents = events; 103 | 104 | correlationIds.replaceAllWith({ awsRequestId }); 105 | } 106 | 107 | function captureSns(records, awsRequestId, sampleDebugLogRate) { 108 | let context = { awsRequestId }; 109 | 110 | const snsRecord = records[0].Sns; 111 | const msgAttributes = snsRecord.MessageAttributes; 112 | 113 | for (var msgAttribute in msgAttributes) { 114 | if (msgAttribute.toLowerCase().startsWith('x-correlation-')) { 115 | context[msgAttribute] = msgAttributes[msgAttribute].Value; 116 | } 117 | 118 | if (msgAttribute === 'User-Agent') { 119 | context['User-Agent'] = msgAttributes['User-Agent'].Value; 120 | } 121 | 122 | if (msgAttribute === 'Debug-Log-Enabled') { 123 | context['Debug-Log-Enabled'] = msgAttributes['Debug-Log-Enabled'].Value; 124 | } 125 | } 126 | 127 | if (!context['x-correlation-id']) { 128 | context['x-correlation-id'] = awsRequestId; 129 | } 130 | 131 | if (!context['Debug-Log-Enabled']) { 132 | context['Debug-Log-Enabled'] = Math.random() < sampleDebugLogRate ? 'true' : 'false'; 133 | } 134 | 135 | correlationIds.replaceAllWith(context); 136 | } 137 | 138 | function isApiGatewayEvent(event) { 139 | return event.hasOwnProperty('httpMethod') 140 | } 141 | 142 | function isKinesisEvent(event) { 143 | if (!event.hasOwnProperty('Records')) { 144 | return false; 145 | } 146 | 147 | if (!Array.isArray(event.Records)) { 148 | return false; 149 | } 150 | 151 | return event.Records[0].eventSource === 'aws:kinesis'; 152 | } 153 | 154 | function isSnsEvent(event) { 155 | if (!event.hasOwnProperty('Records')) { 156 | return false; 157 | } 158 | 159 | if (!Array.isArray(event.Records)) { 160 | return false; 161 | } 162 | 163 | return event.Records[0].EventSource === 'aws:sns'; 164 | } 165 | 166 | module.exports = (config) => { 167 | const sampleDebugLogRate = config.sampleDebugLogRate || 0.01; 168 | 169 | return { 170 | before: (handler, next) => { 171 | correlationIds.clearAll(); 172 | 173 | if (isApiGatewayEvent(handler.event)) { 174 | captureHttp(handler.event.headers, handler.context.awsRequestId, sampleDebugLogRate); 175 | } else if (isKinesisEvent(handler.event)) { 176 | captureKinesis(handler.event, handler.context, sampleDebugLogRate); 177 | } else if (isSnsEvent(handler.event)) { 178 | captureSns(handler.event.Records, handler.context.awsRequestId, sampleDebugLogRate); 179 | } 180 | 181 | next() 182 | } 183 | }; 184 | }; -------------------------------------------------------------------------------- /middleware/flush-metrics.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../lib/log'); 4 | const cloudwatch = require('../lib/cloudwatch'); 5 | 6 | module.exports = { 7 | after: (handler, next) => { 8 | cloudwatch.flush().then(_ => next()); 9 | }, 10 | onError: (handler, next) => { 11 | cloudwatch.flush().then(_ => next(handler.error)); 12 | } 13 | }; -------------------------------------------------------------------------------- /middleware/function-shield.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const FuncShield = require('@puresec/function-shield'); 4 | 5 | module.exports = () => { 6 | return { 7 | before: (handler, next) => { 8 | FuncShield.configure({ 9 | policy: { 10 | outbound_connectivity: "block", 11 | read_write_tmp: "block", 12 | create_child_process: "block" 13 | }, 14 | token: process.env.FUNCTION_SHIELD_TOKEN 15 | }); 16 | 17 | next(); 18 | } 19 | }; 20 | }; -------------------------------------------------------------------------------- /middleware/sample-logging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const correlationIds = require('../lib/correlation-ids'); 4 | const log = require('../lib/log'); 5 | 6 | // config should be { sampleRate: double } where sampleRate is between 0.0-1.0 7 | module.exports = (config) => { 8 | let rollback = undefined; 9 | 10 | const isDebugEnabled = () => { 11 | const context = correlationIds.get(); 12 | if (context['Debug-Log-Enabled'] === 'true') { 13 | return true; 14 | } 15 | 16 | return config.sampleRate && Math.random() <= config.sampleRate; 17 | } 18 | 19 | return { 20 | before: (handler, next) => { 21 | if (isDebugEnabled()) { 22 | rollback = log.enableDebug(); 23 | } 24 | 25 | next(); 26 | }, 27 | after: (handler, next) => { 28 | if (rollback) { 29 | rollback(); 30 | } 31 | 32 | next(); 33 | }, 34 | onError: (handler, next) => { 35 | let awsRequestId = handler.context.awsRequestId; 36 | let invocationEvent = JSON.stringify(handler.event); 37 | log.error('invocation failed', { awsRequestId, invocationEvent }, handler.error); 38 | 39 | next(handler.error); 40 | } 41 | }; 42 | }; -------------------------------------------------------------------------------- /middleware/wrapper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const middy = require('middy'); 4 | const sampleLogging = require('./sample-logging'); 5 | const captureCorrelationIds = require('./capture-correlation-ids'); 6 | const functionShield = require('./function-shield'); 7 | 8 | module.exports = f => { 9 | return middy(f) 10 | .use(captureCorrelationIds({ sampleDebugLogRate: 0.01 })) 11 | .use(sampleLogging({ sampleRate: 0.01 })) 12 | .use(functionShield()); 13 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "big-mouth", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "integration-test": "env TEST_MODE=handler ./node_modules/.bin/mocha tests/test_cases --reporter spec --retries 3 --timeout 10000", 8 | "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" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@puresec/function-shield": "^1.0.7", 14 | "aws-xray-sdk": "^1.2.0", 15 | "bluebird": "^3.5.1", 16 | "chance": "^1.0.13", 17 | "co": "^4.6.0", 18 | "middy": "^0.17.1", 19 | "mustache": "^2.3.0", 20 | "superagent": "^3.8.1", 21 | "superagent-promise": "^1.1.0" 22 | }, 23 | "devDependencies": { 24 | "aws-sdk": "^2.302.0", 25 | "chai": "^4.1.2", 26 | "cheerio": "^1.0.0-rc.2", 27 | "lodash": "^4.17.10", 28 | "mocha": "^4.0.1", 29 | "serverless": "^1.30.1", 30 | "serverless-iam-roles-per-function": "^0.1.5", 31 | "serverless-plugin-aws-alerts": "^1.2.4", 32 | "serverless-plugin-canary-deployments": "^0.4.3", 33 | "serverless-plugin-tracing": "^2.0.0", 34 | "serverless-pseudo-parameters": "^1.2.5", 35 | "serverless-sam": "0.0.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /seed-restaurants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const AWS = require('aws-sdk'); 5 | AWS.config.region = 'us-east-1'; 6 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 7 | 8 | let restaurants = [ 9 | { 10 | name: "Fangtasia", 11 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/fangtasia.png", 12 | themes: ["true blood"] 13 | }, 14 | { 15 | name: "Shoney's", 16 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/shoney's.png", 17 | themes: ["cartoon", "rick and morty"] 18 | }, 19 | { 20 | name: "Freddy's BBQ Joint", 21 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/freddy's+bbq+joint.png", 22 | themes: ["netflix", "house of cards"] 23 | }, 24 | { 25 | name: "Pizza Planet", 26 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/pizza+planet.png", 27 | themes: ["netflix", "toy story"] 28 | }, 29 | { 30 | name: "Leaky Cauldron", 31 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/leaky+cauldron.png", 32 | themes: ["movie", "harry potter"] 33 | }, 34 | { 35 | name: "Lil' Bits", 36 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/lil+bits.png", 37 | themes: ["cartoon", "rick and morty"] 38 | }, 39 | { 40 | name: "Fancy Eats", 41 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/fancy+eats.png", 42 | themes: ["cartoon", "rick and morty"] 43 | }, 44 | { 45 | name: "Don Cuco", 46 | image: "https://d2qt42rcwzspd6.cloudfront.net/manning/don%20cuco.png", 47 | themes: ["cartoon", "rick and morty"] 48 | }, 49 | ]; 50 | 51 | let putReqs = restaurants.map(x => ({ 52 | PutRequest: { 53 | Item: x 54 | } 55 | })); 56 | 57 | let req = { 58 | RequestItems: { 59 | 'restaurants': putReqs 60 | } 61 | }; 62 | dynamodb.batchWrite(req).promise().then(() => console.log("all done")); -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: big-mouth 2 | 3 | plugins: 4 | - serverless-pseudo-parameters 5 | - serverless-sam 6 | - serverless-iam-roles-per-function 7 | - serverless-plugin-tracing 8 | - serverless-plugin-canary-deployments 9 | - serverless-plugin-aws-alerts 10 | 11 | custom: 12 | stage: ${opt:stage, self:provider.stage} 13 | region: ${opt:region} 14 | logLevel: 15 | prod: WARN 16 | default: DEBUG 17 | serverless-iam-roles-per-function: 18 | defaultInherit: true 19 | alerts: 20 | stages: 21 | - dev 22 | - staging 23 | - production 24 | dashboards: false 25 | alarms: 26 | - functionThrottles 27 | - functionErrors 28 | 29 | provider: 30 | name: aws 31 | runtime: nodejs6.10 32 | tracing: true 33 | environment: 34 | log_level: ${self:custom.logLevel.${self:custom.stage}, self:custom.logLevel.default} 35 | STAGE: ${self:custom.stage} 36 | FUNCTION_SHIELD_TOKEN: ${ssm:/bigmouth/${self:custom.stage}/function_shield_token~true} 37 | iamRoleStatements: 38 | - Effect: Allow 39 | Action: cloudwatch:PutMetricData 40 | Resource: '*' 41 | - Effect: Allow 42 | Action: 43 | - 'xray:PutTraceSegments' 44 | - 'xray:PutTelemetryRecords' 45 | Resource: '*' 46 | - Effect: Allow 47 | Action: codedeploy:* 48 | Resource: '*' 49 | 50 | functions: 51 | get-index: 52 | handler: functions/get-index.handler 53 | events: 54 | - http: 55 | path: / 56 | method: get 57 | environment: 58 | async_metrics: true 59 | deploymentSettings: 60 | type: Linear10PercentEvery1Minute 61 | alias: Live 62 | alarms: 63 | - GetDashindexFunctionErrorsAlarm 64 | #iamRoleStatementsInherit: true #optionally inherit shared permissions 65 | iamRoleStatements: 66 | - Effect: Allow 67 | Action: execute-api:Invoke 68 | Resource: arn:aws:execute-api:#{AWS::Region}:#{AWS::AccountId}:*/*/GET/restaurants 69 | - Effect: Allow 70 | Action: ssm:GetParameters* 71 | Resource: arn:aws:ssm:#{AWS::Region}:#{AWS::AccountId}:parameter/bigmouth/${self:custom.stage}/* 72 | - Effect: Allow 73 | Action: secretsmanager:GetSecretValue 74 | Resource: arn:aws:secretsmanager:#{AWS::Region}:#{AWS::AccountId}:secret:/bigmouth/${self:custom.stage}/* 75 | 76 | get-restaurants: 77 | handler: functions/get-restaurants.handler 78 | events: 79 | - http: 80 | path: /restaurants/ 81 | method: get 82 | authorizer: aws_iam 83 | environment: 84 | restaurants_table: restaurants 85 | async_metrics: true 86 | iamRoleStatements: 87 | - Effect: Allow 88 | Action: dynamodb:scan 89 | Resource: arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants 90 | 91 | search-restaurants: 92 | handler: functions/search-restaurants.handler 93 | events: 94 | - http: 95 | path: /restaurants/search 96 | method: post 97 | authorizer: 98 | arn: arn:aws:cognito-idp:#{AWS::Region}:#{AWS::AccountId}:userpool/us-east-1_DfuAwa0vB 99 | environment: 100 | restaurants_table: restaurants 101 | async_metrics: true 102 | iamRoleStatements: 103 | - Effect: Allow 104 | Action: dynamodb:scan 105 | Resource: arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants 106 | 107 | place-order: 108 | handler: functions/place-order.handler 109 | events: 110 | - http: 111 | path: /orders 112 | method: post 113 | authorizer: 114 | arn: arn:aws:cognito-idp:#{AWS::Region}:#{AWS::AccountId}:userpool/us-east-1_DfuAwa0vB 115 | environment: 116 | order_events_stream: order-events 117 | async_metrics: true 118 | iamRoleStatements: 119 | - Effect: Allow 120 | Action: kinesis:PutRecord 121 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 122 | 123 | notify-restaurant: 124 | handler: functions/notify-restaurant.handler 125 | events: 126 | - stream: 127 | arn: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 128 | environment: 129 | order_events_stream: order-events 130 | restaurant_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification 131 | restaurant_notification_retry_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification-retry 132 | iamRoleStatements: 133 | - Effect: Allow 134 | Action: kinesis:PutRecord 135 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 136 | - Effect: Allow 137 | Action: sns:Publish 138 | Resource: 139 | - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification 140 | - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification-retry 141 | 142 | retry-notify-restaurant: 143 | handler: functions/retry-notify-restaurant.handler 144 | events: 145 | - sns: restaurant-notification-retry 146 | environment: 147 | order_events_stream: order-events 148 | restaurant_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification 149 | onError: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification-dlq 150 | iamRoleStatements: 151 | - Effect: Allow 152 | Action: kinesis:PutRecord 153 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 154 | - Effect: Allow 155 | Action: sns:Publish 156 | Resource: 157 | - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:restaurant-notification 158 | 159 | accept-order: 160 | handler: functions/accept-order.handler 161 | events: 162 | - http: 163 | path: /orders/accept 164 | method: post 165 | environment: 166 | order_events_stream: order-events 167 | async_metrics: true 168 | iamRoleStatements: 169 | - Effect: Allow 170 | Action: kinesis:PutRecord 171 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 172 | 173 | notify-user: 174 | handler: functions/notify-user.handler 175 | events: 176 | - stream: 177 | arn: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 178 | environment: 179 | order_events_stream: order-events 180 | user_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification 181 | user_notification_retry_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification-retry 182 | iamRoleStatements: 183 | - Effect: Allow 184 | Action: kinesis:PutRecord 185 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 186 | - Effect: Allow 187 | Action: sns:Publish 188 | Resource: 189 | - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification 190 | - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification-retry 191 | 192 | retry-notify-user: 193 | handler: functions/retry-notify-user.handler 194 | events: 195 | - sns: user-notification-retry 196 | environment: 197 | order_events_stream: order-events 198 | user_notification_topic: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification 199 | onError: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification-dlq 200 | iamRoleStatements: 201 | - Effect: Allow 202 | Action: kinesis:PutRecord 203 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 204 | - Effect: Allow 205 | Action: sns:Publish 206 | Resource: 207 | - arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:user-notification 208 | 209 | fulfill-order: 210 | handler: functions/fulfill-order.handler 211 | events: 212 | - http: 213 | path: /orders/complete 214 | method: post 215 | environment: 216 | order_events_stream: order-events 217 | async_metrics: true 218 | iamRoleStatements: 219 | - Effect: Allow 220 | Action: kinesis:PutRecord 221 | Resource: arn:aws:kinesis:#{AWS::Region}:#{AWS::AccountId}:stream/order-events 222 | 223 | auto-create-api-alarms: 224 | handler: functions/create-alarms.handler 225 | events: 226 | - cloudwatchEvent: 227 | event: 228 | source: 229 | - aws.apigateway 230 | detail-type: 231 | - AWS API Call via CloudTrail 232 | detail: 233 | eventSource: 234 | - apigateway.amazonaws.com 235 | eventName: 236 | - CreateDeployment 237 | environment: 238 | alarm_actions: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:NotifyMe 239 | ok_actions: arn:aws:sns:#{AWS::Region}:#{AWS::AccountId}:NotifyMe 240 | iamRoleStatements: 241 | - Effect: Allow 242 | Action: apigateway:GET 243 | Resource: 244 | - arn:aws:apigateway:#{AWS::Region}::/restapis/* 245 | - arn:aws:apigateway:#{AWS::Region}::/restapis/*/stages/${self:custom.stage} 246 | - Effect: Allow 247 | Action: apigateway:PATCH 248 | Resource: arn:aws:apigateway:#{AWS::Region}::/restapis/*/stages/${self:custom.stage} 249 | - Effect: Allow 250 | Action: cloudwatch:PutMetricAlarm 251 | Resource: "*" 252 | 253 | resources: 254 | Resources: 255 | restaurantsTable: 256 | Type: AWS::DynamoDB::Table 257 | Properties: 258 | TableName: restaurants 259 | AttributeDefinitions: 260 | - AttributeName: name 261 | AttributeType: S 262 | KeySchema: 263 | - AttributeName: name 264 | KeyType: HASH 265 | ProvisionedThroughput: 266 | ReadCapacityUnits: 1 267 | WriteCapacityUnits: 1 268 | 269 | orderEventsStream: 270 | Type: AWS::Kinesis::Stream 271 | Properties: 272 | Name: order-events 273 | ShardCount: 1 274 | 275 | restaurantNotificationTopic: 276 | Type: AWS::SNS::Topic 277 | Properties: 278 | DisplayName: restaurant-notification 279 | TopicName: restaurant-notification 280 | 281 | userNotificationTopic: 282 | Type: AWS::SNS::Topic 283 | Properties: 284 | DisplayName: user-notification 285 | TopicName: user-notification 286 | 287 | restaurantNotificationDLQTopic: 288 | Type: AWS::SNS::Topic 289 | Properties: 290 | DisplayName: restaurant-notification-dlq 291 | TopicName: restaurant-notification-dlq 292 | 293 | userNotificationDLQTopic: 294 | Type: AWS::SNS::Topic 295 | Properties: 296 | DisplayName: user-notification-dlq 297 | TopicName: user-notification-dlq -------------------------------------------------------------------------------- /static/amazon-cognito-identity.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright 2016 Amazon.com, 3 | * Inc. or its affiliates. All Rights Reserved. 4 | * 5 | * Licensed under the Amazon Software License (the "License"). 6 | * You may not use this file except in compliance with the 7 | * License. A copy of the License is located at 8 | * 9 | * http://aws.amazon.com/asl/ 10 | * 11 | * or in the "license" file accompanying this file. This file is 12 | * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 13 | * CONDITIONS OF ANY KIND, express or implied. See the License 14 | * for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("aws-sdk/global"),require("aws-sdk/clients/cognitoidentityserviceprovider")):"function"==typeof define&&define.amd?define(["aws-sdk/global","aws-sdk/clients/cognitoidentityserviceprovider"],t):"object"==typeof exports?exports.AmazonCognitoIdentity=t(require("aws-sdk/global"),require("aws-sdk/clients/cognitoidentityserviceprovider")):e.AmazonCognitoIdentity=t(e.AWSCognito,e.AWSCognito.CognitoIdentityServiceProvider)}(this,function(e,t){return function(e){function t(i){if(n[i])return n[i].exports;var s=n[i]={exports:{},id:i,loaded:!1};return e[i].call(s.exports,s,s.exports,t),s.loaded=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([function(e,t,n){"use strict";function i(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}function s(e){return e&&e.__esModule?e:{default:e}}t.__esModule=!0;var o=n(17);Object.keys(o).forEach(function(e){"default"!==e&&"__esModule"!==e&&Object.defineProperty(t,e,{enumerable:!0,get:function(){return o[e]}})});var r=n(13),a=s(r),u=i(o);Object.keys(u).forEach(function(e){a.default[e]=u[e]})},function(t,n){t.exports=e},function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}t.__esModule=!0;var o=n(1),r=n(3),a=i(r),u="FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF",c="userAttributes.",l=function(){function e(t){s(this,e),this.N=new a.default(u,16),this.g=new a.default("2",16),this.k=new a.default(this.hexHash("00"+this.N.toString(16)+"0"+this.g.toString(16)),16),this.smallAValue=this.generateRandomSmallA(),this.getLargeAValue(function(){}),this.infoBits=new o.util.Buffer("Caldera Derived Key","utf8"),this.poolName=t}return e.prototype.getSmallAValue=function(){return this.smallAValue},e.prototype.getLargeAValue=function(e){var t=this;this.largeAValue?e(null,this.largeAValue):this.calculateA(this.smallAValue,function(n,i){n&&e(n,null),t.largeAValue=i,e(null,t.largeAValue)})},e.prototype.generateRandomSmallA=function(){var e=o.util.crypto.lib.randomBytes(128).toString("hex"),t=new a.default(e,16),n=t.mod(this.N);return n},e.prototype.generateRandomString=function(){return o.util.crypto.lib.randomBytes(40).toString("base64")},e.prototype.getRandomPassword=function(){return this.randomPassword},e.prototype.getSaltDevices=function(){return this.SaltToHashDevices},e.prototype.getVerifierDevices=function(){return this.verifierDevices},e.prototype.generateHashDevice=function(e,t,n){var i=this;this.randomPassword=this.generateRandomString();var s=""+e+t+":"+this.randomPassword,r=this.hash(s),u=o.util.crypto.lib.randomBytes(16).toString("hex");this.SaltToHashDevices=this.padHex(new a.default(u,16)),this.g.modPow(new a.default(this.hexHash(this.SaltToHashDevices+r),16),this.N,function(e,t){e&&n(e,null),i.verifierDevices=i.padHex(t),n(null,null)})},e.prototype.calculateA=function(e,t){var n=this;this.g.modPow(e,this.N,function(e,i){e&&t(e,null),i.mod(n.N).equals(a.default.ZERO)&&t(new Error("Illegal paramater. A mod N cannot be 0."),null),t(null,i)})},e.prototype.calculateU=function(e,t){this.UHexHash=this.hexHash(this.padHex(e)+this.padHex(t));var n=new a.default(this.UHexHash,16);return n},e.prototype.hash=function(e){var t=o.util.crypto.sha256(e,"hex");return new Array(64-t.length).join("0")+t},e.prototype.hexHash=function(e){return this.hash(new o.util.Buffer(e,"hex"))},e.prototype.computehkdf=function(e,t){var n=o.util.crypto.hmac(t,e,"buffer","sha256"),i=o.util.buffer.concat([this.infoBits,new o.util.Buffer(String.fromCharCode(1),"utf8")]),s=o.util.crypto.hmac(n,i,"buffer","sha256");return s.slice(0,16)},e.prototype.getPasswordAuthenticationKey=function(e,t,n,i,s){var r=this;if(n.mod(this.N).equals(a.default.ZERO))throw new Error("B cannot be zero.");if(this.UValue=this.calculateU(this.largeAValue,n),this.UValue.equals(a.default.ZERO))throw new Error("U cannot be zero.");var u=""+this.poolName+e+":"+t,c=this.hash(u),l=new a.default(this.hexHash(this.padHex(i)+c),16);this.calculateS(l,n,function(e,t){e&&s(e,null);var n=r.computehkdf(new o.util.Buffer(r.padHex(t),"hex"),new o.util.Buffer(r.padHex(r.UValue.toString(16)),"hex"));s(null,n)})},e.prototype.calculateS=function(e,t,n){var i=this;this.g.modPow(e,this.N,function(s,o){s&&n(s,null);var r=t.subtract(i.k.multiply(o));r.modPow(i.smallAValue.add(i.UValue.multiply(e)),i.N,function(e,t){e&&n(e,null),n(null,t.mod(i.N))})})},e.prototype.getNewPasswordRequiredChallengeUserAttributePrefix=function(){return c},e.prototype.padHex=function(e){var t=e.toString(16);return t.length%2===1?t="0"+t:"89ABCDEFabcdef".indexOf(t[0])!==-1&&(t="00"+t),t},e}();t.default=l},function(e,t){"use strict";function n(e,t){null!=e&&this.fromString(e,t)}function i(){return new n(null)}function s(e,t,n,i,s,o){for(;--o>=0;){var r=t*this[e++]+n[i]+s;s=Math.floor(r/67108864),n[i++]=67108863&r}return s}function o(e,t,n,i,s,o){for(var r=32767&t,a=t>>15;--o>=0;){var u=32767&this[e],c=this[e++]>>15,l=a*u+c*r;u=r*u+((32767&l)<<15)+n[i]+(1073741823&s),s=(u>>>30)+(l>>>15)+a*c+(s>>>30),n[i++]=1073741823&u}return s}function r(e,t,n,i,s,o){for(var r=16383&t,a=t>>14;--o>=0;){var u=16383&this[e],c=this[e++]>>14,l=a*u+c*r;u=r*u+((16383&l)<<14)+n[i]+s,s=(u>>28)+(l>>14)+a*c,n[i++]=268435455&u}return s}function a(e){return z.charAt(e)}function u(e,t){var n=Q[e.charCodeAt(t)];return null==n?-1:n}function c(e){for(var t=this.t-1;t>=0;--t)e[t]=this[t];e.t=this.t,e.s=this.s}function l(e){this.t=1,this.s=e<0?-1:0,e>0?this[0]=e:e<-1?this[0]=e+this.DV:this.t=0}function h(e){var t=i();return t.fromInt(e),t}function f(e,t){var i;if(16==t)i=4;else if(8==t)i=3;else if(2==t)i=1;else if(32==t)i=5;else{if(4!=t)throw new Error("Only radix 2, 4, 8, 16, 32 are supported");i=2}this.t=0,this.s=0;for(var s=e.length,o=!1,r=0;--s>=0;){var a=u(e,s);a<0?"-"==e.charAt(s)&&(o=!0):(o=!1,0==r?this[this.t++]=a:r+i>this.DB?(this[this.t-1]|=(a&(1<>this.DB-r):this[this.t-1]|=a<=this.DB&&(r-=this.DB))}this.clamp(),o&&n.ZERO.subTo(this,this)}function d(){for(var e=this.s&this.DM;this.t>0&&this[this.t-1]==e;)--this.t}function p(e){if(this.s<0)return"-"+this.negate().toString();var t;if(16==e)t=4;else if(8==e)t=3;else if(2==e)t=1;else if(32==e)t=5;else{if(4!=e)throw new Error("Only radix 2, 4, 8, 16, 32 are supported");t=2}var n,i=(1<0)for(u>u)>0&&(s=!0,o=a(n));r>=0;)u>(u+=this.DB-t)):(n=this[r]>>(u-=t)&i,u<=0&&(u+=this.DB,--r)),n>0&&(s=!0),s&&(o+=a(n));return s?o:"0"}function g(){var e=i();return n.ZERO.subTo(this,e),e}function v(){return this.s<0?this.negate():this}function m(e){var t=this.s-e.s;if(0!=t)return t;var n=this.t;if(t=n-e.t,0!=t)return this.s<0?-t:t;for(;--n>=0;)if(0!=(t=this[n]-e[n]))return t;return 0}function S(e){var t,n=1;return 0!=(t=e>>>16)&&(e=t,n+=16),0!=(t=e>>8)&&(e=t,n+=8),0!=(t=e>>4)&&(e=t,n+=4),0!=(t=e>>2)&&(e=t,n+=2),0!=(t=e>>1)&&(e=t,n+=1),n}function y(){return this.t<=0?0:this.DB*(this.t-1)+S(this[this.t-1]^this.s&this.DM)}function C(e,t){var n;for(n=this.t-1;n>=0;--n)t[n+e]=this[n];for(n=e-1;n>=0;--n)t[n]=0;t.t=this.t+e,t.s=this.s}function w(e,t){for(var n=e;n=0;--n)t[n+r+1]=this[n]>>s|a,a=(this[n]&o)<=0;--n)t[n]=0;t[r]=a,t.t=this.t+r+1,t.s=this.s,t.clamp()}function U(e,t){t.s=this.s;var n=Math.floor(e/this.DB);if(n>=this.t)return void(t.t=0);var i=e%this.DB,s=this.DB-i,o=(1<>i;for(var r=n+1;r>i;i>0&&(t[this.t-n-1]|=(this.s&o)<>=this.DB;if(e.t>=this.DB;i+=this.s}else{for(i+=this.s;n>=this.DB;i-=e.s}t.s=i<0?-1:0,i<-1?t[n++]=this.DV+i:i>0&&(t[n++]=i),t.t=n,t.clamp()}function I(e,t){var i=this.abs(),s=e.abs(),o=i.t;for(t.t=o+s.t;--o>=0;)t[o]=0;for(o=0;o=0;)e[n]=0;for(n=0;n=t.DV&&(e[n+t.t]-=t.DV,e[n+t.t+1]=1)}e.t>0&&(e[e.t-1]+=t.am(n,t[n],e,2*n,0,1)),e.s=0,e.clamp()}function E(e,t,s){var o=e.abs();if(!(o.t<=0)){var r=this.abs();if(r.t0?(o.lShiftTo(l,a),r.lShiftTo(l,s)):(o.copyTo(a),r.copyTo(s));var h=a.t,f=a[h-1];if(0!=f){var d=f*(1<1?a[h-2]>>this.F2:0),p=this.FV/d,g=(1<=0&&(s[s.t++]=1,s.subTo(C,s)),n.ONE.dlShiftTo(h,C),C.subTo(a,a);a.t=0;){var w=s[--m]==f?this.DM:Math.floor(s[m]*p+(s[m-1]+v)*g);if((s[m]+=a.am(0,w,s,y,0,h))0&&s.rShiftTo(l,s),u<0&&n.ZERO.subTo(s,s)}}}function k(e){var t=i();return this.abs().divRemTo(e,null,t),this.s<0&&t.compareTo(n.ZERO)>0&&e.subTo(t,t),t}function R(){if(this.t<1)return 0;var e=this[0];if(0==(1&e))return 0;var t=3&e;return t=t*(2-(15&e)*t)&15,t=t*(2-(255&e)*t)&255,t=t*(2-((65535&e)*t&65535))&65535,t=t*(2-e*t%this.DV)%this.DV,t>0?this.DV-t:-t}function b(e){return 0==this.compareTo(e)}function F(e,t){for(var n=0,i=0,s=Math.min(e.t,this.t);n>=this.DB;if(e.t>=this.DB;i+=this.s}else{for(i+=this.s;n>=this.DB;i+=e.s}t.s=i<0?-1:0,i>0?t[n++]=i:i<-1&&(t[n++]=this.DV+i),t.t=n,t.clamp()}function P(e){var t=i();return this.addTo(e,t),t}function _(e){var t=i();return this.subTo(e,t),t}function B(e){var t=i();return this.multiplyTo(e,t),t}function M(e){var t=i();return this.divRemTo(e,t,null),t}function N(e){this.m=e,this.mp=e.invDigit(),this.mpl=32767&this.mp,this.mph=this.mp>>15,this.um=(1<0&&this.m.subTo(t,t),t}function O(e){var t=i();return e.copyTo(t),this.reduce(t),t}function K(e){for(;e.t<=this.mt2;)e[e.t++]=0;for(var t=0;t>15)*this.mpl&this.um)<<15)&e.DM;for(n=t+this.m.t,e[n]+=this.m.am(0,i,e,t,0,this.m.t);e[n]>=e.DV;)e[n]-=e.DV,e[++n]++}e.clamp(),e.drShiftTo(this.m.t,e),e.compareTo(this.m)>=0&&e.subTo(this.m,e)}function x(e,t){e.squareTo(t),this.reduce(t)}function q(e,t,n){e.multiplyTo(t,n),this.reduce(n)}function H(e,t,n){var s,o=e.bitLength(),r=h(1),a=new N(t);if(o<=0)return r;s=o<18?1:o<48?3:o<144?4:o<768?5:6;var u=new Array,c=3,l=s-1,f=(1<1){var d=i();for(a.sqrTo(u[1],d);c<=f;)u[c]=i(),a.mulTo(d,u[c-2],u[c]),c+=2}var p,g,v=e.t-1,m=!0,y=i();for(o=S(e[v])-1;v>=0;){for(o>=l?p=e[v]>>o-l&f:(p=(e[v]&(1<0&&(p|=e[v-1]>>this.DB+o-l)),c=s;0==(1&p);)p>>=1,--c;if((o-=c)<0&&(o+=this.DB,--v),m)u[p].copyTo(r),m=!1;else{for(;c>1;)a.sqrTo(r,y),a.sqrTo(y,r),c-=2;c>0?a.sqrTo(r,y):(g=r,r=y,y=g),a.mulTo(y,u[p],r)}for(;v>=0&&0==(e[v]&1<0&&void 0!==arguments[0]?arguments[0]:{},i=n.AccessToken;return s(this,t),o(this,e.call(this,i||""))}return r(t,e),t}(u.default);t.default=c},function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function o(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function r(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}t.__esModule=!0;var a=n(6),u=i(a),c=function(e){function t(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},i=n.IdToken;return s(this,t),o(this,e.call(this,i||""))}return r(t,e),t}(u.default);t.default=c},function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}t.__esModule=!0;var s=n(1),o=function(){function e(t){i(this,e),this.jwtToken=t||"",this.payload=this.decodePayload()}return e.prototype.getJwtToken=function(){return this.jwtToken},e.prototype.getExpiration=function(){return this.payload.exp},e.prototype.getIssuedAt=function(){return this.payload.iat},e.prototype.decodePayload=function(){var e=this.jwtToken.split(".")[1];try{return JSON.parse(s.util.base64.decode(e).toString("utf8"))}catch(e){return{}}},e}();t.default=o},function(e,t){"use strict";function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}t.__esModule=!0;/*! 18 | * Copyright 2016 Amazon.com, 19 | * Inc. or its affiliates. All Rights Reserved. 20 | * 21 | * Licensed under the Amazon Software License (the "License"). 22 | * You may not use this file except in compliance with the 23 | * License. A copy of the License is located at 24 | * 25 | * http://aws.amazon.com/asl/ 26 | * 27 | * or in the "license" file accompanying this file. This file is 28 | * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 29 | * CONDITIONS OF ANY KIND, express or implied. See the License 30 | * for the specific language governing permissions and 31 | * limitations under the License. 32 | */ 33 | var i=function(){function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},i=t.RefreshToken;n(this,e),this.token=i||""}return e.prototype.getToken=function(){return this.token},e}();t.default=i},function(e,t,n){"use strict";function i(e){return e&&e.__esModule?e:{default:e}}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}t.__esModule=!0;var o=n(1),r=n(3),a=i(r),u=n(2),c=i(u),l=n(4),h=i(l),f=n(5),d=i(f),p=n(7),g=i(p),v=n(10),m=i(v),S=n(11),y=i(S),C=n(9),w=i(C),A=n(12),U=i(A),T=function(){function e(t){if(s(this,e),null==t||null==t.Username||null==t.Pool)throw new Error("Username and pool information are required.");this.username=t.Username||"",this.pool=t.Pool,this.Session=null,this.client=t.Pool.client,this.signInUserSession=null,this.authenticationFlowType="USER_SRP_AUTH",this.storage=t.Storage||(new U.default).getStorage()}return e.prototype.setSignInUserSession=function(e){this.clearCachedTokens(),this.signInUserSession=e,this.cacheTokens()},e.prototype.getSignInUserSession=function(){return this.signInUserSession},e.prototype.getUsername=function(){return this.username},e.prototype.getAuthenticationFlowType=function(){return this.authenticationFlowType},e.prototype.setAuthenticationFlowType=function(e){this.authenticationFlowType=e},e.prototype.initiateAuth=function(e,t){var n=this,i=e.getAuthParameters();i.USERNAME=this.username,this.client.makeUnauthenticatedRequest("initiateAuth",{AuthFlow:"CUSTOM_AUTH",ClientId:this.pool.getClientId(),AuthParameters:i,ClientMetadata:e.getValidationData()},function(e,i){if(e)return t.onFailure(e);var s=i.ChallengeName,o=i.ChallengeParameters;return"CUSTOM_CHALLENGE"===s?(n.Session=i.Session,t.customChallenge(o)):(n.signInUserSession=n.getCognitoUserSession(i.AuthenticationResult),n.cacheTokens(),t.onSuccess(n.signInUserSession))})},e.prototype.authenticateUser=function(e,t){var n=this,i=new c.default(this.pool.getUserPoolId().split("_")[1]),s=new y.default,r=void 0,u=void 0,l={};null!=this.deviceKey&&(l.DEVICE_KEY=this.deviceKey),l.USERNAME=this.username,i.getLargeAValue(function(c,h){c&&t.onFailure(c),l.SRP_A=h.toString(16),"CUSTOM_AUTH"===n.authenticationFlowType&&(l.CHALLENGE_NAME="SRP_A"),n.client.makeUnauthenticatedRequest("initiateAuth",{AuthFlow:n.authenticationFlowType,ClientId:n.pool.getClientId(),AuthParameters:l,ClientMetadata:e.getValidationData()},function(c,l){if(c)return t.onFailure(c);var h=l.ChallengeParameters;n.username=h.USER_ID_FOR_SRP,r=new a.default(h.SRP_B,16),u=new a.default(h.SALT,16),n.getCachedDeviceKeyAndPassword(),i.getPasswordAuthenticationKey(n.username,e.getPassword(),r,u,function(e,r){e&&t.onFailure(e);var a=s.getNowString(),u=o.util.crypto.hmac(r,o.util.buffer.concat([new o.util.Buffer(n.pool.getUserPoolId().split("_")[1],"utf8"),new o.util.Buffer(n.username,"utf8"),new o.util.Buffer(h.SECRET_BLOCK,"base64"),new o.util.Buffer(a,"utf8")]),"base64","sha256"),c={};c.USERNAME=n.username,c.PASSWORD_CLAIM_SECRET_BLOCK=h.SECRET_BLOCK,c.TIMESTAMP=a,c.PASSWORD_CLAIM_SIGNATURE=u,null!=n.deviceKey&&(c.DEVICE_KEY=n.deviceKey);var f=function e(t,i){return n.client.makeUnauthenticatedRequest("respondToAuthChallenge",t,function(s,o){return s&&"ResourceNotFoundException"===s.code&&s.message.toLowerCase().indexOf("device")!==-1?(c.DEVICE_KEY=null,n.deviceKey=null,n.randomPassword=null,n.deviceGroupKey=null,n.clearCachedDeviceKeyAndPassword(),e(t,i)):i(s,o)})};f({ChallengeName:"PASSWORD_VERIFIER",ClientId:n.pool.getClientId(),ChallengeResponses:c,Session:l.Session},function(e,s){if(e)return t.onFailure(e);var o=s.ChallengeName;if("NEW_PASSWORD_REQUIRED"===o){n.Session=s.Session;var r=null,a=null,u=[],c=i.getNewPasswordRequiredChallengeUserAttributePrefix();if(s.ChallengeParameters&&(r=JSON.parse(s.ChallengeParameters.userAttributes),a=JSON.parse(s.ChallengeParameters.requiredAttributes)),a)for(var l=0;l0&&void 0!==arguments[0]?arguments[0]:{},i=t.Name,s=t.Value;n(this,e),this.Name=i||"",this.Value=s||""}return e.prototype.getValue=function(){return this.Value},e.prototype.setValue=function(e){return this.Value=e,this},e.prototype.getName=function(){return this.Name},e.prototype.setName=function(e){return this.Name=e,this},e.prototype.toString=function(){return JSON.stringify(this)},e.prototype.toJSON=function(){return{Name:this.Name,Value:this.Value}},e}();t.default=i},function(e,t){"use strict";function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}t.__esModule=!0;/*! 50 | * Copyright 2016 Amazon.com, 51 | * Inc. or its affiliates. All Rights Reserved. 52 | * 53 | * Licensed under the Amazon Software License (the "License"). 54 | * You may not use this file except in compliance with the 55 | * License. A copy of the License is located at 56 | * 57 | * http://aws.amazon.com/asl/ 58 | * 59 | * or in the "license" file accompanying this file. This file is 60 | * distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 61 | * CONDITIONS OF ANY KIND, express or implied. See the License 62 | * for the specific language governing permissions and 63 | * limitations under the License. 64 | */ 65 | var i=function(){function e(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},i=t.IdToken,s=t.RefreshToken,o=t.AccessToken,r=t.ClockDrift;if(n(this,e),null==o||null==i)throw new Error("Id token and Access Token must be present.");this.idToken=i,this.refreshToken=s,this.accessToken=o,this.clockDrift=void 0===r?this.calculateClockDrift():r}return e.prototype.getIdToken=function(){return this.idToken},e.prototype.getRefreshToken=function(){return this.refreshToken},e.prototype.getAccessToken=function(){return this.accessToken},e.prototype.getClockDrift=function(){return this.clockDrift},e.prototype.calculateClockDrift=function(){var e=Math.floor(new Date/1e3),t=Math.min(this.accessToken.getIssuedAt(),this.idToken.getIssuedAt());return e-t},e.prototype.isValid=function(){var e=Math.floor(new Date/1e3),t=e-this.clockDrift;return t1){if(o=e({path:"/"},i.defaults,o),"number"==typeof o.expires){var a=new Date;a.setMilliseconds(a.getMilliseconds()+864e5*o.expires),o.expires=a}o.expires=o.expires?o.expires.toUTCString():"";try{r=JSON.stringify(s),/^[\{\[]/.test(r)&&(s=r)}catch(e){}s=n.write?n.write(s,t):encodeURIComponent(String(s)).replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g,decodeURIComponent),t=encodeURIComponent(String(t)),t=t.replace(/%(23|24|26|2B|5E|60|7C)/g,decodeURIComponent),t=t.replace(/[\(\)]/g,escape);var u="";for(var c in o)o[c]&&(u+="; "+c,o[c]!==!0&&(u+="="+o[c]));return document.cookie=t+"="+s+u}t||(r={});for(var l=document.cookie?document.cookie.split("; "):[],h=/(%[0-9A-Z]{2})+/g,f=0;f 2 | 3 | 4 | 5 | Big Mouth 6 | 7 | 8 | 9 | 10 | 13 | 16 | 17 | 18 | 124 | 125 | 397 | 398 | 399 | 400 |
401 |
    402 |
  • 403 | 414 |
  • 415 |
  • 416 | 417 |
  • 418 |
  • 419 | 420 | 421 |
  • 422 |
  • 423 |
    424 | {{dayOfWeek}} 425 |
      426 | {{#restaurants}} 427 |
    • 428 |
        429 |
      • {{name}}
      • 430 |
      • 431 | 432 |
      • 433 |
      434 |
    • 435 | {{/restaurants}} 436 |
    437 |
    438 |
  • 439 |
440 |
441 | 442 |
443 |
444 |
445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 |
456 |
457 |
458 | 459 |
460 |
461 |
462 | 463 | 464 |
465 |
466 |
467 | 468 |
469 |

470 | 471 | You are now registered! 472 |

473 |
474 | 475 |
476 |
477 |
478 | 479 | 480 | 481 | 482 |
483 |
484 |
485 | 486 | 487 | 488 | -------------------------------------------------------------------------------- /template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | Description: 'SAM template for Serverless framework service: ' 4 | Resources: 5 | restaurantsTable: 6 | Type: 'AWS::DynamoDB::Table' 7 | Properties: 8 | TableName: restaurants 9 | AttributeDefinitions: 10 | - AttributeName: name 11 | AttributeType: S 12 | KeySchema: 13 | - AttributeName: name 14 | KeyType: HASH 15 | ProvisionedThroughput: 16 | ReadCapacityUnits: 1 17 | WriteCapacityUnits: 1 18 | GetIndex: 19 | Type: 'AWS::Serverless::Function' 20 | Properties: 21 | Handler: functions/get-index.handler 22 | Runtime: nodejs6.10 23 | CodeUri: >- 24 | /Users/yancui/SourceCode/Personal/manning-aws-lambda-operational-patterns-and-practices/.serverless/big-mouth.zip 25 | MemorySize: 128 26 | Timeout: 3 27 | Policies: 28 | - Effect: Allow 29 | Action: 'dynamodb:scan' 30 | Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants' 31 | Environment: 32 | Variables: 33 | restaurants_api: https://i3c5h755j0.execute-api.us-east-1.amazonaws.com/dev/restaurants 34 | cognito_user_pool_id: us-east-1_DfuAwa0vB 35 | cognito_client_id: 1tf8pb1s1n53p7u608e7ic5ih8 36 | Events: 37 | Event1: 38 | Type: Api 39 | Properties: 40 | Path: / 41 | Method: get 42 | GetRestaurants: 43 | Type: 'AWS::Serverless::Function' 44 | Properties: 45 | Handler: functions/get-restaurants.handler 46 | Runtime: nodejs6.10 47 | CodeUri: >- 48 | /Users/yancui/SourceCode/Personal/manning-aws-lambda-operational-patterns-and-practices/.serverless/big-mouth.zip 49 | MemorySize: 128 50 | Timeout: 3 51 | Policies: 52 | - Effect: Allow 53 | Action: 'dynamodb:scan' 54 | Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants' 55 | Environment: 56 | Variables: 57 | restaurants_table: restaurants 58 | Events: 59 | Event1: 60 | Type: Api 61 | Properties: 62 | Path: /restaurants/ 63 | Method: get 64 | Aws_iamResourcePolicy: 65 | Type: 'AWS::Lambda::Permission' 66 | Properties: 67 | Action: 'lambda:InvokeFunction' 68 | FunctionName: 69 | 'Fn::GetAtt': 70 | - Aws_iam 71 | - Arn 72 | Principal: apigateway.amazonaws.com 73 | SourceAccount: 74 | Ref: 'AWS::AccountId' 75 | SearchRestaurants: 76 | Type: 'AWS::Serverless::Function' 77 | Properties: 78 | Handler: functions/search-restaurants.handler 79 | Runtime: nodejs6.10 80 | CodeUri: >- 81 | /Users/yancui/SourceCode/Personal/manning-aws-lambda-operational-patterns-and-practices/.serverless/big-mouth.zip 82 | MemorySize: 128 83 | Timeout: 3 84 | Policies: 85 | - Effect: Allow 86 | Action: 'dynamodb:scan' 87 | Resource: 'arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/restaurants' 88 | Environment: 89 | Variables: 90 | restaurants_table: restaurants 91 | Events: 92 | Event1: 93 | Type: Api 94 | Properties: 95 | Path: /restaurants/search 96 | Method: post 97 | -------------------------------------------------------------------------------- /tests/steps/given.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AWS = require('aws-sdk'); 4 | AWS.config.region = 'us-east-1'; 5 | const cognito = new AWS.CognitoIdentityServiceProvider(); 6 | const chance = require('chance').Chance(); 7 | 8 | let random_password = () => { 9 | // needs number, special char, upper and lower case 10 | return `${chance.string({ length: 8})}B!gM0uth`; 11 | } 12 | 13 | let an_authenticated_user = function* () { 14 | let userpoolId = process.env.cognito_user_pool_id; 15 | let clientId = process.env.cognito_server_client_id; 16 | 17 | let firstName = chance.first(); 18 | let lastName = chance.last(); 19 | let username = `test-${firstName}-${lastName}-${chance.string({length: 8})}`; 20 | let password = random_password(); 21 | let email = `${firstName}-${lastName}@big-mouth.com`; 22 | 23 | let createReq = { 24 | UserPoolId : userpoolId, 25 | Username : username, 26 | MessageAction : 'SUPPRESS', 27 | TemporaryPassword : password, 28 | UserAttributes : [ 29 | { Name: "given_name", Value: firstName }, 30 | { Name: "family_name", Value: lastName }, 31 | { Name: "email", Value: email } 32 | ] 33 | }; 34 | yield cognito.adminCreateUser(createReq).promise(); 35 | 36 | console.log(`[${username}] - user is created`); 37 | 38 | let req = { 39 | AuthFlow : 'ADMIN_NO_SRP_AUTH', 40 | UserPoolId : userpoolId, 41 | ClientId : clientId, 42 | AuthParameters : { 43 | USERNAME: username, 44 | PASSWORD: password 45 | } 46 | }; 47 | let resp = yield cognito.adminInitiateAuth(req).promise(); 48 | 49 | console.log(`[${username}] - initialised auth flow`); 50 | 51 | let challengeReq = { 52 | UserPoolId : userpoolId, 53 | ClientId : clientId, 54 | ChallengeName : resp.ChallengeName, 55 | Session : resp.Session, 56 | ChallengeResponses : { 57 | USERNAME: username, 58 | NEW_PASSWORD: random_password() 59 | } 60 | }; 61 | let challengeResp = yield cognito.adminRespondToAuthChallenge(challengeReq).promise(); 62 | 63 | console.log(`[${username}] - responded to auth challenge`); 64 | 65 | return { 66 | username, 67 | firstName, 68 | lastName, 69 | idToken: challengeResp.AuthenticationResult.IdToken 70 | }; 71 | }; 72 | 73 | module.exports = { 74 | an_authenticated_user 75 | }; -------------------------------------------------------------------------------- /tests/steps/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const co = require('co'); 5 | const Promise = require('bluebird'); 6 | const aws4 = require('../../lib/aws4'); 7 | const AWS = require('aws-sdk'); 8 | AWS.config.region = 'us-east-1'; 9 | const SSM = new AWS.SSM(); 10 | 11 | let initialized = false; 12 | 13 | const getParameters = co.wrap(function* (keys) { 14 | const prefix = '/bigmouth/dev/'; 15 | const req = { 16 | Names: keys.map(key => `${prefix}${key}`) 17 | } 18 | const resp = yield SSM.getParameters(req).promise(); 19 | return _.reduce(resp.Parameters, function(obj, param) { 20 | obj[param.Name.substr(prefix.length)] = param.Value 21 | return obj; 22 | }, {}) 23 | }); 24 | 25 | let init = co.wrap(function* () { 26 | if (initialized) { 27 | return; 28 | } 29 | 30 | const params = yield getParameters([ 31 | 'cognito_client_id', 32 | 'cognito_user_pool_id', 33 | 'restaurants_api' 34 | ]); 35 | 36 | process.env.restaurants_api = params.restaurants_api; 37 | process.env.restaurants_table = "restaurants"; 38 | process.env.AWS_REGION = "us-east-1"; 39 | process.env.cognito_client_id = params.cognito_client_id; 40 | process.env.cognito_user_pool_id = params.cognito_user_pool_id; 41 | process.env.cognito_server_client_id = "niv7esuaibla0tj5q36b6mvnr"; 42 | process.env.AWS_XRAY_CONTEXT_MISSING = "LOG_ERROR"; 43 | process.env.STAGE = 'dev'; 44 | 45 | yield aws4.init(); 46 | 47 | initialized = true; 48 | }); 49 | 50 | module.exports.init = init; -------------------------------------------------------------------------------- /tests/steps/tearDown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const AWS = require('aws-sdk'); 5 | AWS.config.region = 'us-east-1'; 6 | const cognito = new AWS.CognitoIdentityServiceProvider(); 7 | 8 | let an_authenticated_user = function* (user) { 9 | let req = { 10 | UserPoolId: process.env.cognito_user_pool_id, 11 | Username: user.username 12 | }; 13 | yield cognito.adminDeleteUser(req).promise(); 14 | 15 | console.log(`[${user.username}] - user deleted`); 16 | }; 17 | 18 | module.exports = { 19 | an_authenticated_user 20 | }; -------------------------------------------------------------------------------- /tests/steps/when.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const APP_ROOT = '../../'; 4 | 5 | const _ = require('lodash'); 6 | const co = require('co'); 7 | const Promise = require("bluebird"); 8 | const http = require('superagent-promise')(require('superagent'), Promise); 9 | const aws4 = require('../../lib/aws4'); 10 | const URL = require('url'); 11 | const mode = process.env.TEST_MODE; 12 | 13 | let respondFrom = function (httpRes) { 14 | let contentType = _.get(httpRes, 'headers.content-type', 'application/json'); 15 | let body = 16 | contentType === 'application/json' 17 | ? httpRes.body 18 | : httpRes.text; 19 | 20 | return { 21 | statusCode: httpRes.status, 22 | body: body, 23 | headers: httpRes.headers 24 | }; 25 | } 26 | 27 | let signHttpRequest = (url, httpReq) => { 28 | let urlData = URL.parse(url); 29 | let opts = { 30 | host: urlData.hostname, 31 | path: urlData.pathname 32 | }; 33 | 34 | aws4.sign(opts); 35 | 36 | httpReq 37 | .set('Host', opts.headers['Host']) 38 | .set('X-Amz-Date', opts.headers['X-Amz-Date']) 39 | .set('Authorization', opts.headers['Authorization']); 40 | 41 | if (opts.headers['X-Amz-Security-Token']) { 42 | httpReq.set('X-Amz-Security-Token', opts.headers['X-Amz-Security-Token']); 43 | } 44 | } 45 | 46 | let viaHttp = co.wrap(function* (relPath, method, opts) { 47 | let root = process.env.TEST_ROOT; 48 | let url = `${root}/${relPath}`; 49 | console.log(`invoking via HTTP ${method} ${url}`); 50 | 51 | try { 52 | let httpReq = http(method, url); 53 | 54 | let body = _.get(opts, "body"); 55 | if (body) { 56 | httpReq.send(body); 57 | } 58 | 59 | if (_.get(opts, "iam_auth", false) === true) { 60 | signHttpRequest(url, httpReq); 61 | } 62 | 63 | let authHeader = _.get(opts, "auth"); 64 | if (authHeader) { 65 | httpReq.set('Authorization', authHeader); 66 | } 67 | 68 | let res = yield httpReq; 69 | return respondFrom(res); 70 | } catch (err) { 71 | if (err.status) { 72 | return { 73 | statusCode: err.status, 74 | headers: err.response.headers 75 | }; 76 | } else { 77 | throw err; 78 | } 79 | } 80 | }) 81 | 82 | let viaHandler = (event, functionName) => { 83 | let handler = require(`${APP_ROOT}/functions/${functionName}`).handler; 84 | console.log(`invoking via handler function ${functionName}`); 85 | 86 | return new Promise((resolve, reject) => { 87 | let context = {}; 88 | let callback = function (err, response) { 89 | if (err) { 90 | reject(err); 91 | } else { 92 | let contentType = _.get(response, 'headers.content-type', 'application/json'); 93 | if (response.body && contentType === 'application/json') { 94 | response.body = JSON.parse(response.body); 95 | } 96 | 97 | resolve(response); 98 | } 99 | }; 100 | 101 | handler(event, context, callback); 102 | }); 103 | } 104 | 105 | let we_invoke_get_index = co.wrap(function* () { 106 | let res = 107 | mode === 'handler' 108 | ? yield viaHandler({}, 'get-index') 109 | : yield viaHttp('', 'GET'); 110 | 111 | return res; 112 | }); 113 | 114 | let we_invoke_get_restaurants = co.wrap(function* () { 115 | let res = 116 | mode === 'handler' 117 | ? yield viaHandler({}, 'get-restaurants') 118 | : yield viaHttp('restaurants', 'GET', { iam_auth: true }); 119 | 120 | return res; 121 | }); 122 | 123 | let we_invoke_search_restaurants = co.wrap(function* (user, theme) { 124 | let body = JSON.stringify({ theme }); 125 | let auth = user.idToken; 126 | 127 | let res = 128 | mode === 'handler' 129 | ? viaHandler({ body }, 'search-restaurants') 130 | : viaHttp('restaurants/search', 'POST', { body, auth }) 131 | 132 | return res; 133 | }); 134 | 135 | module.exports = { 136 | we_invoke_get_index, 137 | we_invoke_get_restaurants, 138 | we_invoke_search_restaurants 139 | }; -------------------------------------------------------------------------------- /tests/test_cases/get-index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const expect = require('chai').expect; 5 | const when = require('../steps/when'); 6 | const init = require('../steps/init').init; 7 | const cheerio = require('cheerio'); 8 | 9 | describe(`When we invoke the GET / endpoint`, co.wrap(function* () { 10 | before(co.wrap(function* () { 11 | yield init(); 12 | })); 13 | 14 | it(`Should return the index page with 8 restaurants`, co.wrap(function* () { 15 | let res = yield when.we_invoke_get_index(); 16 | 17 | expect(res.statusCode).to.equal(200); 18 | expect(res.headers['content-type']).to.equal('text/html; charset=UTF-8'); 19 | expect(res.body).to.not.be.null; 20 | 21 | let $ = cheerio.load(res.body); 22 | let restaurants = $('.restaurant', '#restaurantsUl'); 23 | expect(restaurants.length).to.equal(8); 24 | 25 | })); 26 | 27 | })); -------------------------------------------------------------------------------- /tests/test_cases/get-restaurants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const expect = require('chai').expect; 5 | const init = require('../steps/init').init; 6 | const when = require('../steps/when'); 7 | 8 | describe(`When we invoke the GET /restaurants endpoint`, co.wrap(function* () { 9 | before(co.wrap(function* () { 10 | yield init(); 11 | })); 12 | 13 | it(`Should return an array of 8 restaurants`, co.wrap(function* () { 14 | let res = yield when.we_invoke_get_restaurants(); 15 | 16 | expect(res.statusCode).to.equal(200); 17 | expect(res.body).to.have.lengthOf(8); 18 | 19 | for (let restaurant of res.body) { 20 | expect(restaurant).to.have.property('name'); 21 | expect(restaurant).to.have.property('image'); 22 | } 23 | })); 24 | })); -------------------------------------------------------------------------------- /tests/test_cases/search-restaurants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const co = require('co'); 4 | const expect = require('chai').expect; 5 | const init = require('../steps/init').init; 6 | const when = require('../steps/when'); 7 | const given = require('../steps/given'); 8 | const tearDown = require('../steps/tearDown'); 9 | 10 | describe(`Given an authenticated user`, co.wrap(function* () { 11 | let user; 12 | before(co.wrap(function* () { 13 | yield init(); 14 | user = yield given.an_authenticated_user(); 15 | })); 16 | 17 | after(co.wrap(function* () { 18 | yield tearDown.an_authenticated_user(user); 19 | })); 20 | 21 | describe(`When we invoke the POST /restaurants/search endpoint with theme 'cartoon'`, co.wrap(function* () { 22 | it(`Should return an array of 4 restaurants`, co.wrap(function* () { 23 | let res = yield when.we_invoke_search_restaurants(user, 'cartoon'); 24 | 25 | expect(res.statusCode).to.equal(200); 26 | expect(res.body).to.have.lengthOf(4); 27 | 28 | for (let restaurant of res.body) { 29 | expect(restaurant).to.have.property('name'); 30 | expect(restaurant).to.have.property('image'); 31 | } 32 | })); 33 | })); 34 | })); 35 | 36 | --------------------------------------------------------------------------------