├── .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 | 
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 | 
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 | 
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 | 
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 | 
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":