├── .github └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── README.md ├── docs ├── assets │ ├── archive-replay1.dio │ ├── archive-replay1.png │ ├── archive-replay2.dio │ ├── archive-replay2.png │ ├── replay-all.gif │ ├── sqs-dlq-redrive.dio │ ├── sqs-dlq-redrive.png │ ├── stepfunctions.dio │ └── stepfunctions.png └── dlq-handling.md ├── evb-local-backend ├── LICENSE.txt ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── EventConsumer.js │ ├── Registration.js │ ├── builders │ │ ├── cloudFormationClient.js │ │ ├── eventBridgeClient.js │ │ ├── localRuleCreator.js │ │ ├── ruleArnCreator.js │ │ └── stackRuleCreator.js │ ├── connect.js │ ├── disconnect.js │ ├── step-functions │ │ ├── CalculateWait.js │ │ ├── PacedCleanup.js │ │ └── PacedDispatch.js │ └── utils │ │ └── http-signed-client.js └── template.yaml ├── images ├── demo-api.gif ├── demo-browse.gif ├── demo-codebinding.gif ├── demo-diagram.gif ├── demo-input.gif ├── demo-local.gif ├── demo-pipes.gif ├── demo-sam.gif └── demo.gif ├── index.html ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json └── src ├── commands ├── api-destination │ ├── api-destination.js │ └── index.js ├── browse │ ├── browse-events.js │ └── index.js ├── code-binding │ ├── code-binding.js │ ├── code-binding.test.js │ ├── index.js │ └── languages │ │ ├── csharp.js │ │ ├── java.js │ │ ├── python.js │ │ ├── swift.js │ │ └── typescript.js ├── diagram │ ├── diagram-builder.js │ ├── index.js │ └── ui │ │ ├── icons.js │ │ └── index.html ├── extract-sam-event │ ├── index.js │ ├── sam-extractor.js │ └── sam-extractor.test.js ├── find-usages │ ├── find-usages.js │ └── index.js ├── input │ ├── index.js │ └── input-transformer-builder.js ├── local │ ├── index.js │ ├── listeners │ │ ├── arnListener.js │ │ ├── localPatternListener.js │ │ ├── stackListener.js │ │ └── websocket.js │ ├── templateParser.js │ └── utils.js ├── pattern │ ├── index.js │ ├── pattern-builder.js │ └── pattern-builder.test.js ├── pipes │ ├── index.js │ ├── pipe-builder.js │ ├── pipes-cfn-schema.json │ └── pipes-config.json ├── replay-dead-letter │ ├── index.js │ ├── replay-lambda.js │ └── replay-util.js ├── replay │ └── index.js ├── shared │ ├── archive-util.js │ ├── auth-helper.js │ ├── eventbridge-util.js │ ├── input-util.js │ ├── sam-config-parser.js │ ├── schema-browser.js │ ├── schema-browser.test.js │ ├── template-parser.js │ └── yaml-wrapper.js └── test-event │ ├── event-tester.js │ └── index.js └── test-input ├── aws.codepipeline@CodePipelinePipelineExecutionStateChange-v1.json └── test-template.yaml /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | # - run: npm test 30 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | # - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | 109 | evb-local-backend/samconfig.toml -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo.gif 2 | demo-*.gif 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cfn-resource-actions.stackName": "serverlessrepo-evb-local" 3 | } -------------------------------------------------------------------------------- /docs/assets/archive-replay1.dio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/assets/archive-replay1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/docs/assets/archive-replay1.png -------------------------------------------------------------------------------- /docs/assets/archive-replay2.dio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /docs/assets/archive-replay2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/docs/assets/archive-replay2.png -------------------------------------------------------------------------------- /docs/assets/replay-all.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/docs/assets/replay-all.gif -------------------------------------------------------------------------------- /docs/assets/sqs-dlq-redrive.dio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /docs/assets/sqs-dlq-redrive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/docs/assets/sqs-dlq-redrive.png -------------------------------------------------------------------------------- /docs/assets/stepfunctions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/docs/assets/stepfunctions.png -------------------------------------------------------------------------------- /docs/dlq-handling.md: -------------------------------------------------------------------------------- 1 | # Lambda Destinations + EventBridge Archive & Replay + evb-cli = :rocket: 2 | 3 | In a pre-EventBridge time a common pub/sub pattern was SNS/SQS. We always added an SQS DLQ to our SQS->Lambda event sources, and when we wanted to redrive it, we could simply add it as a trigger to the original function. The structure of the messages would be the same and the Lambda handler would be able to parse the event: 4 | 5 | With EventBridge we're given the power to transform the input the way we want it. The event doesn't follow the `$.Records[]`-format, which is great. However, the only durable DLQ choice for EventBridge would be an `OnFailure` destination to `SQS`. When we wanted to redrive messages, we needed an intermediary function that fetched the message body from the SQS record and passed it back on EventBridge: 6 | ``` 7 | { 8 | "Records": [ 9 | { 10 | "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", 11 | "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", 12 | "body": "{\"my\": \"payload\"}", <--- this is what we want to forward to the function 13 | "attributes": { 14 | "ApproximateReceiveCount": "1", 15 | "SentTimestamp": "1545082649183", 16 | "SenderId": "AIDAIENQZJOLO23YVJ4VO", 17 | "ApproximateFirstReceiveTimestamp": "1545082649185" 18 | }, 19 | "messageAttributes": {}, 20 | "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", 21 | "eventSource": "aws:sqs", 22 | "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", 23 | "awsRegion": "us-east-2" 24 | }, 25 | [...] 26 | ] 27 | } 28 | ``` 29 | 30 | ![sqs-redrive](assets/sqs-dlq-redrive.png) 31 | 32 | ## Using EventBridge as Lambda destination 33 | 34 | ``` 35 | DemoEventConsumer: 36 | Type: 'AWS::Serverless::Function' 37 | Properties: 38 | Handler: src/app.handler 39 | EventInvokeConfig: 40 | MaximumRetryAttempts: 0 41 | DestinationConfig: 42 | OnFailure: 43 | Type: EventBridge 44 | Destination: !Sub arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/my-event-bus 45 | ``` 46 | 47 | Until EventBridge archives, passing failures to EventBridge meant that you had to have a rule consuming those events, or they'd get lost. 48 | 49 | The event being put on the eventbus in the case of a function failure looks like this (with some fields removed for brevity): 50 | 51 | ``` 52 | { 53 | "detail-type": "Lambda Function Invocation Result - Failure", 54 | "source": "lambda", 55 | "resources": [ 56 | "arn:aws:events:eu-west-1:123456789012:event-bus/myeventbus", 57 | "arn:aws:lambda:eu-west-1:123456789012:function:my-function:$LATEST" 58 | ], 59 | "detail": { 60 | "requestContext": { 61 | "functionArn": "arn:aws:lambda:eu-west-1:123456789012:function:my-function:$LATEST", 62 | "condition": "RetriesExhausted", 63 | "approximateInvokeCount": 1 64 | }, 65 | "requestPayload": { 66 | "time": "2020-11-26T21:06:18Z", 67 | "City": "Bålsta", 68 | "UserId": "12345" 69 | }, 70 | "responseContext": { 71 | "statusCode": 200, 72 | "executedVersion": "$LATEST", 73 | "functionError": "Unhandled" 74 | }, 75 | "responsePayload": { 76 | "errorType": "Error", 77 | "errorMessage": "error!", 78 | "trace": [ 79 | "Error: error!", 80 | " at Runtime.exports.handler (/var/task/src/demoevent.js:6:15)", 81 | " at Runtime.handleOnce (/var/runtime/Runtime.js:66:25)" 82 | ] 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | ## Adding durability to the failed events 89 | Using EventBridge Archives, we can easily create a swallow-all pattern per eventbus: 90 | ``` 91 | FailureArchive: 92 | Type: AWS::Events::Archive 93 | Properties: 94 | ArchiveName: !Sub ${MyEventBus}-errors 95 | EventPattern: 96 | source: 97 | - lambda 98 | detail-type: 99 | - Lambda Function Invocation Result - Failure 100 | RetentionDays: 10 101 | SourceArn: 102 | Fn::GetAtt: 103 | - MyEventBus 104 | - Arn 105 | ``` 106 | 107 | Now all you need to do to make your failed events durable is to add an `OnFailure` destination to your function. Note that this function could be triggered by any asynchronous event source. 108 | 109 | Note that [extra cost](https://aws.amazon.com/eventbridge/pricing/) for archiving applies and should be considered if you have an environment passing vast amounts of events to the OnFailure destination. 110 | 111 | ## Replaying failed events 112 | EventBridge replays lets you replay archived events between two datetimes against either all rules or specified ones. This works great if the archived events match the original event pattern and is suitable to recover from total downtime due to a dead third party service for example, but what if you have a mission critical target function that over the past 3 hours has handled 10 million invocations with a 1% error rate. then you don't want to rerun _all_ 10 million events only to reprocess the 100K faulty ones. 113 | 114 | In this scenario we want to create a replay of _only the erroneous_ events. However when replaying from the `OnFailure`-archived events, the event body looks different and won't match the target rule's event pattern. What we want to invoke the function with is the `$.detail.requestPayload` object. This is the result of any input transformations made by the original target. 115 | 116 | ![sqs-redrive](assets/archive-replay1.png) 117 | 118 | _Illustration of the event pattern mismatch_ 119 | 120 | What we need is another rule with the same target function, but with a different event pattern and input path: 121 | ![sqs-redrive](assets/archive-replay2.png) 122 | 123 | The bottom pattern is the one needed to ingest a replay from the dead letter archive. However, since it's bound to a replay-name, we want it to only exist during the cours eof the replay 124 | 125 | 126 | ## Easy replays with evb-cli 127 | evb-cli provides a solution for both simple replays of all events and replays of events sent to a Lambda OnFailure destination. 128 | 129 | ### Replays of all events 130 | ``` 131 | Usage: evb replay|r [options] 132 | 133 | Starts a replay of events against a specific destination 134 | 135 | Options: 136 | -b, --eventbus [eventbus] The eventbus the archive is stored against (default: "default") 137 | -r, --rule-prefix [rulePrefix] Rule name prefix 138 | -p, --profile [profile] AWS profile to use 139 | -s, --replay-speed [speed] The speed of the replay in % where 0 == all at once and 100 == real time speed 140 | -n, --replay-name [name] The replay name (default: "evb-cli-replay-1606428653058") 141 | --region [region] The AWS region to use. Falls back on AWS_REGION environment variable if not specified 142 | -h, --help output usage information 143 | ``` 144 | 145 | 146 | ![replay all](assets/replay-all.gif) 147 | 148 | This will start a replay at the fastest possible speed of events between the datetimes you chose. Should you want to run the replay at a slower speed, then you can pass `--replay-speed` with a value between 1 and 100, where 100 is 100% of the original duration. For example, if you choose to replay 3 hours of events with 100% speed, then the replay will take 3 hours. If you choose 50, then it'll take 1.5 hours 149 | 150 | ### Replays of failed events 151 | ``` 152 | Usage: evb replay-dead-letter|rdl [options] 153 | 154 | Starts a replay of events against a specific destination 155 | 156 | Options: 157 | -n, --function-name [functionName] Function name 158 | -p, --function-name-prefix [functionNamePrefix] Function name prefix to speed up load 159 | --profile [profile] AWS profile to use 160 | -s, --replay-speed [speed] The speed of the replay in % where 0 == all at once and 100 == real time speed 161 | -n, --replay-name [name] The replay name (default: "evb-cli-replay-1606428700661") 162 | --region [region] The AWS region to use. Falls back on AWS_REGION environment variable if not specified 163 | -h, --help output usage information 164 | ``` 165 | 166 | This works in a similar way to a normal replay, but it creates a temporary rule which is matching the replay and forwards the `$.detail.requestPayload` object. 167 | 168 | When the replay is over this rule and associated permissions are removed. 169 | 170 | ## Paced replays 171 | When adding `--replay-speed <1-100>`, the tool with create a new temporary rule wich is forwarding the replayed events to a StepFunctions state machine: 172 | ![paced replays](assets/stepfunctions.png) 173 | 174 | Steps: 175 | * `CalculateWait`: Calculates the duration of the `Wait` state base don the length of the replay and the `--replay-speed` precentage. 176 | * `Wait`: Wait state holding each event from dispatching based on the set replay speed 177 | * `ActionChoice`: Either cleanup or dispatch. The cleanup execution is started first and finished last 178 | * `CleanUp`: Removes all temporary resources created for the replay 179 | * `Dispatch`: Re-puts the events on the event bus 180 | 181 | *Note that the Step Functions flow will only happen when using `--replay-speed` > 0 or replaying from an OnFailure archive. Both require the [evb-local](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/751354400372/evb-local) >= v0.0.8 backend installed in your AWS account. 182 | 183 | ## Caveats of paced and dead-letter replays 184 | Because the actual replay is targeting the Step Functions state machine and is actually dispatched back to the eventbus in the Dispatch step, the `replay-name` field is lost. This is due to limitattions in the SDK where you can't explicitly set it. Instead we're passing the replay ARN in the `resources` array. 185 | 186 | To make sure only the target receives the event we're modifying the `source` on the temporary rule to match on the `replay-name`. If you're using the `source` for any logic in your target, then be aware of this. 187 | 188 | * Note that set-speed replays for normal non-dead-letter replays will target all rules on the bus. This issue is described in #17 * -------------------------------------------------------------------------------- /evb-local-backend/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Lob.com 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /evb-local-backend/README.md: -------------------------------------------------------------------------------- 1 | # EventBridge CLI backend 2 | 3 | This application is used to forward EventBridge events to developer machines using API Gateway V2's websockets. 4 | 5 | For more info on usage, see [evb-cli's readme](https://github.com/mhlabs/evb-cli#readme) 6 | -------------------------------------------------------------------------------- /evb-local-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evb-local", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "jest": { 7 | "collectCoverage": false, 8 | "collectCoverageFrom": [ 9 | "src/**/*.js" 10 | ] 11 | }, 12 | "dependencies": { 13 | "aws-sdk": "^2.794.0", 14 | "node-cache": "^5.1.0", 15 | "uuid": "^8.0.0", 16 | "ws": "^7.2.5" 17 | }, 18 | "devDependencies": { 19 | "eslint": "^6.8.0", 20 | "eslint-config-airbnb-base": "*", 21 | "eslint-config-prettier": "^6.0.0", 22 | "eslint-plugin-import": "^2.18.0", 23 | "eslint-plugin-jest": "*", 24 | "eslint-plugin-prettier": "^3.1.0", 25 | "husky": "*", 26 | "jest": "*", 27 | "lint-staged": "*", 28 | "nodemon": "*", 29 | "prettier": "^2.0.4" 30 | }, 31 | "scripts": { 32 | "test": "jest", 33 | "coverage": "jest --coverage", 34 | "coverage-percentage": "jest --coverage --coverageReporters=text-summary 2>/dev/null | grep 'Functions :' | cut -d ':' -f 2 | cut -d '%' -f 1 | xargs | tr -d '\n'", 35 | "eslint": "eslint", 36 | "start": "nodemon ./src/apiLocal.js", 37 | "debug": "node --inspect-brk=9229 ./src/apiLocal.js" 38 | }, 39 | "nodemonConfig": { 40 | "ignore": [ 41 | "**/*.json" 42 | ] 43 | }, 44 | "husky": { 45 | "hooks": { 46 | "pre-commit": "lint-staged" 47 | } 48 | }, 49 | "lint-staged": { 50 | "*.{js,css,json,md}": [ 51 | "prettier --write", 52 | "git add" 53 | ] 54 | }, 55 | "keywords": [], 56 | "author": "", 57 | "license": "ISC" 58 | } 59 | -------------------------------------------------------------------------------- /evb-local-backend/src/EventConsumer.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const NodeCache = require("node-cache"); 3 | const cache = new NodeCache({ stdTTL: 10 }); 4 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 5 | const apigateway = new AWS.ApiGatewayManagementApi({ 6 | endpoint: `https://${process.env.ApiId}.execute-api.${process.env.AWS_REGION}.amazonaws.com/Prod/`, 7 | }); 8 | 9 | async function receiver(event, context) { 10 | const cacheKey = `connections_ ${event.Token}`; 11 | let connections = cache.get(cacheKey); 12 | 13 | if (!connections) { 14 | connections = ( 15 | await dynamodb 16 | .query({ 17 | TableName: process.env.ConnectionsTable, 18 | IndexName: "TokenGSI", 19 | KeyConditionExpression: "#token = :token", 20 | ExpressionAttributeNames: { 21 | "#token": "token", 22 | }, 23 | ExpressionAttributeValues: { 24 | ":token": event.Token, 25 | }, 26 | }) 27 | .promise() 28 | ).Items; 29 | cache.set(cacheKey, connections); 30 | } 31 | console.log(connections); 32 | const tasks = []; 33 | for (const connection of connections) { 34 | try { 35 | tasks.push( 36 | apigateway 37 | .postToConnection({ 38 | ConnectionId: connection.id, 39 | Data: JSON.stringify(event), 40 | }) 41 | .promise() 42 | ); 43 | } catch (ex) { 44 | console.log(ex); 45 | } 46 | } 47 | await Promise.all(tasks.map((p) => p.catch((e) => e))); 48 | 49 | return "Success"; 50 | } 51 | 52 | module.exports = { 53 | receiver, 54 | }; 55 | -------------------------------------------------------------------------------- /evb-local-backend/src/Registration.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const localRuleCreator = require("./builders/localRuleCreator"); 3 | const stackRuleCreator = require("./builders/stackRuleCreator"); 4 | const ruleArnCreator = require("./builders/ruleArnCreator"); 5 | 6 | const apigateway = new AWS.ApiGatewayManagementApi({ 7 | endpoint: `https://${process.env.ApiId}.execute-api.${process.env.AWS_REGION}.amazonaws.com/Prod/`, 8 | }); 9 | 10 | const dynamoDb = new AWS.DynamoDB.DocumentClient(); 11 | const eventBridge = new AWS.EventBridge(); 12 | 13 | async function handler(event, context) { 14 | console.log(event); 15 | const body = JSON.parse(event.body); 16 | const token = body.token; 17 | const localRule = body.localRule; 18 | const ruleArn = body.ruleArn; 19 | const replaySettings = body.replaySettings; 20 | console.log("Rulearn", ruleArn); 21 | let ruleNames; 22 | if (localRule) { 23 | ruleNames = await localRuleCreator.create(event); 24 | } else if (ruleArn) { 25 | ruleNames = await ruleArnCreator.create(event); 26 | } else { 27 | ruleNames = await stackRuleCreator.create(event); 28 | } 29 | if (ruleNames.error) { 30 | await apigateway 31 | .postToConnection({ 32 | ConnectionId: event.requestContext.connectionId, 33 | Data: ruleNames.error.toString(), 34 | }) 35 | .promise(); 36 | return { statusCode: 500 }; 37 | } 38 | await dynamoDb 39 | .put({ 40 | Item: { 41 | id: event.requestContext.connectionId, 42 | rules: ruleNames, 43 | token: token, 44 | }, 45 | TableName: process.env.ConnectionsTable, 46 | }) 47 | .promise(); 48 | 49 | await apigateway 50 | .postToConnection({ 51 | ConnectionId: event.requestContext.connectionId, 52 | Data: JSON.stringify({ 53 | Status: 54 | "Connected!" + 55 | (replaySettings 56 | ? `\n\nReplay starting. This can take a few minutes. You can follow the progress here: https://${process.env.AWS_REGION}.console.aws.amazon.com/events/home?region=${process.env.AWS_REGION}#/replay/${replaySettings.ReplayName}` 57 | : ""), 58 | Rules: ruleNames, 59 | EvbLocalRegistration: true, 60 | }), 61 | }) 62 | .promise(); 63 | if (replaySettings) { 64 | replaySettings.Destination.FilterArns = ruleNames.map( 65 | (p) => 66 | `arn:aws:events:${process.env.AWS_REGION}:${process.env.AccountId}:rule/${replaySettings.EventBusName}/${p}` 67 | ); 68 | delete replaySettings.EventBusName; 69 | const resp = await eventBridge.startReplay(replaySettings).promise(); 70 | } 71 | 72 | return { statusCode: 200 }; 73 | } 74 | 75 | exports.handler = handler; 76 | -------------------------------------------------------------------------------- /evb-local-backend/src/builders/cloudFormationClient.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const cloudFormation = new AWS.CloudFormation(); 3 | 4 | async function getEventConsumerName() { 5 | const evbLocalStack = await cloudFormation 6 | .listStackResources({ StackName: process.env.StackName }) 7 | .promise(); 8 | const eventConsumerName = evbLocalStack.StackResourceSummaries.filter( 9 | (p) => p.LogicalResourceId === "EventConsumer" 10 | )[0].PhysicalResourceId; 11 | return eventConsumerName; 12 | } 13 | 14 | async function getStackResources(stackName) { 15 | const stackResourcesResponse = await cloudFormation 16 | .listStackResources({ StackName: stackName }) 17 | .promise(); 18 | let nextToken = stackResourcesResponse.NextToken; 19 | while (nextToken) { 20 | const more = await cloudFormation 21 | .listStackResources({ StackName: stackName, NextToken: nextToken }) 22 | .promise(); 23 | stackResourcesResponse.StackResourceSummaries.push( 24 | ...more.StackResourceSummaries 25 | ); 26 | nextToken = more.NextToken; 27 | } 28 | return stackResourcesResponse; 29 | } 30 | 31 | module.exports = { 32 | getEventConsumerName, 33 | getStackResources, 34 | }; 35 | -------------------------------------------------------------------------------- /evb-local-backend/src/builders/eventBridgeClient.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const { v4: uuidv4 } = require("uuid"); 3 | const eventBridge = new AWS.EventBridge(); 4 | 5 | async function putRule(busName, input, ruleName) { 6 | await eventBridge 7 | .putRule({ 8 | EventBusName: busName, 9 | EventPattern: input.EventPattern, 10 | Name: ruleName, 11 | State: "ENABLED", 12 | ScheduleExpression: input.ScheduleExpression, 13 | }) 14 | .promise(); 15 | } 16 | 17 | async function putTargets(busName, ruleName, targets) { 18 | await eventBridge 19 | .putTargets({ 20 | EventBusName: busName, 21 | Rule: ruleName, 22 | Targets: targets, 23 | }) 24 | .promise(); 25 | } 26 | 27 | function createTarget(eventConsumerName, target, targetLogicalId, token) { 28 | const t = { 29 | Id: `${eventConsumerName}-${uuidv4()}`.substring(0, 64), 30 | Arn: `arn:aws:lambda:${process.env.AWS_REGION}:${process.env.AccountId}:function:${eventConsumerName}`, 31 | Input: target.Input, 32 | InputPath: target.InputPath, 33 | }; 34 | if (target.InputTransformer) { 35 | t.InputTransformer = target.InputTransformer; 36 | t.InputTransformer.InputTemplate = 37 | `{ \"Target\": \"${targetLogicalId}\", \"Token\": \"${token}\", \"Body\": ` + 38 | target.InputTransformer.InputTemplate + 39 | "}"; 40 | } else { 41 | t.InputTransformer = { 42 | InputPathsMap: { Body: t.InputPath || "$" }, 43 | InputTemplate: `{ "Target": "${targetLogicalId}", "Token": "${token}", "Body": }`, 44 | }; 45 | if (t.InputPath) { 46 | t.InputPath = null; 47 | } 48 | } 49 | return t; 50 | } 51 | 52 | function getRuleName(busName) { 53 | return `evb-local-${busName 54 | .replace(/\//g, "-") 55 | .substring(0, 30)}-${new Date().getTime()}`; 56 | } 57 | 58 | module.exports = { 59 | putRule, 60 | createTarget, 61 | putTargets, 62 | getRuleName, 63 | }; 64 | -------------------------------------------------------------------------------- /evb-local-backend/src/builders/localRuleCreator.js: -------------------------------------------------------------------------------- 1 | const eventBridgeClient = require("./eventBridgeClient"); 2 | const cloudFormationClient = require("./cloudFormationClient"); 3 | 4 | async function create(event) { 5 | const body = JSON.parse(event.body); 6 | const token = body.token; 7 | const localRule = body.localRule; 8 | 9 | const ruleName = eventBridgeClient.getRuleName(localRule.EventBusName); 10 | const eventConsumerName = await cloudFormationClient.getEventConsumerName(); 11 | 12 | try { 13 | await eventBridgeClient.putRule( 14 | localRule.EventBusName, 15 | localRule, 16 | ruleName 17 | ); 18 | console.log(eventConsumerName, localRule, body, token); 19 | const targets = []; 20 | if (localRule.Targets) { 21 | for (const target of localRule.Targets) { 22 | targets.push( 23 | eventBridgeClient.createTarget( 24 | eventConsumerName, 25 | target, 26 | body.targetId, 27 | token 28 | ) 29 | ); 30 | } 31 | } else { 32 | targets.push( 33 | eventBridgeClient.createTarget( 34 | eventConsumerName, 35 | localRule, 36 | localRule.Target, 37 | token 38 | ) 39 | ); 40 | } 41 | 42 | await eventBridgeClient.putTargets( 43 | localRule.EventBusName, 44 | ruleName, 45 | targets 46 | ); 47 | } catch (err) { 48 | return { error: err }; 49 | } 50 | 51 | return [ruleName]; 52 | } 53 | 54 | module.exports = { 55 | create, 56 | }; 57 | -------------------------------------------------------------------------------- /evb-local-backend/src/builders/ruleArnCreator.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const eventBridgeClient = require("./eventBridgeClient"); 3 | const cloudFormationClient = require("./cloudFormationClient"); 4 | const eventBridge = new AWS.EventBridge(); 5 | 6 | async function create(event) { 7 | const body = JSON.parse(event.body); 8 | const token = body.token; 9 | const ruleArn = body.ruleArn; 10 | const eventConsumerName = await cloudFormationClient.getEventConsumerName(); 11 | const split = ruleArn.split("/"); 12 | let busName; 13 | let ruleName; 14 | if (split.length == 2) { 15 | busName = "default"; 16 | ruleName = split[1]; 17 | } else { 18 | busName = split[1]; 19 | ruleName = split[2]; 20 | } 21 | const ruleNames = []; 22 | 23 | if (ruleName) { 24 | const ruleResponse = await eventBridge 25 | .describeRule({ EventBusName: busName, Name: ruleName }) 26 | .promise(); 27 | const ruleTargets = await eventBridge 28 | .listTargetsByRule({ EventBusName: busName, Rule: ruleResponse.Name }) 29 | .promise(); 30 | const newRuleName = eventBridgeClient.getRuleName(busName); 31 | ruleNames.push(newRuleName); 32 | if (body.replaySettings && ruleResponse.EventPattern) { 33 | const pattern = JSON.parse(ruleResponse.EventPattern); 34 | pattern["replay-name"] = [body.replaySettings.ReplayName]; 35 | ruleResponse.EventPattern = JSON.stringify(pattern); 36 | } 37 | await eventBridgeClient.putRule(busName, ruleResponse, newRuleName); 38 | const targets = []; 39 | for (const target of ruleTargets.Targets) { 40 | const targetLogicalId = body.target || target.Arn || "Unknown target"; 41 | 42 | targets.push( 43 | eventBridgeClient.createTarget( 44 | eventConsumerName, 45 | target, 46 | targetLogicalId, 47 | token 48 | ) 49 | ); 50 | } 51 | await eventBridgeClient.putTargets(busName, newRuleName, targets); 52 | } 53 | return ruleNames; 54 | } 55 | 56 | module.exports = { 57 | create, 58 | }; 59 | -------------------------------------------------------------------------------- /evb-local-backend/src/builders/stackRuleCreator.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const eventBridgeClient = require("./eventBridgeClient"); 3 | const cloudFormationClient = require("./cloudFormationClient"); 4 | const eventBridge = new AWS.EventBridge(); 5 | 6 | async function create(event) { 7 | const body = JSON.parse(event.body); 8 | const token = body.token; 9 | const stackName = body.stack; 10 | const eventConsumerName = await cloudFormationClient.getEventConsumerName(); 11 | const stackResourcesResponse = await cloudFormationClient.getStackResources( 12 | stackName 13 | ); 14 | const ruleNames = []; 15 | for (const resource of stackResourcesResponse.StackResourceSummaries.filter( 16 | (p) => p.ResourceType.startsWith("AWS::Events::Rule") 17 | )) { 18 | const busName = resource.PhysicalResourceId.split("|")[0]; 19 | const ruleName = resource.PhysicalResourceId.split("|")[1]; 20 | if (ruleName) { 21 | const ruleResponse = await eventBridge 22 | .describeRule({ EventBusName: busName, Name: ruleName }) 23 | .promise(); 24 | const ruleTargets = await eventBridge 25 | .listTargetsByRule({ EventBusName: busName, Rule: ruleResponse.Name }) 26 | .promise(); 27 | const newRuleName = eventBridgeClient.getRuleName(busName); 28 | ruleNames.push(newRuleName); 29 | if (body.replayName) { 30 | ruleResponse.EventPattern["replay-name"] = 31 | body.replaySettings.ReplayName; 32 | } 33 | await eventBridgeClient.putRule(busName, ruleResponse, newRuleName); 34 | const targets = []; 35 | for (const target of ruleTargets.Targets) { 36 | const targetPhysicalId = target.Arn.split(":").slice(-1)[0]; 37 | const targetLogicalIds = stackResourcesResponse.StackResourceSummaries.filter( 38 | (p) => p.PhysicalResourceId === targetPhysicalId 39 | ); 40 | const targetLogicalId = 41 | targetLogicalIds && targetLogicalIds.length 42 | ? targetLogicalIds[0].LogicalResourceId 43 | : targetPhysicalId || "Unknown target"; 44 | 45 | targets.push( 46 | eventBridgeClient.createTarget( 47 | eventConsumerName, 48 | target, 49 | targetLogicalId, 50 | token 51 | ) 52 | ); 53 | } 54 | await eventBridgeClient.putTargets(busName, newRuleName, targets); 55 | } 56 | } 57 | return ruleNames; 58 | } 59 | 60 | module.exports = { 61 | create, 62 | }; 63 | -------------------------------------------------------------------------------- /evb-local-backend/src/connect.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | 3 | const dynamoDb = new AWS.DynamoDB.DocumentClient(); 4 | async function handler(event, context, callback) { 5 | await dynamoDb 6 | .put({ 7 | Item: { id: event.requestContext.connectionId }, 8 | TableName: process.env.ConnectionsTable, 9 | }) 10 | .promise(); 11 | 12 | callback(null, { statusCode: 200, body: "Connected!" }); 13 | } 14 | 15 | exports.handler = handler; 16 | -------------------------------------------------------------------------------- /evb-local-backend/src/disconnect.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const dynamoDb = new AWS.DynamoDB.DocumentClient(); 3 | const eventBridge = new AWS.EventBridge(); 4 | async function handler(event, context) { 5 | const connection = await dynamoDb 6 | .get({ 7 | Key: { id: event.requestContext.connectionId }, 8 | TableName: process.env.ConnectionsTable, 9 | }) 10 | .promise(); 11 | await dynamoDb 12 | .delete({ 13 | Key: { id: event.requestContext.connectionId }, 14 | TableName: process.env.ConnectionsTable, 15 | }) 16 | .promise(); 17 | console.log(JSON.stringify(connection)); 18 | for (const rule of connection.Item.rules) { 19 | const busName = rule.split("-")[2]; 20 | const targets = await eventBridge 21 | .listTargetsByRule({ EventBusName: busName, Rule: rule }) 22 | .promise(); 23 | await eventBridge 24 | .removeTargets({ 25 | EventBusName: busName, 26 | Rule: rule, 27 | Ids: targets.Targets.map((p) => p.Id), 28 | }) 29 | .promise(); 30 | await eventBridge 31 | .deleteRule({ EventBusName: busName, Name: rule }) 32 | .promise(); 33 | } 34 | return { statusCode: 200, body: "Disconnected!" }; 35 | } 36 | 37 | exports.handler = handler; 38 | -------------------------------------------------------------------------------- /evb-local-backend/src/step-functions/CalculateWait.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function (event, context) { 2 | console.log(event); 3 | const eventTime = Date.parse(event.OriginalEvent.time); 4 | const startTime = Date.parse(event.StartTime); 5 | const speed = event.ReplaySpeed / 100; 6 | console.log(eventTime, startTime, speed); 7 | const waitSeconds = Math.round((speed * (eventTime - startTime)) / 1000); 8 | 9 | event.waitSeconds = waitSeconds; 10 | return event; 11 | }; 12 | -------------------------------------------------------------------------------- /evb-local-backend/src/step-functions/PacedCleanup.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const eventbridge = new AWS.EventBridge(); 3 | const lambda = new AWS.Lambda(); 4 | const iam = new AWS.IAM(); 5 | exports.handler = async function (event, context) { 6 | for (const rule of event.Rules) { 7 | const bus = event.EventBusName; 8 | const targets = await eventbridge 9 | .listTargetsByRule({ 10 | EventBusName: bus, 11 | Rule: rule, 12 | }) 13 | .promise(); 14 | await eventbridge 15 | .removeTargets({ 16 | Ids: targets.Targets.map((p) => p.Id), 17 | Rule: rule, 18 | EventBusName: bus, 19 | }) 20 | .promise(); 21 | await eventbridge.deleteRule({ EventBusName: bus, Name: rule }).promise(); 22 | } 23 | for (const item of event.Policies) { 24 | await iam 25 | .deleteRolePolicy({ 26 | RoleName: item.roleName, 27 | PolicyName: item.policyName, 28 | }) 29 | .promise(); 30 | } 31 | for (const item of event.Permissions) { 32 | await lambda 33 | .removePermission({ 34 | FunctionName: item.functionName, 35 | StatementId: item.statementId, 36 | }) 37 | .promise(); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /evb-local-backend/src/step-functions/PacedDispatch.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const eventbridge = new AWS.EventBridge(); 3 | 4 | exports.handler = async function (event, context) { 5 | const originalEvent = event.OriginalEvent; 6 | originalEvent.source = event.DispatchSource; 7 | console.log(event); 8 | const entry = { 9 | Detail: JSON.stringify(originalEvent.detail), 10 | Source: event.DispatchSource, 11 | Time: originalEvent.time, 12 | EventBusName: event.EventBusName, 13 | DetailType: originalEvent["detail-type"], 14 | Resources: [ 15 | ...originalEvent.resources, 16 | `arn:aws:events:${process.env.AWS_REGION}:${process.env.AccountId}:archive/${event.ReplayName}`, 17 | ], 18 | }; 19 | 20 | console.log(entry); 21 | await eventbridge 22 | .putEvents({ 23 | Entries: [ 24 | entry 25 | ], 26 | }) 27 | .promise(); 28 | }; 29 | -------------------------------------------------------------------------------- /evb-local-backend/src/utils/http-signed-client.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const aws4 = require("aws4"); 3 | 4 | const baseurl = process.env.ApiBaseUrl; 5 | const region = process.env.AWS_REGION || "eu-west-1"; 6 | 7 | const post = async (path, data) => { 8 | const request = buildRequest("POST", path, data); 9 | const signedRequest = signRequest(request); 10 | return axios(signedRequest); 11 | }; 12 | 13 | const get = async (path) => { 14 | const request = buildRequest("GET", path); 15 | const signedRequest = signRequest(request); 16 | return axios(signedRequest); 17 | }; 18 | 19 | const signRequest = (request) => { 20 | const signedRequest = aws4.sign(request); 21 | delete signedRequest.headers["Host"]; 22 | delete signedRequest.headers["Content-Length"]; 23 | return signedRequest; 24 | }; 25 | 26 | const buildRequest = (method, path, data) => { 27 | if (!baseurl) { 28 | throw Error('Environment variable "ApiBaseUrl" is not set!'); 29 | } 30 | 31 | const request = { 32 | host: baseurl, 33 | method: method, 34 | url: `https://${baseurl}/${path}`, 35 | path: path, 36 | region: region, 37 | headers: { 38 | "content-type": "application/json", 39 | }, 40 | service: "execute-api", 41 | }; 42 | 43 | if (data) { 44 | const body = JSON.stringify(data); 45 | request["data"] = data; 46 | request["body"] = body; 47 | } 48 | 49 | return request; 50 | }; 51 | 52 | module.exports = { 53 | post: post, 54 | get: get, 55 | }; 56 | -------------------------------------------------------------------------------- /evb-local-backend/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Transform: 3 | - 'AWS::Serverless-2016-10-31' 4 | Metadata: 5 | 'AWS::ServerlessRepo::Application': 6 | Name: evb-local 7 | Description: Backend for evb-cli's local debugging 8 | Author: mhlabs 9 | SpdxLicenseId: MIT 10 | LicenseUrl: LICENSE.txt 11 | ReadmeUrl: README.md 12 | Labels: 13 | - eventbridge 14 | - evb-cli 15 | - debugging 16 | HomePageUrl: 'https://github.com/mhlabs/evb-cli#readme' 17 | SemanticVersion: 0.0.8 18 | SourceCodeUrl: 'https://github.com/mhlabs/evb-cli' 19 | Resources: 20 | OnConnectFunction: 21 | Type: 'AWS::Serverless::Function' 22 | Properties: 23 | CodeUri: . 24 | Handler: src/connect.handler 25 | MemorySize: 256 26 | Runtime: nodejs12.x 27 | Environment: 28 | Variables: 29 | ConnectionsTable: !Ref ConnectionsTable 30 | Policies: 31 | - DynamoDBCrudPolicy: 32 | TableName: !Ref ConnectionsTable 33 | RegistrationPermission: 34 | Type: 'AWS::Lambda::Permission' 35 | DependsOn: 36 | - WebSocket 37 | - RegistrationFunction 38 | Properties: 39 | Action: 'lambda:InvokeFunction' 40 | FunctionName: !Ref RegistrationFunction 41 | Principal: apigateway.amazonaws.com 42 | OnConnectPermission: 43 | Type: 'AWS::Lambda::Permission' 44 | DependsOn: 45 | - WebSocket 46 | - OnConnectFunction 47 | Properties: 48 | Action: 'lambda:InvokeFunction' 49 | FunctionName: !Ref OnConnectFunction 50 | Principal: apigateway.amazonaws.com 51 | OnDisconnectFunction: 52 | Type: 'AWS::Serverless::Function' 53 | Properties: 54 | CodeUri: . 55 | Handler: src/disconnect.handler 56 | MemorySize: 256 57 | Runtime: nodejs12.x 58 | Environment: 59 | Variables: 60 | ConnectionsTable: !Ref ConnectionsTable 61 | Policies: 62 | - Version: 2012-10-17 63 | Statement: 64 | - Sid: Statement1 65 | Effect: Allow 66 | Action: 67 | - 'events:DeleteRule' 68 | - 'events:ListTargetsByRule' 69 | - 'events:RemoveTargets' 70 | Resource: 71 | - '*' 72 | - DynamoDBCrudPolicy: 73 | TableName: !Ref ConnectionsTable 74 | OnDisconnectPermission: 75 | Type: 'AWS::Lambda::Permission' 76 | DependsOn: 77 | - WebSocket 78 | - OnDisconnectFunction 79 | Properties: 80 | Action: 'lambda:InvokeFunction' 81 | FunctionName: !Ref OnDisconnectFunction 82 | Principal: apigateway.amazonaws.com 83 | RegistrationFunction: 84 | Type: 'AWS::Serverless::Function' 85 | Properties: 86 | CodeUri: . 87 | Handler: src/Registration.handler 88 | MemorySize: 512 89 | Timeout: 60 90 | Runtime: nodejs12.x 91 | Environment: 92 | Variables: 93 | ConnectionsTable: !Ref ConnectionsTable 94 | StackName: !Ref 'AWS::StackName' 95 | AccountId: !Ref 'AWS::AccountId' 96 | ApiId: !Ref WebSocket 97 | Policies: 98 | - AmazonAPIGatewayInvokeFullAccess 99 | - DynamoDBCrudPolicy: 100 | TableName: !Ref ConnectionsTable 101 | - Version: 2012-10-17 102 | Statement: 103 | - Sid: Statement1 104 | Effect: Allow 105 | Action: 106 | - 'events:DescribeRule' 107 | - 'events:ListTargetsByRule' 108 | - 'events:PutRule' 109 | - 'events:PutTargets' 110 | - 'events:StartReplay' 111 | Resource: 112 | - '*' 113 | - Version: 2012-10-17 114 | Statement: 115 | - Sid: Statement1 116 | Effect: Allow 117 | Action: 118 | - 'cloudformation:ListStackResources' 119 | Resource: 120 | - '*' 121 | ConnectionsTable: 122 | Type: 'AWS::DynamoDB::Table' 123 | Properties: 124 | AttributeDefinitions: 125 | - AttributeName: id 126 | AttributeType: S 127 | - AttributeName: token 128 | AttributeType: S 129 | KeySchema: 130 | - AttributeName: id 131 | KeyType: HASH 132 | GlobalSecondaryIndexes: 133 | - IndexName: TokenGSI 134 | KeySchema: 135 | - AttributeName: token 136 | KeyType: HASH 137 | Projection: 138 | ProjectionType: ALL 139 | BillingMode: PAY_PER_REQUEST 140 | WebSocket: 141 | Type: 'AWS::ApiGatewayV2::Api' 142 | Properties: 143 | Name: !Sub '${AWS::StackName}-WebSocket' 144 | ProtocolType: WEBSOCKET 145 | RouteSelectionExpression: $request.body.action 146 | ConnectIntegration: 147 | Type: 'AWS::ApiGatewayV2::Integration' 148 | Properties: 149 | ApiId: !Ref WebSocket 150 | Description: Connect Integration 151 | IntegrationType: AWS_PROXY 152 | IntegrationUri: !Sub >- 153 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnConnectFunction.Arn}/invocations 154 | ConnectRoute: 155 | Type: 'AWS::ApiGatewayV2::Route' 156 | Properties: 157 | ApiId: !Ref WebSocket 158 | RouteKey: $connect 159 | AuthorizationType: NONE 160 | OperationName: ConnectRoute 161 | Target: !Join 162 | - / 163 | - - integrations 164 | - !Ref ConnectIntegration 165 | RegistrationRoute: 166 | Type: 'AWS::ApiGatewayV2::Route' 167 | Properties: 168 | ApiId: !Ref WebSocket 169 | RouteKey: register 170 | AuthorizationType: NONE 171 | Target: !Join 172 | - / 173 | - - integrations 174 | - !Ref RegistrationIntegration 175 | RegistrationIntegration: 176 | Type: 'AWS::ApiGatewayV2::Integration' 177 | Properties: 178 | ApiId: !Ref WebSocket 179 | Description: Registration Integration 180 | IntegrationType: AWS_PROXY 181 | IntegrationUri: !Sub >- 182 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RegistrationFunction.Arn}/invocations 183 | DisconnectRoute: 184 | Type: 'AWS::ApiGatewayV2::Route' 185 | Properties: 186 | ApiId: !Ref WebSocket 187 | RouteKey: $disconnect 188 | AuthorizationType: NONE 189 | Target: !Join 190 | - / 191 | - - integrations 192 | - !Ref DisconnectIntegration 193 | DisconnectIntegration: 194 | Type: 'AWS::ApiGatewayV2::Integration' 195 | Properties: 196 | ApiId: !Ref WebSocket 197 | Description: Disconnect Integration 198 | IntegrationType: AWS_PROXY 199 | IntegrationUri: !Sub >- 200 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations 201 | Deployment: 202 | Type: 'AWS::ApiGatewayV2::Deployment' 203 | DependsOn: 204 | - RegistrationRoute 205 | - ConnectRoute 206 | - DisconnectRoute 207 | Properties: 208 | ApiId: !Ref WebSocket 209 | Stage: 210 | Type: 'AWS::ApiGatewayV2::Stage' 211 | Properties: 212 | StageName: Prod 213 | Description: Prod Stage 214 | DeploymentId: !Ref Deployment 215 | ApiId: !Ref WebSocket 216 | EventConsumer: 217 | Type: 'AWS::Serverless::Function' 218 | Properties: 219 | CodeUri: . 220 | Runtime: nodejs12.x 221 | Handler: src/EventConsumer.receiver 222 | MemorySize: 512 223 | Timeout: 10 224 | ReservedConcurrentExecutions: 10 225 | Policies: 226 | - AmazonAPIGatewayInvokeFullAccess 227 | - DynamoDBReadPolicy: 228 | TableName: !Ref ConnectionsTable 229 | Environment: 230 | Variables: 231 | ApiId: !Ref WebSocket 232 | ConnectionsTable: !Ref ConnectionsTable 233 | AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1 234 | EventConsumerPermission: 235 | Type: 'AWS::Lambda::Permission' 236 | Properties: 237 | Action: 'lambda:InvokeFunction' 238 | FunctionName: !Ref EventConsumer 239 | Principal: events.amazonaws.com 240 | PacedReplayStateMachine: 241 | Type: 'AWS::Serverless::StateMachine' 242 | Properties: 243 | Name: evb-cli-paced-replays 244 | Definition: 245 | StartAt: CalculateWait 246 | States: 247 | CalculateWait: 248 | Type: Task 249 | Resource: !GetAtt CalculateWait.Arn 250 | Next: Wait 251 | Wait: 252 | Type: Wait 253 | SecondsPath: $.waitSeconds 254 | Next: ActionChoice 255 | ActionChoice: 256 | Type: Choice 257 | Choices: 258 | - Variable: $.Action 259 | StringEquals: dispatch 260 | Next: Dispatch 261 | - Variable: $.Action 262 | StringEquals: cleanup 263 | Next: WaitForCleanup 264 | Dispatch: 265 | Type: Task 266 | Resource: !GetAtt PacedDispatch.Arn 267 | End: true 268 | WaitForCleanup: 269 | Type: Wait 270 | Seconds: 100 271 | Next: Cleanup 272 | Cleanup: 273 | Type: Task 274 | Resource: !GetAtt PacedCleanup.Arn 275 | End: true 276 | Policies: 277 | - Version: 2012-10-17 278 | Statement: 279 | - Sid: Statement1 280 | Effect: Allow 281 | Action: 282 | - 'lambda:InvokeFunction' 283 | Resource: 284 | - !GetAtt CalculateWait.Arn 285 | - !GetAtt PacedDispatch.Arn 286 | - !GetAtt PacedCleanup.Arn 287 | CalculateWait: 288 | Type: 'AWS::Serverless::Function' 289 | Properties: 290 | CodeUri: . 291 | Handler: ./src/step-functions/CalculateWait.handler 292 | MemorySize: 256 293 | Runtime: nodejs12.x 294 | PacedDispatch: 295 | Type: 'AWS::Serverless::Function' 296 | Properties: 297 | CodeUri: . 298 | Handler: ./src/step-functions/PacedDispatch.handler 299 | MemorySize: 256 300 | Runtime: nodejs12.x 301 | Environment: 302 | Variables: 303 | AccountId: !Ref AWS::AccountId 304 | Policies: 305 | - EventBridgePutEventsPolicy: 306 | EventBusName: '*' 307 | PacedCleanup: 308 | Type: 'AWS::Serverless::Function' 309 | Properties: 310 | CodeUri: . 311 | Handler: ./src/step-functions/PacedCleanup.handler 312 | MemorySize: 256 313 | Runtime: nodejs12.x 314 | Policies: 315 | Version: 2012-10-17 316 | Statement: 317 | - Sid: Statement1 318 | Effect: Allow 319 | Action: 320 | - 'events:DeleteRule' 321 | - 'events:RemoveTargets' 322 | - 'events:ListTargetsByRule' 323 | - 'iam:DeleteRolePolicy' 324 | - 'lambda:RemovePermission' 325 | Resource: 326 | - '*' 327 | EventToStepFunctionsRole: 328 | Type: 'AWS::IAM::Role' 329 | Properties: 330 | RoleName: !Sub evb-cli-eventbridge-to-stepfunctions-${AWS::Region} 331 | AssumeRolePolicyDocument: 332 | Version: 2012-10-17 333 | Statement: 334 | - Effect: Allow 335 | Principal: 336 | Service: 337 | - events.amazonaws.com 338 | Action: 339 | - 'sts:AssumeRole' 340 | Path: / 341 | EventToStepFunctionsRolePolicies: 342 | Type: 'AWS::IAM::Policy' 343 | Properties: 344 | PolicyName: policy1 345 | PolicyDocument: 346 | Version: 2012-10-17 347 | Statement: 348 | - Sid: Statement1 349 | Effect: Allow 350 | Action: 351 | - 'states:StartExecution' 352 | Resource: 353 | - !Ref PacedReplayStateMachine 354 | Roles: 355 | - !Ref EventToStepFunctionsRole 356 | 357 | Outputs: {} 358 | -------------------------------------------------------------------------------- /images/demo-api.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-api.gif -------------------------------------------------------------------------------- /images/demo-browse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-browse.gif -------------------------------------------------------------------------------- /images/demo-codebinding.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-codebinding.gif -------------------------------------------------------------------------------- /images/demo-diagram.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-diagram.gif -------------------------------------------------------------------------------- /images/demo-input.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-input.gif -------------------------------------------------------------------------------- /images/demo-local.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-local.gif -------------------------------------------------------------------------------- /images/demo-pipes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-pipes.gif -------------------------------------------------------------------------------- /images/demo-sam.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo-sam.gif -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljacobsson/evb-cli/8bd4eb64d77f3a4cc42a0351fad0031e7efb6a89/images/demo.gif -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | process.env.AWS_SDK_LOAD_CONFIG = 1; 3 | const program = require("commander"); 4 | const package = require("./package.json"); 5 | require("./src/commands/pattern"); 6 | require("./src/commands/input"); 7 | require("./src/commands/browse"); 8 | require("./src/commands/diagram"); 9 | require("./src/commands/extract-sam-event"); 10 | require("./src/commands/test-event"); 11 | require("./src/commands/replay"); 12 | require("./src/commands/replay-dead-letter"); 13 | require("./src/commands/local"); 14 | require("./src/commands/code-binding"); 15 | require("./src/commands/api-destination"); 16 | require("./src/commands/pipes"); 17 | require("./src/commands/find-usages"); 18 | 19 | program.version(package.version, "-v, --vers", "output the current version"); 20 | 21 | program.parse(process.argv); 22 | 23 | if (process.argv.length < 3) { 24 | program.help(); 25 | } 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: undefined, 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: undefined, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | testEnvironment: "node", 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | // testPathIgnorePatterns: [ 151 | // "/node_modules/" 152 | // ], 153 | 154 | // The regexp pattern or array of patterns that Jest uses to detect test files 155 | // testRegex: [], 156 | 157 | // This option allows the use of a custom results processor 158 | // testResultsProcessor: undefined, 159 | 160 | // This option allows use of a custom test runner 161 | // testRunner: "jasmine2", 162 | 163 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 164 | // testURL: "http://localhost", 165 | 166 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 167 | // timers: "real", 168 | 169 | // A map from regular expressions to paths to transformers 170 | // transform: undefined, 171 | 172 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 173 | // transformIgnorePatterns: [ 174 | // "/node_modules/" 175 | // ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 178 | // unmockedModulePathPatterns: undefined, 179 | 180 | // Indicates whether each individual test should be reported during the run 181 | // verbose: undefined, 182 | 183 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 184 | // watchPathIgnorePatterns: [], 185 | 186 | // Whether to use watchman for file crawling 187 | // watchman: true, 188 | }; 189 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mhlabs/evb-cli", 3 | "version": "1.2.4", 4 | "description": "A package for building EventBridge patterns", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/mhlabs/evb-cli.git" 12 | }, 13 | "author": "mhdev", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@aws-sdk/client-cloudformation": "^3.425.0", 17 | "@aws-sdk/client-eventbridge": "^3.414.0", 18 | "@aws-sdk/client-iam": "^3.423.0", 19 | "@aws-sdk/client-kinesis": "^3.423.0", 20 | "@aws-sdk/client-lambda": "^3.425.0", 21 | "@aws-sdk/client-schemas": "^3.414.0", 22 | "@aws-sdk/client-sfn": "^3.423.0", 23 | "@aws-sdk/client-sns": "^3.423.0", 24 | "@aws-sdk/client-sqs": "^3.423.0", 25 | "@aws-sdk/client-sts": "^3.414.0", 26 | "@aws-sdk/credential-provider-node": "^3.499.0", 27 | "@aws-sdk/credential-provider-sso": "^3.414.0", 28 | "@aws-sdk/credential-providers": "^3.499.0", 29 | "axios": "^0.21.4", 30 | "cli-spinner": "^0.2.10", 31 | "commander": "^4.1.1", 32 | "date-prompt": "^1.0.0", 33 | "inquirer": "^7.0.4", 34 | "inquirer-autocomplete-prompt": "^1.3.0", 35 | "inquirer-tree-prompt": "^1.1.2", 36 | "json-schema-faker": "^0.5.0-rcv.32", 37 | "json-to-pretty-yaml": "^1.2.2", 38 | "jsonpath": "^1.0.2", 39 | "link2aws": "^1.0.12", 40 | "open": "^7.4.2", 41 | "prompt-skeleton": "^1.0.2", 42 | "quicktype-core": "^6.0.62", 43 | "temp-dir": "^2.0.0", 44 | "to-json-schema": "^0.2.5", 45 | "toml": "^3.0.0", 46 | "ws": "^7.3.1", 47 | "yaml": "^2.3.2", 48 | "yaml-cfn": "^0.2.3" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/mhlabs/evb-cli/issues" 52 | }, 53 | "homepage": "https://github.com/mhlabs/evb-cli#readme", 54 | "devDependencies": { 55 | "jest": "^29.3.1", 56 | "y18n": ">=4.0.1" 57 | }, 58 | "bin": { 59 | "evb": "index.js" 60 | }, 61 | "keywords": [ 62 | "aws", 63 | "serverless", 64 | "eventbridge", 65 | "productivity" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/api-destination/api-destination.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const inquirer = require("inquirer"); 3 | const inputUtil = require("../shared/input-util"); 4 | const templateParser = require("../shared/template-parser"); 5 | const schemaBrowser = require("../shared/schema-browser"); 6 | const inputTransformerBuilder = require("../input/input-transformer-builder"); 7 | const { option } = require("commander"); 8 | const { yamlParse } = require("yaml-cfn"); 9 | 10 | function validateTemplateExists(cmd) { 11 | // eslint-disable-line no-unused-vars 12 | const templateName = cmd.template; 13 | const template = templateParser.load(templateName, true); 14 | if (!template) { 15 | throw new Error(`Template "${templateName}" does not exist.`); 16 | } 17 | } 18 | 19 | async function create(cmd) { 20 | try { 21 | validateTemplateExists(cmd); 22 | } catch (err) { 23 | console.log(err.message); 24 | return; 25 | } 26 | let oad; 27 | try { 28 | oad = await axios.get(cmd.url); 29 | } catch (err) { 30 | console.log(err.message); 31 | return; 32 | } 33 | if (typeof oad.data === "string") { 34 | oad.data = yamlParse(oad.data); 35 | } 36 | 37 | const path = await inputUtil.selectFrom( 38 | Object.keys(oad.data.paths).map((p) => { 39 | return { 40 | name: `${p} [${Object.keys(oad.data.paths[p]) 41 | .filter((p) => 42 | [ 43 | "get", 44 | "head", 45 | "post", 46 | "put", 47 | "delete", 48 | "options", 49 | "trace", 50 | "patch", 51 | ].includes(p.toLowerCase()) 52 | ) 53 | .join(",")}]`, 54 | value: p, 55 | }; 56 | }), 57 | "Select the path to create an API destination for", 58 | true 59 | ); 60 | 61 | const methods = Object.keys(oad.data.paths[path]).filter((p) => 62 | [ 63 | "get", 64 | "head", 65 | "post", 66 | "put", 67 | "delete", 68 | "options", 69 | "trace", 70 | "patch", 71 | ].includes(p.toLowerCase()) 72 | ); 73 | const template = templateParser.load(cmd.template, true); 74 | cmd.format = templateParser.templateFormat(); 75 | let method = methods[0]; 76 | 77 | if (methods.length > 1) { 78 | method = await inputUtil.selectFrom(methods, "Select method", true, "list"); 79 | } 80 | let required, optional; 81 | if (oad.data.paths[path][method].parameters) { 82 | required = oad.data.paths[path][method].parameters.filter( 83 | (p) => p.required && p.in 84 | ); 85 | optional = oad.data.paths[path][method].parameters.filter( 86 | (p) => !p.required && p.in 87 | ); 88 | } 89 | let schema; 90 | let eventRuleName; 91 | if (template) { 92 | const events = templateParser.getEventRules(); 93 | if (!events.length) { 94 | console.log("No 'AWS::Events::Rule' found in template"); 95 | return; 96 | } 97 | if (events.length > 1) { 98 | // eslint-disable-line no-magic-numbers 99 | eventRuleName = await inputUtil.selectFrom( 100 | events, 101 | "Select event to add destination to", 102 | true, 103 | "list" 104 | ); 105 | } else { 106 | eventRuleName = events[0]; 107 | } 108 | try { 109 | const schemaResponse = await schemaBrowser.getForPattern( 110 | template.Resources[eventRuleName].Properties.EventPattern 111 | ); 112 | schema = JSON.parse(schemaResponse.Content); 113 | } catch (err) { 114 | console.log(err.message); 115 | if (err.message.includes("does not exist")) { 116 | console.log("Please make sure it exists in the selected registry."); 117 | } 118 | return; 119 | } 120 | } 121 | 122 | const options = ["Preview", "Done"]; 123 | if (required && required.length) { 124 | options.push( 125 | new inquirer.Separator("Required"), 126 | ...required.map((p) => { 127 | return { name: `${p.name} (${p.in})`, value: p }; 128 | }) 129 | ); 130 | } 131 | if (optional && optional.length) { 132 | options.push( 133 | new inquirer.Separator("Optional"), 134 | ...optional.map((p) => { 135 | return { name: `${p.name} (${p.in})`, value: p }; 136 | }), 137 | new inquirer.Separator("********") 138 | ); 139 | } 140 | 141 | let value; 142 | let currentTransform; 143 | let httpParameters; 144 | do { 145 | const option = await inputUtil.selectFrom( 146 | options, 147 | "Select parameter", 148 | true, 149 | "list" 150 | ); 151 | 152 | if (option.description) { 153 | console.log(option.description); 154 | } 155 | if (option === "Done") { 156 | break; 157 | } 158 | if (option === "Preview") { 159 | schemaBrowser.outputPattern(currentTransform, cmd.format); 160 | continue; 161 | } 162 | const valueSource = await inputUtil.selectFrom( 163 | ["Event mapping", "Static value"], 164 | true, 165 | "list" 166 | ); 167 | if (valueSource === "Event mapping") { 168 | const transformer = await inputTransformerBuilder.buildForSchema( 169 | cmd.format, 170 | schema, 171 | option.name, 172 | currentTransform 173 | ); 174 | if (["body", "formdata"].includes(option.in.toLowerCase())) { 175 | currentTransform = transformer.output; 176 | } else { 177 | value = transformer.path; 178 | } 179 | } else { 180 | value = await inputUtil.text( 181 | `Value for ${option.name} (${option.type || "string"})` 182 | ); 183 | if (["body", "formdata"].includes(option.in.toLowerCase())) { 184 | currentTransform = appendToTransform(currentTransform, option, value); 185 | } 186 | } 187 | 188 | if (option.in.toLowerCase().startsWith("query")) { 189 | httpParameters = appendHttpParameters( 190 | httpParameters, 191 | option, 192 | value, 193 | "QueryStringParameters" 194 | ); 195 | } 196 | if (option.in.toLowerCase().startsWith("header")) { 197 | httpParameters = appendHttpParameters( 198 | httpParameters, 199 | option, 200 | value, 201 | "HeaderParameters" 202 | ); 203 | } 204 | if (option.in.toLowerCase().startsWith("path")) { 205 | httpParameters = appendHttpParameters( 206 | httpParameters, 207 | option, 208 | value, 209 | "PathParameterValues" 210 | ); 211 | } 212 | } while (true); 213 | console.log( 214 | "About to generate `AWS::Events::Connection` and `AWS::Events::ApiDestination` resources. How do you want to prefix these?" 215 | ); 216 | const resourcePrefix = await inputUtil.text( 217 | "Resource prefix", 218 | oad.data.info.title.replace(/[^a-zA-Z0-9]/g, "").substring(0, 20) 219 | ); 220 | 221 | const authType = await getAuthTypeCfn(resourcePrefix); 222 | 223 | template.Resources[`${resourcePrefix}Connection`] = { 224 | Type: "AWS::Events::Connection", 225 | Properties: authType, 226 | }; 227 | template.Resources[`${resourcePrefix}Destination`] = { 228 | Type: "AWS::Events::ApiDestination", 229 | Properties: { 230 | ConnectionArn: { "Fn::GetAtt": [`${resourcePrefix}Connection`, "Arn"] }, 231 | InvocationEndpoint: `https://${oad.data.host}/api${path}`, 232 | HttpMethod: method.toUpperCase(), 233 | InvocationRateLimitPerSecond: 10, 234 | }, 235 | }; 236 | template.Resources[`${resourcePrefix}InvokeRole`] = { 237 | Type: "AWS::IAM::Role", 238 | Properties: { 239 | AssumeRolePolicyDocument: { 240 | Version: "2012-10-17", 241 | Statement: [ 242 | { 243 | Effect: "Allow", 244 | Principal: { 245 | Service: ["events.amazonaws.com"], 246 | }, 247 | Action: ["sts:AssumeRole"], 248 | }, 249 | ], 250 | }, 251 | Policies: [ 252 | { 253 | PolicyName: "AllowAPIdestinationAccess", 254 | PolicyDocument: { 255 | Version: "2012-10-17", 256 | Statement: [ 257 | { 258 | Effect: "Allow", 259 | Action: "events:InvokeApiDestination", 260 | Resource: { "Fn::GetAtt": [`${resourcePrefix}Destination`, "Arn"] }, 261 | }, 262 | ], 263 | }, 264 | }, 265 | ], 266 | }, 267 | }; 268 | 269 | template.Resources[eventRuleName].Properties.Targets = 270 | template.Resources[eventRuleName].Properties.Targets || []; 271 | const target = { 272 | Arn: { "Fn::GetAtt": [`${resourcePrefix}Destination`, "Arn"] }, 273 | RoleArn: { "Fn::GetAtt": [`${resourcePrefix}InvokeRole`, "Arn"] }, 274 | Id: `${resourcePrefix}Target`, 275 | }; 276 | if (currentTransform) { 277 | target.InputTransformer = currentTransform; 278 | } 279 | if (httpParameters) { 280 | target.HttpParameters = httpParameters; 281 | } 282 | 283 | template.Resources[eventRuleName].Properties.Targets.push(target); 284 | 285 | templateParser.saveTemplate(); 286 | } 287 | 288 | async function getAuthTypeCfn(resourcePrefix) { 289 | return await inputUtil.selectFrom( 290 | [ 291 | { 292 | name: "API_KEY", 293 | value: { 294 | AuthorizationType: "API_KEY", 295 | AuthParameters: { 296 | ApiKeyAuthParameters: { 297 | ApiKeyName: "Authorization", 298 | ApiKeyValue: `{{resolve:secretsmanager:${resourcePrefix}-auth/Credentials:SecretString:ApiKey}}`, 299 | }, 300 | }, 301 | }, 302 | }, 303 | { 304 | name: "BASIC", 305 | value: { 306 | AuthorizationType: "BASIC", 307 | AuthParameters: { 308 | BasicAuthParameters: { 309 | Username: `{{resolve:secretsmanager:${resourcePrefix}-auth/Credentials:SecretString:Username}}`, 310 | Password: `{{resolve:secretsmanager:${resourcePrefix}-auth/Credentials:SecretString:Password}}`, 311 | }, 312 | }, 313 | }, 314 | }, 315 | { 316 | name: "OAUTH_CLIENT_CREDENTIALS", 317 | value: { 318 | AuthorizationType: "OAUTH_CLIENT_CREDENTIALS", 319 | AuthParameters: { 320 | OAuthParameters: { 321 | ClientParameters: { 322 | ClientId: `{{resolve:secretsmanager:${resourcePrefix}-auth/Credentials:SecretString:ClientId}}`, 323 | ClientSecret: `{{resolve:secretsmanager:${resourcePrefix}-auth/Credentials:SecretString:ClientSecret}}`, 324 | }, 325 | AuthorizationEndpoint: 326 | "https://yourUserName.us.auth0.com/oauth/token", 327 | HttpMethod: "POST", 328 | }, 329 | }, 330 | }, 331 | }, 332 | ], 333 | "Select authorization type", 334 | true, 335 | "list" 336 | ); 337 | } 338 | 339 | function appendHttpParameters(httpParameters, option, value, parameterName) { 340 | httpParameters = httpParameters || {}; 341 | httpParameters[parameterName] = httpParameters[parameterName] || {}; 342 | httpParameters[parameterName][option.name] = value; 343 | return httpParameters; 344 | } 345 | 346 | function appendToTransform(currentTransform, option, value) { 347 | currentTransform = currentTransform || {}; 348 | currentTransform.InputTemplate = currentTransform.InputTemplate || "{}"; 349 | const inputTemplate = JSON.parse(currentTransform.InputTemplate); 350 | inputTemplate[option.name] = value; 351 | currentTransform.InputTemplate = JSON.stringify(inputTemplate); 352 | return currentTransform; 353 | } 354 | 355 | module.exports = { 356 | create, 357 | }; 358 | -------------------------------------------------------------------------------- /src/commands/api-destination/index.js: -------------------------------------------------------------------------------- 1 | const program = require("commander"); 2 | 3 | const apiDestination = require("./api-destination"); 4 | const authHelper = require("../shared/auth-helper"); 5 | 6 | program 7 | .command("api-destination") 8 | .alias("api") 9 | .requiredOption("-t, --template [template]", "Path to template file", "template.yaml") 10 | .option("-p, --profile [profile]", "AWS profile to use") 11 | .option( 12 | "--region [region]", 13 | "The AWS region to use. Falls back on AWS_REGION environment variable if not specified" 14 | ) 15 | .requiredOption("-u --url [url]", "URL to OpenAPI specification of API") 16 | .description("Generates API Destination SAM template resources") 17 | .action(async (cmd) => { 18 | await authHelper.initAuth(cmd); 19 | 20 | await apiDestination.create(cmd); 21 | 22 | //await templateParser.injectPattern(pattern); 23 | }); 24 | -------------------------------------------------------------------------------- /src/commands/browse/browse-events.js: -------------------------------------------------------------------------------- 1 | const patternBuilder = require("../shared/schema-browser"); 2 | const inputUtil = require("../shared/input-util"); 3 | const eventBridgeUtil = require("../shared/eventbridge-util"); 4 | const { SchemasClient } = require("@aws-sdk/client-schemas"); 5 | const { EventBridgeClient, ListTargetsByRuleCommand } = require("@aws-sdk/client-eventbridge"); 6 | const { fromSSO } = require('@aws-sdk/credential-provider-sso'); 7 | 8 | async function browseEvents(cmd) { 9 | const schemas = new SchemasClient(); 10 | while (true) { 11 | const { targets } = await getTargets(schemas); 12 | if (targets.length) { 13 | while (true) { 14 | console.log("CTRL+C to exit"); 15 | const target = await inputUtil.selectFrom( 16 | targets, 17 | "Select target for more info" 18 | ); 19 | 20 | if (target === inputUtil.BACK) { 21 | break; 22 | } 23 | 24 | let details = [{ name: "EventPattern", value: target.pattern }]; 25 | for (const key of Object.keys(target.target)) { 26 | details.push({ name: key, value: target.target[key] }); 27 | } 28 | details.push(inputUtil.CONSUME_LOCALLY); 29 | const detail = await inputUtil.selectFrom( 30 | details, 31 | "Select property for more info" 32 | ); 33 | if (detail === inputUtil.BACK) { 34 | continue; 35 | } 36 | 37 | console.log("\n" + JSON.stringify(detail, null, 2) + "\n"); 38 | } 39 | } else { 40 | console.log("No subscribers found"); 41 | } 42 | } 43 | } 44 | 45 | async function getTargets() { 46 | const { schema, sourceName } = await patternBuilder.getSchema(); 47 | const evb = new EventBridgeClient(); 48 | const eventBusName = await inputUtil.getEventBusName(); 49 | const targets = []; 50 | for await (const ruleBatch of eventBridgeUtil.listRules({ 51 | EventBusName: eventBusName, 52 | Limit: 100, 53 | })) { 54 | for (const rule of ruleBatch) { 55 | if (!rule.EventPattern) { 56 | continue; 57 | } 58 | 59 | const pattern = JSON.parse(rule.EventPattern); 60 | if ( 61 | pattern.source == sourceName && 62 | pattern["detail-type"] == 63 | schema.components.schemas.AWSEvent["x-amazon-events-detail-type"] 64 | ) { 65 | const targetResponse = await evb.send(new ListTargetsByRuleCommand({ Rule: rule.Name, EventBusName: eventBusName })); 66 | for (const target of targetResponse.Targets) { 67 | const arnSplit = target.Arn.split(":"); 68 | const service = arnSplit[2]; 69 | const name = arnSplit[arnSplit.length - 1]; 70 | targets.push({ 71 | name: `${service}: ${name}`, 72 | value: { pattern, target }, 73 | }); 74 | } 75 | } 76 | } 77 | } 78 | return { schema, targets }; 79 | } 80 | 81 | module.exports = { 82 | browseEvents, 83 | }; 84 | -------------------------------------------------------------------------------- /src/commands/browse/index.js: -------------------------------------------------------------------------------- 1 | const program = require("commander"); 2 | const browser = require("./browse-events"); 3 | const authHelper = require("../shared/auth-helper"); 4 | 5 | program 6 | .command("browse") 7 | .alias("b") 8 | .option("-f, --filter-patterns [filters]", "Comma separated list of '$.json.path.to.property=regex-pattern' to filter the results with") 9 | .option("-p, --profile [profile]", "AWS profile to use") 10 | .option("--region [region]", "The AWS region to use. Falls back on AWS_REGION environment variable if not specified") 11 | .description("Browses sources and detail types and shows their consumers") 12 | .action(async (cmd) => { 13 | await authHelper.initAuth(cmd); 14 | await browser.browseEvents(cmd); 15 | }); 16 | -------------------------------------------------------------------------------- /src/commands/code-binding/code-binding.js: -------------------------------------------------------------------------------- 1 | const schemaBrowser = require("../shared/schema-browser"); 2 | const inputUtil = require("../shared/input-util"); 3 | const templateParser = require("../shared/template-parser"); 4 | const { SchemasClient, ExportSchemaCommand } = require("@aws-sdk/client-schemas"); 5 | const jsf = require("json-schema-faker"); 6 | const jp = require("jsonpath"); 7 | const toJsonSchema = require("to-json-schema"); 8 | const fs = require("fs"); 9 | const { 10 | quicktype, 11 | InputData, 12 | JSONSchemaInput, 13 | JSONSchemaStore, 14 | } = require("quicktype-core"); 15 | const languages = require("quicktype-core/dist/language/All"); 16 | require("./languages/csharp"); 17 | require("./languages/typescript"); 18 | require("./languages/python"); 19 | require("./languages/java"); 20 | require("./languages/swift"); 21 | async function loadFromRegistry(cmd) { 22 | const schemas = new SchemasClient(); 23 | const schemaLocation = await schemaBrowser.getSchemaName(); 24 | const schema = await schemas.send(new ExportSchemaCommand({ 25 | RegistryName: schemaLocation.registry.id, 26 | SchemaName: schemaLocation.schemaName, 27 | Type: "JSONSchemaDraft4", 28 | })); 29 | await generateType(cmd, schema.Content); 30 | } 31 | 32 | async function loadFromTemplate(cmd) { 33 | const schemas = new SchemasClient(); 34 | 35 | if (!cmd.registryName) { 36 | cmd.registryName = (await inputUtil.getRegistry()).id; 37 | } 38 | const template = templateParser.load(cmd.template); 39 | rules = templateParser.getEventRules().map((r) => { 40 | return { key: r, resource: template.Resources[r] }; 41 | }); 42 | const transforms = []; 43 | for (const rule of rules) { 44 | rule.resource.Properties.Targets = rule.resource.Properties.Targets; 45 | for (const target of rule.resource.Properties.Targets) { 46 | const tempRule = Object.assign({}, rule); 47 | tempRule.name = `${rule.key} -> ${target.Id}`; 48 | transforms.push({ rule: tempRule, target: target }); 49 | } 50 | } 51 | 52 | addSAMEvents(transforms, template); 53 | 54 | const target = await inputUtil.selectFrom( 55 | transforms.map((p) => { 56 | return { name: p.rule.name, value: p }; 57 | }), 58 | "Select rule/target", 59 | true 60 | ); 61 | const source = target.rule.resource.Properties.EventPattern.source; 62 | const detailType = 63 | target.rule.resource.Properties.EventPattern["detail-type"]; 64 | const schemaName = `${source}@${detailType}` 65 | .replace(/\//g, "-") 66 | .replace(/ /g, ""); 67 | 68 | try { 69 | const describeSchemaResponse = await schemas.send(new ExportSchemaCommand 70 | ({ 71 | RegistryName: cmd.registryName, 72 | SchemaName: schemaName, 73 | Type: "JSONSchemaDraft4", 74 | })); 75 | 76 | let schema = JSON.parse(describeSchemaResponse.Content); 77 | if (target.target.InputTransformer) { 78 | schema = generateSchemaForTransform(schema, target); 79 | } 80 | if (target.target.InputPath) { 81 | schema = generateSchemaForInputPath(schema, target); 82 | } 83 | await generateType(cmd, JSON.stringify(schema)); 84 | } catch (err) { 85 | console.log(err.message); 86 | return; 87 | } 88 | } 89 | 90 | function addSAMEvents(transforms, template) { 91 | transforms.push( 92 | ...templateParser.getSAMEvents(template).map((r) => { 93 | return { 94 | rule: { 95 | name: r.name, 96 | resource: { 97 | Properties: { 98 | EventPattern: r.value.config.Pattern, 99 | Targets: [ 100 | { 101 | Id: r.name, 102 | InputPath: r.value.config.InputPath, 103 | }, 104 | ], 105 | }, 106 | }, 107 | }, 108 | target: { 109 | Id: r.name, 110 | InputPath: r.value.config.InputPath, 111 | }, 112 | }; 113 | }) 114 | ); 115 | } 116 | 117 | async function getLanguageInput() { 118 | return await inputUtil.selectFrom( 119 | languages.all.map((p) => p.displayName).sort(), 120 | "Select language. Provide --language flag to command to skip", 121 | true 122 | ); 123 | } 124 | 125 | function generateSchemaForInputPath(schema, target) { 126 | const sampleObject = jsf.generate(schema); 127 | const node = jp.query(sampleObject, target.target.InputPath)[0]; 128 | return toJsonSchema(node); 129 | } 130 | 131 | function generateSchemaForTransform(schema, target) { 132 | const sampleObject = jsf.generate(schema); 133 | const pathsMap = target.target.InputTransformer.InputPathsMap; 134 | for (const key of Object.keys(pathsMap)) { 135 | pathsMap[key] = jp.query(sampleObject, pathsMap[key])[0]; 136 | } 137 | const inputTemplate = JSON.parse( 138 | target.target.InputTransformer.InputTemplate.replace(/[<>]/g, '"') 139 | ); 140 | for (const key of Object.keys(inputTemplate)) { 141 | inputTemplate[key] = pathsMap[inputTemplate[key]] || null; 142 | } 143 | return toJsonSchema(inputTemplate); 144 | } 145 | 146 | async function generateType(cmd, schema) { 147 | const schemaInput = new JSONSchemaInput(new JSONSchemaStore()); 148 | 149 | await schemaInput.addSource({ 150 | name: cmd.typeName, 151 | schema: schema, 152 | }); 153 | 154 | const inputData = new InputData(); 155 | inputData.addInput(schemaInput); 156 | const output = ( 157 | await quicktype({ 158 | inputData, 159 | lang: cmd.language, 160 | }) 161 | ).lines.join("\n"); 162 | 163 | if (cmd.outputFile) { 164 | fs.writeFileSync(cmd.outputFile, output); 165 | } else { 166 | console.log(output); 167 | } 168 | } 169 | 170 | module.exports = { 171 | loadFromTemplate, 172 | loadFromRegistry, 173 | getLanguageInput, 174 | generateSchemaForInputPath, 175 | generateSchemaForTransform, 176 | }; 177 | -------------------------------------------------------------------------------- /src/commands/code-binding/code-binding.test.js: -------------------------------------------------------------------------------- 1 | const codeBinding = require("./code-binding"); 2 | const testSchema = require("../../test-input/aws.codepipeline@CodePipelinePipelineExecutionStateChange-v1.json"); 3 | 4 | test("Generate schema for InputPath", async () => { 5 | const subSchema = codeBinding.generateSchemaForInputPath(testSchema, { 6 | target: { InputPath: "$.detail" }, 7 | }); 8 | expect(subSchema.type).toBe("object"); 9 | expect(subSchema.properties.pipeline.type).toBe("string"); 10 | }); 11 | 12 | test("Generate schema for InputTransformer", async () => { 13 | const subSchema = codeBinding.generateSchemaForTransform(testSchema, { 14 | target: { InputTransformer: { 15 | InputPathsMap: { 16 | AccountId: "$.account", 17 | Pipeline: "$.detail.pipeline", 18 | Time: "$.time" 19 | }, 20 | "InputTemplate":`{"AccountId": , "PipelineName": , "Time":