├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Cargo.toml
├── LICENSE
├── Makefile
├── README.md
├── imgs
├── diagram.png
└── load-test.png
├── src
├── bin
│ └── lambda
│ │ ├── delete-product.rs
│ │ ├── dynamodb-streams.rs
│ │ ├── get-product.rs
│ │ ├── get-products.rs
│ │ └── put-product.rs
├── domain.rs
├── entrypoints
│ ├── lambda
│ │ ├── apigateway.rs
│ │ ├── dynamodb
│ │ │ ├── mod.rs
│ │ │ └── model.rs
│ │ └── mod.rs
│ └── mod.rs
├── error.rs
├── event_bus
│ ├── eventbridge
│ │ ├── ext.rs
│ │ └── mod.rs
│ ├── mod.rs
│ └── void.rs
├── lib.rs
├── model.rs
├── store
│ ├── dynamodb
│ │ ├── ext.rs
│ │ └── mod.rs
│ ├── memory.rs
│ └── mod.rs
└── utils.rs
├── template.yaml
└── tests
├── aws_test.rs
├── generator.js
└── load-test.yml
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | push:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | test:
13 | runs-on: ${{ matrix.os }}
14 | strategy:
15 | matrix:
16 | rust: [stable]
17 | os: [ubuntu-latest]
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Install Rust
21 | uses: actions-rs/toolchain@v1
22 | with:
23 | target: aarch64-unknown-linux-gnu
24 | toolchain: ${{ matrix.rust }}
25 | override: true
26 | components: rustfmt, clippy
27 | - uses: Swatinem/rust-cache@v1
28 | - name: Run cargo fmt
29 | uses: actions-rs/cargo@v1
30 | with:
31 | command: fmt
32 | - name: Run cargo clippy
33 | uses: actions-rs/cargo@v1
34 | with:
35 | command: clippy
36 | - name: Test
37 | uses: actions-rs/cargo@v1
38 | with:
39 | command: test
40 | args: --lib --bins
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt
11 | **/*.rs.bk
12 |
13 | # MSVC Windows builds of rustc generate these, which store debugging information
14 | *.pdb
15 |
16 | .aws-sam
17 | build
18 | samconfig.toml
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
4 | opensource-codeofconduct@amazon.com with any additional questions or comments.
5 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
4 | documentation, we greatly value feedback and contributions from our community.
5 |
6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
7 | information to effectively respond to your bug report or contribution.
8 |
9 |
10 | ## Reporting Bugs/Feature Requests
11 |
12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features.
13 |
14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
16 |
17 | * A reproducible test case or series of steps
18 | * The version of our code being used
19 | * Any modifications you've made relevant to the bug
20 | * Anything unusual about your environment or deployment
21 |
22 |
23 | ## Contributing via Pull Requests
24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
25 |
26 | 1. You are working against the latest source on the *main* branch.
27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
29 |
30 | To send us a pull request, please:
31 |
32 | 1. Fork the repository.
33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
34 | 3. Ensure local tests pass.
35 | 4. Commit to your fork using clear commit messages.
36 | 5. Send us a pull request, answering any default questions in the pull request interface.
37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
38 |
39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
41 |
42 |
43 | ## Finding contributions to work on
44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
45 |
46 |
47 | ## Code of Conduct
48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
50 | opensource-codeofconduct@amazon.com with any additional questions or comments.
51 |
52 |
53 | ## Security issue notifications
54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
55 |
56 |
57 | ## Licensing
58 |
59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
60 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "products"
3 | version = "0.1.0"
4 | license-file = "LICENSE"
5 | edition = "2021"
6 |
7 | [dependencies]
8 | async-trait = "0.1"
9 | aws-config = "0.7"
10 | aws-sdk-dynamodb = "0.7"
11 | aws-sdk-eventbridge = "0.7"
12 | aws-smithy-client = { version = "0.37", features = ["test-util"] }
13 | aws-smithy-http = "0.37"
14 | aws-types = "0.7"
15 | futures = { version = "0.3", features = ["std"] }
16 | lambda_runtime = { version = "0.5", optional = true }
17 | lambda_http = { version = "0.5", optional = true }
18 | rayon = { version = "1.5", optional = true }
19 | serde = "1"
20 | serde_json = "1.0"
21 | tracing = "0.1"
22 | tracing-subscriber = { version = "0.2", features = ["fmt", "json"] }
23 | tokio = { version = "1", features = ["full"] }
24 |
25 | [dev-dependencies]
26 | # Only allow hardcoded credentials for unit tests
27 | aws-types = { version = "0.7", features = ["hardcoded-credentials"] }
28 | float-cmp = "0.9"
29 | http = "0.2"
30 | rand = "0.8"
31 | reqwest = { version = "0.11", features = ["json"] }
32 |
33 | [features]
34 | default = ["lambda"]
35 | lambda = ["lambda_runtime", "lambda_http", "rayon"]
36 |
37 | [[bin]]
38 | name = "delete-product"
39 | path = "src/bin/lambda/delete-product.rs"
40 | test = false
41 | required-features = ["lambda"]
42 |
43 | [[bin]]
44 | name = "get-product"
45 | path = "src/bin/lambda/get-product.rs"
46 | test = false
47 | required-features = ["lambda"]
48 |
49 | [[bin]]
50 | name = "get-products"
51 | path = "src/bin/lambda/get-products.rs"
52 | test = false
53 | required-features = ["lambda"]
54 |
55 | [[bin]]
56 | name = "put-product"
57 | path = "src/bin/lambda/put-product.rs"
58 | test = false
59 | required-features = ["lambda"]
60 |
61 | [[bin]]
62 | name = "dynamodb-streams"
63 | path = "src/bin/lambda/dynamodb-streams.rs"
64 | test = false
65 | required-features = ["lambda"]
66 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7 | the Software, and to permit persons to whom the Software is furnished to do so.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15 |
16 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | STACK_NAME ?= rust-products
2 | FUNCTIONS := get-products get-product put-product delete-product dynamodb-streams
3 |
4 | ARCH := aarch64-unknown-linux-gnu
5 | ARCH_SPLIT = $(subst -, ,$(ARCH))
6 |
7 | .PHONY: build deploy tests
8 |
9 | all: build tests-unit deploy tests-integ
10 | ci: build tests-unit
11 |
12 | setup:
13 | ifeq (,$(shell which rustc))
14 | $(error "Could not found Rust compiler, please install it")
15 | endif
16 | ifeq (,$(shell which cargo))
17 | $(error "Could not found Cargo, please install it")
18 | endif
19 | ifeq (,$(shell which zig))
20 | $(error "Could not found Zig compiler, please install it")
21 | endif
22 | cargo install cargo-lambda
23 | ifeq (,$(shell which sam))
24 | $(error "Could not found SAM CLI, please install it")
25 | endif
26 | ifeq (,$(shell which artillery))
27 | $(error "Could not found Artillery, it's required for load testing")
28 | endif
29 |
30 | build:
31 | cargo lambda build --release --target $(ARCH)
32 |
33 | deploy:
34 | if [ -f samconfig.toml ]; \
35 | then sam deploy --stack-name $(STACK_NAME); \
36 | else sam deploy -g --stack-name $(STACK_NAME); \
37 | fi
38 |
39 | tests-unit:
40 | cargo test --lib --bins
41 |
42 | tests-integ:
43 | RUST_BACKTRACE=1 API_URL=$$(aws cloudformation describe-stacks --stack-name $(STACK_NAME) \
44 | --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
45 | --output text) cargo test
46 |
47 | tests-load:
48 | API_URL=$$(aws cloudformation describe-stacks --stack-name $(STACK_NAME) \
49 | --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
50 | --output text) artillery run tests/load-test.yml
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Serverless Rust Demo
2 |
3 | 
4 |
5 |
6 |
7 |
8 |
9 | This is a simple serverless application built in Rust. It consists of an API Gateway backed by four Lambda functions and a DynamoDB table for storage.
10 |
11 | This single crate will create [five different binaries](./src/bin), one for each Lambda function. It uses an [hexagonal architecture pattern](https://aws.amazon.com/blogs/compute/developing-evolutionary-architecture-with-aws-lambda/) to decouple the [entry points](./src/entrypoints/), from the main [domain logic](./src/lib.rs), the [storage component](./src/store), and the [event bus component](./src/event_bus).
12 |
13 | ## 🏗️ Deployment and testing
14 |
15 | ### Requirements
16 |
17 | * [Rust](https://www.rust-lang.org/) 1.56.0 or higher
18 | * [cargo-lambda](https://github.com/calavera/cargo-lambda)
19 | * [Zig](https://ziglang.org/) for cross-compilation (cargo-lambda will prompt you to install it if it's missing in your host system)
20 | * The [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) 1.33.0 or higher for deploying to the cloud
21 | * [Artillery](https://artillery.io/) for load-testing the application
22 |
23 | ### Commands
24 |
25 | You can use the following commands at the root of this repository to test, build, and deploy this project:
26 |
27 | ```bash
28 | # Optional: check if tools are installed
29 | make setup
30 |
31 | # Run unit tests
32 | make tests-unit
33 |
34 | # Compile and prepare Lambda functions
35 | make build
36 |
37 | # Deploy the functions on AWS
38 | make deploy
39 |
40 | # Run integration tests against the API in the cloud
41 | make tests-integ
42 | ```
43 |
44 | ## Load Test
45 |
46 | [Artillery](https://www.artillery.io/) is used to make 300 requests / second for 10 minutes to our API endpoints. You can run this
47 | with the following command:
48 |
49 | ```bash
50 | make tests-load
51 | ```
52 |
53 | ### CloudWatch Logs Insights
54 |
55 | Using this CloudWatch Logs Insights query you can analyse the latency of the requests made to the Lambda functions.
56 |
57 | The query separates cold starts from other requests and then gives you p50, p90 and p99 percentiles.
58 |
59 | ```
60 | filter @type="REPORT"
61 | | fields greatest(@initDuration, 0) + @duration as duration, ispresent(@initDuration) as coldStart
62 | | stats count(*) as count, pct(duration, 50) as p50, pct(duration, 90) as p90, pct(duration, 99) as p99, max(duration) as max by coldStart
63 | ```
64 |
65 | 
66 |
67 | ## 🦀 Getting started with Rust on Lambda
68 |
69 | If you want to get started with Rust on Lambda, you can use [these cookiecutter templates](https://github.com/aws-samples/cookiecutter-aws-sam-rust) to setup your project.
70 |
71 | ## 👀 With other languages
72 |
73 | You can find implementations of this project in other languages here:
74 |
75 | * [🐿️ Go](https://github.com/aws-samples/serverless-go-demo)
76 | * [⭐ Groovy](https://github.com/aws-samples/serverless-groovy-demo)
77 | * [☕ Java with GraalVM](https://github.com/aws-samples/serverless-graalvm-demo)
78 | * [🤖 Kotlin](https://github.com/aws-samples/serverless-kotlin-demo)
79 | * [🏗️ TypeScript](https://github.com/aws-samples/serverless-typescript-demo)
80 | * [🥅 .NET](https://github.com/aws-samples/serverless-dotnet-demo)
81 |
82 | ## Security
83 |
84 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
85 |
86 | ## License
87 |
88 | This library is licensed under the MIT-0 License. See the LICENSE file.
89 |
90 |
--------------------------------------------------------------------------------
/imgs/diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/serverless-rust-demo/bd0164945a92ae1c05afc0b215f0eaa894e5ded9/imgs/diagram.png
--------------------------------------------------------------------------------
/imgs/load-test.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/serverless-rust-demo/bd0164945a92ae1c05afc0b215f0eaa894e5ded9/imgs/load-test.png
--------------------------------------------------------------------------------
/src/bin/lambda/delete-product.rs:
--------------------------------------------------------------------------------
1 | use lambda_http::{service_fn, Request};
2 | use products::{entrypoints::lambda::apigateway::delete_product, utils::*};
3 |
4 | #[tokio::main]
5 | async fn main() -> Result<(), Box> {
6 | // Initialize logger
7 | setup_tracing();
8 |
9 | // Initialize store
10 | let store = get_store().await;
11 |
12 | // Run the Lambda function
13 | //
14 | // This is the entry point for the Lambda function. The `lambda_http`
15 | // crate will take care of contacting the Lambda runtime API and invoking
16 | // the `delete_product` function.
17 | // See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
18 | //
19 | // This uses a closure to pass the Service without having to reinstantiate
20 | // it for every call. This is a bit of a hack, but it's the only way to
21 | // pass a store to a lambda function.
22 | //
23 | // Furthermore, we don't await the result of `delete_product` because
24 | // async closures aren't stable yet. This way, the closure returns a Future,
25 | // which matches the signature of the lambda function.
26 | // See https://github.com/rust-lang/rust/issues/62290
27 | lambda_http::run(service_fn(|event: Request| delete_product(&store, event))).await?;
28 | Ok(())
29 | }
30 |
--------------------------------------------------------------------------------
/src/bin/lambda/dynamodb-streams.rs:
--------------------------------------------------------------------------------
1 | use lambda_runtime::{service_fn, LambdaEvent};
2 | use products::{
3 | entrypoints::lambda::dynamodb::{model::DynamoDBEvent, parse_events},
4 | utils::*,
5 | };
6 |
7 | #[tokio::main]
8 | async fn main() -> Result<(), Box> {
9 | // Initialize logger
10 | setup_tracing();
11 |
12 | // Initialize event bus
13 | let event_bus = get_event_bus().await;
14 |
15 | // Run the Lambda function
16 | //
17 | // This is the entry point for the Lambda function. The `lambda_runtime`
18 | // crate will take care of contacting the Lambda runtime API and invoking
19 | // the `parse_events` function.
20 | // See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
21 | //
22 | // This uses a closure to pass the Service without having to reinstantiate
23 | // it for every call. This is a bit of a hack, but it's the only way to
24 | // pass the event bus to a lambda function.
25 | //
26 | // Furthermore, we don't await the result of `parse_events` because
27 | // async closures aren't stable yet. This way, the closure returns a Future,
28 | // which matches the signature of the lambda function.
29 | // See https://github.com/rust-lang/rust/issues/62290
30 | lambda_runtime::run(service_fn(|event: LambdaEvent| {
31 | let (event, ctx) = event.into_parts();
32 | parse_events(&event_bus, event, ctx)
33 | }))
34 | .await?;
35 | Ok(())
36 | }
37 |
--------------------------------------------------------------------------------
/src/bin/lambda/get-product.rs:
--------------------------------------------------------------------------------
1 | use lambda_http::{service_fn, Request};
2 | use products::{entrypoints::lambda::apigateway::get_product, utils::*};
3 |
4 | type E = Box;
5 |
6 | #[tokio::main]
7 | async fn main() -> Result<(), E> {
8 | // Initialize logger
9 | setup_tracing();
10 |
11 | // Initialize store
12 | let store = get_store().await;
13 |
14 | // Run the Lambda function
15 | //
16 | // This is the entry point for the Lambda function. The `lambda_http`
17 | // crate will take care of contacting the Lambda runtime API and invoking
18 | // the `get_product` function.
19 | // See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
20 | //
21 | // This uses a closure to pass the Service without having to reinstantiate
22 | // it for every call. This is a bit of a hack, but it's the only way to
23 | // pass a store to a lambda function.
24 | //
25 | // Furthermore, we don't await the result of `get_product` because
26 | // async closures aren't stable yet. This way, the closure returns a Future,
27 | // which matches the signature of the lambda function.
28 | // See https://github.com/rust-lang/rust/issues/62290
29 | lambda_http::run(service_fn(|event: Request| get_product(&store, event))).await?;
30 | Ok(())
31 | }
32 |
--------------------------------------------------------------------------------
/src/bin/lambda/get-products.rs:
--------------------------------------------------------------------------------
1 | use lambda_http::{service_fn, Request};
2 | use products::{entrypoints::lambda::apigateway::get_products, utils::*};
3 |
4 | type E = Box;
5 |
6 | #[tokio::main]
7 | async fn main() -> Result<(), E> {
8 | // Initialize logger
9 | setup_tracing();
10 |
11 | // Initialize store
12 | let store = get_store().await;
13 |
14 | // Run the Lambda function
15 | //
16 | // This is the entry point for the Lambda function. The `lambda_http`
17 | // crate will take care of contacting the Lambda runtime API and invoking
18 | // the `get_products` function.
19 | // See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
20 | //
21 | // This uses a closure to pass the Service without having to reinstantiate
22 | // it for every call. This is a bit of a hack, but it's the only way to
23 | // pass a store to a lambda function.
24 | //
25 | // Furthermore, we don't await the result of `get_products` because
26 | // async closures aren't stable yet. This way, the closure returns a Future,
27 | // which matches the signature of the lambda function.
28 | // See https://github.com/rust-lang/rust/issues/62290
29 | lambda_http::run(service_fn(|event: Request| get_products(&store, event))).await?;
30 | Ok(())
31 | }
32 |
--------------------------------------------------------------------------------
/src/bin/lambda/put-product.rs:
--------------------------------------------------------------------------------
1 | use lambda_http::{service_fn, Request};
2 | use products::{entrypoints::lambda::apigateway::put_product, utils::*};
3 |
4 | type E = Box;
5 |
6 | #[tokio::main]
7 | async fn main() -> Result<(), E> {
8 | // Initialize logger
9 | setup_tracing();
10 |
11 | // Initialize store
12 | let store = get_store().await;
13 |
14 | // Run the Lambda function
15 | //
16 | // This is the entry point for the Lambda function. The `lambda_http`
17 | // crate will take care of contacting the Lambda runtime API and invoking
18 | // the `put_product` function.
19 | // See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html
20 | //
21 | // This uses a closure to pass the Service without having to reinstantiate
22 | // it for every call. This is a bit of a hack, but it's the only way to
23 | // pass a store to a lambda function.
24 | //
25 | // Furthermore, we don't await the result of `put_product` because
26 | // async closures aren't stable yet. This way, the closure returns a Future,
27 | // which matches the signature of the lambda function.
28 | // See https://github.com/rust-lang/rust/issues/62290
29 | lambda_http::run(service_fn(|event: Request| put_product(&store, event))).await?;
30 | Ok(())
31 | }
32 |
--------------------------------------------------------------------------------
/src/domain.rs:
--------------------------------------------------------------------------------
1 | //! Domain logic for the application.
2 |
3 | use crate::{
4 | error::Error,
5 | event_bus::EventBus,
6 | model::{Event, Product, ProductRange},
7 | store::{StoreDelete, StoreGet, StoreGetAll, StorePut},
8 | };
9 |
10 | pub async fn get_products(
11 | store: &dyn StoreGetAll,
12 | next: Option<&str>,
13 | ) -> Result {
14 | store.all(next).await
15 | }
16 |
17 | pub async fn get_product(store: &dyn StoreGet, id: &str) -> Result, Error> {
18 | store.get(id).await
19 | }
20 |
21 | pub async fn put_product(store: &dyn StorePut, product: &Product) -> Result<(), Error> {
22 | // Round price to 2 decimal digits
23 | let mut product = product.clone();
24 | product.price = (product.price * 100.0).round() / 100.0;
25 |
26 | store.put(&product).await
27 | }
28 |
29 | pub async fn delete_product(store: &dyn StoreDelete, id: &str) -> Result<(), Error> {
30 | store.delete(id).await
31 | }
32 |
33 | pub async fn send_events(
34 | event_bus: &dyn EventBus,
35 | events: &[Event],
36 | ) -> Result<(), Error> {
37 | event_bus.send_events(events).await
38 | }
39 |
--------------------------------------------------------------------------------
/src/entrypoints/lambda/apigateway.rs:
--------------------------------------------------------------------------------
1 | use crate::{domain, store, Product};
2 | use lambda_http::{http::StatusCode, IntoResponse, Request, RequestExt, Response};
3 | use serde_json::json;
4 | use tracing::{error, info, instrument, warn};
5 |
6 | type E = Box;
7 |
8 | /// Delete a product
9 | #[instrument(skip(store))]
10 | pub async fn delete_product(
11 | store: &dyn store::StoreDelete,
12 | event: Request,
13 | ) -> Result {
14 | // Retrieve product ID from event
15 | //
16 | // If the event doesn't contain a product ID, we return a 400 Bad Request.
17 | let path_parameters = event.path_parameters();
18 | let id = match path_parameters.first("id") {
19 | Some(id) => id,
20 | None => {
21 | warn!("Missing 'id' parameter in path");
22 | return Ok(response(
23 | StatusCode::BAD_REQUEST,
24 | json!({ "message": "Missing 'id' parameter in path" }).to_string(),
25 | ));
26 | }
27 | };
28 |
29 | // Delete product
30 | info!("Deleting product {}", id);
31 | let res = domain::delete_product(store, id).await;
32 |
33 | // Return response
34 | //
35 | // The service returns a Result based on the success of the operation. If
36 | // the operation was successful, the Result is Ok(()), otherwise it will
37 | // contain an Err with the reason.
38 | match res {
39 | Ok(_) => {
40 | info!("Product {} deleted", id);
41 | Ok(response(
42 | StatusCode::OK,
43 | json!({"message": "Product deleted"}).to_string(),
44 | ))
45 | }
46 | Err(err) => {
47 | // Log the error message
48 | error!("Error deleting the product {}: {}", id, err);
49 | Ok(response(
50 | StatusCode::INTERNAL_SERVER_ERROR,
51 | json!({"message": "Failed to delete product"}).to_string(),
52 | ))
53 | }
54 | }
55 | }
56 |
57 | /// Get a product
58 | #[instrument(skip(store))]
59 | pub async fn get_product(
60 | store: &dyn store::StoreGet,
61 | event: Request,
62 | ) -> Result {
63 | // Retrieve product ID from event.
64 | //
65 | // If the event doesn't contain a product ID, we return a 400 Bad Request.
66 | let path_parameters = event.path_parameters();
67 | let id = match path_parameters.first("id") {
68 | Some(id) => id,
69 | None => {
70 | warn!("Missing 'id' parameter in path");
71 | return Ok(response(
72 | StatusCode::BAD_REQUEST,
73 | json!({ "message": "Missing 'id' parameter in path" }).to_string(),
74 | ));
75 | }
76 | };
77 |
78 | // Retrieve product
79 | info!("Fetching product {}", id);
80 | let product = domain::get_product(store, id).await;
81 |
82 | // Return response
83 | //
84 | // Since the service returns an `Option` within a `Result`, there are three
85 | // potential scenarios: the product exists, it doesn't exist, or there was
86 | // an error.
87 | Ok(match product {
88 | // Product exists
89 | Ok(Some(product)) => response(StatusCode::OK, json!(product).to_string()),
90 | // Product doesn't exist
91 | Ok(None) => {
92 | warn!("Product not found: {}", id);
93 | response(
94 | StatusCode::NOT_FOUND,
95 | json!({"message": "Product not found"}).to_string(),
96 | )
97 | }
98 | // Error
99 | Err(err) => {
100 | error!("Error fetching product: {}", err);
101 | response(
102 | StatusCode::INTERNAL_SERVER_ERROR,
103 | json!({"message": "Error fetching product"}).to_string(),
104 | )
105 | }
106 | })
107 | }
108 |
109 | /// Retrieve products
110 | #[instrument(skip(store))]
111 | pub async fn get_products(
112 | store: &dyn store::StoreGetAll,
113 | _event: Request,
114 | ) -> Result {
115 | // Retrieve products
116 | // TODO: Add pagination
117 | let res = domain::get_products(store, None).await;
118 |
119 | // Return response
120 | Ok(match res {
121 | // Return a list of products
122 | Ok(res) => response(StatusCode::OK, json!(res).to_string()),
123 | // Return an error
124 | Err(err) => {
125 | error!("Something went wrong: {:?}", err);
126 | response(
127 | StatusCode::INTERNAL_SERVER_ERROR,
128 | json!({ "message": format!("Something went wrong: {:?}", err) }).to_string(),
129 | )
130 | }
131 | })
132 | }
133 |
134 | /// Put a product
135 | #[instrument(skip(store))]
136 | pub async fn put_product(
137 | store: &dyn store::StorePut,
138 | event: Request,
139 | ) -> Result {
140 | // Retrieve product ID from event.
141 | //
142 | // If the event doesn't contain a product ID, we return a 400 Bad Request.
143 | let path_parameters = event.path_parameters();
144 | let id = match path_parameters.first("id") {
145 | Some(id) => id,
146 | None => {
147 | warn!("Missing 'id' parameter in path");
148 | return Ok(response(
149 | StatusCode::BAD_REQUEST,
150 | json!({ "message": "Missing 'id' parameter in path" }).to_string(),
151 | ));
152 | }
153 | };
154 |
155 | // Read product from request
156 | let product: Product = match event.payload() {
157 | Ok(Some(product)) => product,
158 | Ok(None) => {
159 | warn!("Missing product in request body");
160 | return Ok(response(
161 | StatusCode::BAD_REQUEST,
162 | json!({"message": "Missing product in request body"}).to_string(),
163 | ));
164 | }
165 | Err(err) => {
166 | warn!("Failed to parse product from request body: {}", err);
167 | return Ok(response(
168 | StatusCode::BAD_REQUEST,
169 | json!({"message": "Failed to parse product from request body"}).to_string(),
170 | ));
171 | }
172 | };
173 | info!("Parsed product: {:?}", product);
174 |
175 | // Compare product ID with product ID in body
176 | if product.id != id {
177 | warn!(
178 | "Product ID in path ({}) does not match product ID in body ({})",
179 | id, product.id
180 | );
181 | return Ok(response(
182 | StatusCode::BAD_REQUEST,
183 | json!({"message": "Product ID in path does not match product ID in body"}).to_string(),
184 | ));
185 | }
186 |
187 | // Put product
188 | let res = domain::put_product(store, &product).await;
189 |
190 | // Return response
191 | //
192 | // If the put was successful, we return a 201 Created. Otherwise, we return
193 | // a 500 Internal Server Error.
194 | Ok(match res {
195 | // Product created
196 | Ok(_) => {
197 | info!("Created product {:?}", product.id);
198 | response(
199 | StatusCode::CREATED,
200 | json!({"message": "Product created"}).to_string(),
201 | )
202 | }
203 | // Error creating product
204 | Err(err) => {
205 | error!("Failed to create product {}: {}", product.id, err);
206 | response(
207 | StatusCode::INTERNAL_SERVER_ERROR,
208 | json!({"message": "Failed to create product"}).to_string(),
209 | )
210 | }
211 | })
212 | }
213 |
214 | /// HTTP Response with a JSON payload
215 | fn response(status_code: StatusCode, body: String) -> Response {
216 | Response::builder()
217 | .status(status_code)
218 | .header("Content-Type", "application/json")
219 | .body(body)
220 | .unwrap()
221 | }
222 |
--------------------------------------------------------------------------------
/src/entrypoints/lambda/dynamodb/mod.rs:
--------------------------------------------------------------------------------
1 | use crate::{domain, event_bus::EventBus, Event};
2 | use lambda_runtime::Context;
3 | use rayon::prelude::*;
4 | use tracing::{info, instrument};
5 |
6 | pub mod model;
7 |
8 | type E = Box;
9 |
10 | /// Parse events from DynamoDB Streams
11 | #[instrument(skip(event_bus, event))]
12 | pub async fn parse_events(
13 | event_bus: &dyn EventBus,
14 | event: model::DynamoDBEvent,
15 | _: Context,
16 | ) -> Result<(), E> {
17 | info!("Transform events");
18 | let events = event
19 | .records
20 | .par_iter()
21 | .map(|record| record.try_into())
22 | .collect::, _>>()?;
23 |
24 | info!("Dispatching {} events", events.len());
25 | domain::send_events(event_bus, &events).await?;
26 | info!("Done dispatching events");
27 |
28 | Ok(())
29 | }
30 |
--------------------------------------------------------------------------------
/src/entrypoints/lambda/dynamodb/model.rs:
--------------------------------------------------------------------------------
1 | //! # DynamoDB Event models
2 | //!
3 | //! Models for the DynamoDB event entrypoint.
4 | //!
5 | //! We cannot use the models provided by the AWS SDK for Rust, as they do not
6 | //! implement the `serde::Serialize` and `serde::Deserialize` traits.
7 |
8 | use crate::{
9 | model::{Event, Product},
10 | Error,
11 | };
12 | use serde::{Deserialize, Serialize};
13 | use std::collections::HashMap;
14 |
15 | #[derive(Deserialize, Serialize, Debug)]
16 | pub struct DynamoDBEvent {
17 | #[serde(rename = "Records")]
18 | pub records: Vec,
19 | }
20 |
21 | #[derive(Deserialize, Serialize, Debug)]
22 | pub struct DynamoDBRecord {
23 | #[serde(rename = "awsRegion")]
24 | pub aws_region: String,
25 |
26 | #[serde(rename = "dynamodb")]
27 | pub dynamodb: DynamoDBStreamRecord,
28 |
29 | #[serde(rename = "eventID")]
30 | pub event_id: String,
31 |
32 | #[serde(rename = "eventName")]
33 | pub event_name: String,
34 |
35 | #[serde(rename = "eventSource")]
36 | pub event_source: String,
37 |
38 | #[serde(rename = "eventSourceARN")]
39 | pub event_source_arn: String,
40 |
41 | #[serde(rename = "eventVersion")]
42 | pub event_version: String,
43 | }
44 |
45 | impl TryFrom<&DynamoDBRecord> for Event {
46 | type Error = Error;
47 |
48 | /// Try converting a DynamoDB record to an event.
49 | fn try_from(value: &DynamoDBRecord) -> Result {
50 | match value.event_name.as_str() {
51 | "INSERT" => {
52 | let product = (&value.dynamodb.new_image).try_into()?;
53 | Ok(Event::Created { product })
54 | }
55 | "MODIFY" => {
56 | let old = (&value.dynamodb.old_image).try_into()?;
57 | let new = (&value.dynamodb.new_image).try_into()?;
58 | Ok(Event::Updated { old, new })
59 | }
60 | "REMOVE" => {
61 | let product = (&value.dynamodb.old_image).try_into()?;
62 | Ok(Event::Deleted { product })
63 | }
64 | _ => Err(Error::InternalError("Unknown event type")),
65 | }
66 | }
67 | }
68 |
69 | #[derive(Deserialize, Serialize, Debug)]
70 | pub struct DynamoDBStreamRecord {
71 | #[serde(rename = "ApproximateCreationDateTime", default)]
72 | pub approximate_creation_date_time: Option,
73 |
74 | #[serde(rename = "Keys", default)]
75 | pub keys: HashMap,
76 |
77 | #[serde(rename = "NewImage", default)]
78 | pub new_image: HashMap,
79 |
80 | #[serde(rename = "OldImage", default)]
81 | pub old_image: HashMap,
82 |
83 | #[serde(rename = "SequenceNumber")]
84 | pub sequence_number: String,
85 |
86 | #[serde(rename = "SizeBytes")]
87 | pub size_bytes: f64,
88 |
89 | #[serde(rename = "StreamViewType")]
90 | pub stream_view_type: String,
91 | }
92 |
93 | /// Attribute Value
94 | ///
95 | /// This is a copy of the `AttributeValue` struct from the AWS SDK for Rust,
96 | /// but without blob and `is_`-prefixed methods.
97 | /// See https://docs.rs/aws-sdk-dynamodb/0.0.22-alpha/aws_sdk_dynamodb/model/enum.AttributeValue.html
98 | #[derive(Deserialize, Serialize, Debug)]
99 | pub enum AttributeValue {
100 | // B(Blob),
101 | Bool(bool),
102 | // Bs(Vec),
103 | L(Vec),
104 | M(HashMap),
105 | N(String),
106 | Ns(Vec),
107 | Null(bool),
108 | S(String),
109 | Ss(Vec),
110 | }
111 |
112 | impl AttributeValue {
113 | pub fn as_bool(&self) -> Option {
114 | match self {
115 | AttributeValue::Bool(b) => Some(*b),
116 | _ => None,
117 | }
118 | }
119 | pub fn as_l(&self) -> Option<&Vec> {
120 | match self {
121 | AttributeValue::L(l) => Some(l),
122 | _ => None,
123 | }
124 | }
125 | pub fn as_m(&self) -> Option<&HashMap> {
126 | match self {
127 | AttributeValue::M(m) => Some(m),
128 | _ => None,
129 | }
130 | }
131 | pub fn as_n(&self) -> Option {
132 | match self {
133 | AttributeValue::N(n) => n.parse::().ok(),
134 | _ => None,
135 | }
136 | }
137 | pub fn as_ns(&self) -> Vec {
138 | match self {
139 | AttributeValue::Ns(ns) => ns.iter().filter_map(|n| n.parse::().ok()).collect(),
140 | _ => Default::default(),
141 | }
142 | }
143 | pub fn as_null(&self) -> Option {
144 | match self {
145 | AttributeValue::Null(null) => Some(*null),
146 | _ => None,
147 | }
148 | }
149 | pub fn as_s(&self) -> Option<&str> {
150 | match self {
151 | AttributeValue::S(s) => Some(s),
152 | _ => None,
153 | }
154 | }
155 | pub fn as_ss(&self) -> Vec {
156 | match self {
157 | AttributeValue::Ss(ss) => ss.to_owned(),
158 | _ => Default::default(),
159 | }
160 | }
161 | }
162 |
163 | impl TryFrom<&HashMap> for Product {
164 | type Error = Error;
165 |
166 | /// Try to convert a DynamoDB item into a Product
167 | ///
168 | /// This could fail as the DynamoDB item might be missing some fields.
169 | fn try_from(value: &HashMap) -> Result {
170 | Ok(Product {
171 | id: value
172 | .get("id")
173 | .ok_or(Error::InternalError("Missing id"))?
174 | .as_s()
175 | .ok_or(Error::InternalError("id is not a string"))?
176 | .to_string(),
177 | name: value
178 | .get("name")
179 | .ok_or(Error::InternalError("Missing name"))?
180 | .as_s()
181 | .ok_or(Error::InternalError("name is not a string"))?
182 | .to_string(),
183 | price: value
184 | .get("price")
185 | .ok_or(Error::InternalError("Missing price"))?
186 | .as_n()
187 | .ok_or(Error::InternalError("price is not a number"))?,
188 | })
189 | }
190 | }
191 |
192 | #[cfg(test)]
193 | mod tests {
194 | use super::*;
195 |
196 | fn get_ddb_event() -> DynamoDBEvent {
197 | let data = r#"
198 | {
199 | "Records": [
200 | {
201 | "eventID": "1",
202 | "eventVersion": "1.1",
203 | "dynamodb": {
204 | "Keys": {
205 | "id": {
206 | "S": "101"
207 | }
208 | },
209 | "NewImage": {
210 | "id": {
211 | "S": "101"
212 | },
213 | "name": {
214 | "S": "new-item"
215 | },
216 | "price": {
217 | "N": "10.5"
218 | }
219 | },
220 | "StreamViewType": "NEW_AND_OLD_IMAGES",
221 | "SequenceNumber": "111",
222 | "SizeBytes": 26
223 | },
224 | "awsRegion": "us-west-2",
225 | "eventName": "INSERT",
226 | "eventSourceARN": "someARN",
227 | "eventSource": "aws:dynamodb"
228 | },
229 | {
230 | "eventID": "2",
231 | "eventVersion": "1.1",
232 | "dynamodb": {
233 | "OldImage": {
234 | "id": {
235 | "S": "102"
236 | },
237 | "name": {
238 | "S": "new-item2"
239 | },
240 | "price": {
241 | "N": "20.5"
242 | }
243 | },
244 | "SequenceNumber": "222",
245 | "Keys": {
246 | "id": {
247 | "S": "102"
248 | }
249 | },
250 | "SizeBytes": 59,
251 | "NewImage": {
252 | "id": {
253 | "S": "102"
254 | },
255 | "name": {
256 | "S": "new-item2"
257 | },
258 | "price": {
259 | "N": "30.5"
260 | }
261 | },
262 | "StreamViewType": "NEW_AND_OLD_IMAGES"
263 | },
264 | "awsRegion": "us-west-2",
265 | "eventName": "MODIFY",
266 | "eventSourceARN": "someARN",
267 | "eventSource": "aws:dynamodb"
268 | }]
269 | }"#;
270 |
271 | let event: DynamoDBEvent = serde_json::from_str(data).unwrap();
272 |
273 | event
274 | }
275 |
276 | #[test]
277 | fn test_deserialize() {
278 | let event = get_ddb_event();
279 |
280 | assert_eq!(event.records.len(), 2);
281 | assert_eq!(event.records[0].event_name, "INSERT");
282 | assert_eq!(
283 | event.records[0]
284 | .dynamodb
285 | .new_image
286 | .get("name")
287 | .unwrap()
288 | .as_s(),
289 | Some("new-item")
290 | );
291 | assert_eq!(event.records[1].event_name, "MODIFY");
292 | assert_eq!(
293 | event.records[1]
294 | .dynamodb
295 | .old_image
296 | .get("name")
297 | .unwrap()
298 | .as_s(),
299 | Some("new-item2")
300 | );
301 | }
302 |
303 | #[test]
304 | fn test_dynamodb_into_event() {
305 | let ddb_event = get_ddb_event();
306 |
307 | let events = ddb_event
308 | .records
309 | .iter()
310 | .map(|r| r.try_into())
311 | .collect::, _>>()
312 | .unwrap();
313 |
314 | assert_eq!(events.len(), 2);
315 | match &events[0] {
316 | Event::Created { product } => {
317 | assert_eq!(product.id, "101");
318 | assert_eq!(product.name, "new-item");
319 | assert_eq!(product.price, 10.5);
320 | }
321 | _ => {
322 | assert!(false)
323 | }
324 | };
325 | match &events[1] {
326 | Event::Updated { new, old } => {
327 | assert_eq!(new.id, "102");
328 | assert_eq!(new.name, "new-item2");
329 | assert_eq!(new.price, 30.5);
330 | assert_eq!(old.id, "102");
331 | assert_eq!(old.name, "new-item2");
332 | assert_eq!(old.price, 20.5);
333 | }
334 | _ => {
335 | assert!(false)
336 | }
337 | };
338 | }
339 |
340 | #[test]
341 | fn test_dynamodb_into_product() {
342 | let ddb_event = get_ddb_event();
343 |
344 | let product: Product = (&ddb_event.records[0].dynamodb.new_image)
345 | .try_into()
346 | .unwrap();
347 |
348 | assert_eq!(product.id, "101");
349 | assert_eq!(product.name, "new-item");
350 | assert_eq!(product.price, 10.5);
351 | }
352 | }
353 |
--------------------------------------------------------------------------------
/src/entrypoints/lambda/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod apigateway;
2 | pub mod dynamodb;
3 |
--------------------------------------------------------------------------------
/src/entrypoints/mod.rs:
--------------------------------------------------------------------------------
1 | #[cfg(feature = "lambda")]
2 | pub mod lambda;
3 |
--------------------------------------------------------------------------------
/src/error.rs:
--------------------------------------------------------------------------------
1 | use aws_sdk_dynamodb::model::AttributeValue;
2 | use aws_smithy_http::result::SdkError;
3 | use std::error;
4 | use std::fmt;
5 |
6 | #[derive(Debug)]
7 | pub enum Error {
8 | InitError(&'static str),
9 | ClientError(&'static str),
10 | InternalError(&'static str),
11 | SdkError(String),
12 | }
13 |
14 | impl fmt::Display for Error {
15 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
16 | match self {
17 | Error::InitError(msg) => write!(f, "InitError: {}", msg),
18 | Error::ClientError(msg) => write!(f, "ClientError: {}", msg),
19 | Error::InternalError(msg) => write!(f, "InternalError: {}", msg),
20 | Error::SdkError(err) => write!(f, "SdkError: {}", err),
21 | }
22 | }
23 | }
24 |
25 | impl error::Error for Error {}
26 |
27 | impl From for Error {
28 | fn from(_: std::num::ParseFloatError) -> Error {
29 | Error::InternalError("Unable to parse float")
30 | }
31 | }
32 |
33 | impl From<&AttributeValue> for Error {
34 | fn from(_: &AttributeValue) -> Error {
35 | Error::InternalError("Invalid value type")
36 | }
37 | }
38 |
39 | impl From> for Error
40 | where
41 | E: error::Error,
42 | {
43 | fn from(value: SdkError) -> Error {
44 | Error::SdkError(format!("{}", value))
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/event_bus/eventbridge/ext.rs:
--------------------------------------------------------------------------------
1 | use crate::Event;
2 | use aws_sdk_eventbridge::model::PutEventsRequestEntry;
3 |
4 | static SOURCE: &str = "rust-products";
5 |
6 | pub trait EventExt {
7 | fn to_eventbridge(&self, bus_name: &str) -> PutEventsRequestEntry;
8 | }
9 |
10 | impl EventExt for Event {
11 | fn to_eventbridge(&self, bus_name: &str) -> PutEventsRequestEntry {
12 | PutEventsRequestEntry::builder()
13 | .event_bus_name(bus_name)
14 | .source(SOURCE)
15 | .detail_type(match self {
16 | Event::Created { .. } => "ProductCreated",
17 | Event::Updated { .. } => "ProductUpdated",
18 | Event::Deleted { .. } => "ProductDeleted",
19 | })
20 | .resources(self.id())
21 | .detail(serde_json::to_string(self).unwrap())
22 | .build()
23 | }
24 | }
25 |
26 | #[cfg(test)]
27 | mod tests {
28 | use super::*;
29 | use crate::Product;
30 |
31 | #[test]
32 | fn test_to_eventbridge() {
33 | let event = Event::Created {
34 | product: Product {
35 | id: "123".to_string(),
36 | name: "test".to_string(),
37 | price: 10.0,
38 | },
39 | };
40 | let entry = event.to_eventbridge("test-bus");
41 | assert_eq!(entry.event_bus_name.unwrap(), "test-bus");
42 | assert_eq!(entry.source.unwrap(), SOURCE);
43 | assert_eq!(entry.detail_type.unwrap(), "ProductCreated");
44 | assert_eq!(entry.resources.unwrap(), vec!["123".to_string()]);
45 | assert_eq!(
46 | entry.detail.unwrap(),
47 | serde_json::to_string(&event).unwrap()
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/event_bus/eventbridge/mod.rs:
--------------------------------------------------------------------------------
1 | //! EventBridge bus implementation
2 | //!
3 | //! Bus implementation using the AWS SDK for EventBridge.
4 |
5 | use super::EventBus;
6 | use crate::{Error, Event};
7 | use async_trait::async_trait;
8 | use aws_sdk_eventbridge::Client;
9 | use futures::future::join_all;
10 | use tracing::{info, instrument};
11 |
12 | mod ext;
13 | use ext::EventExt;
14 |
15 | /// EventBridge bus implementation.
16 | pub struct EventBridgeBus {
17 | client: Client,
18 | bus_name: String,
19 | }
20 |
21 | impl EventBridgeBus {
22 | pub fn new(client: Client, bus_name: String) -> Self {
23 | Self { client, bus_name }
24 | }
25 | }
26 |
27 | #[async_trait]
28 | impl EventBus for EventBridgeBus {
29 | type E = Event;
30 |
31 | /// Publish an event to the event bus.
32 | #[instrument(skip(self))]
33 | async fn send_event(&self, event: &Self::E) -> Result<(), Error> {
34 | info!("Publishing event to EventBridge");
35 | self.client
36 | .put_events()
37 | .entries(event.to_eventbridge(&self.bus_name))
38 | .send()
39 | .await?;
40 |
41 | Ok(())
42 | }
43 |
44 | /// Publish a batch of events to the event bus.
45 | #[instrument(skip(self, events))]
46 | async fn send_events(&self, events: &[Self::E]) -> Result<(), Error> {
47 | // Send batches of 10 events at a time
48 | //
49 | // EventBridge has a limit of 10 events per `put_events()` request.
50 | //
51 | // `send()` returns a Future, so we can use `join_all` to wait for all of the
52 | // futures to complete. This means we can send all batches at the same time
53 | // and not have to wait for each batch to complete before sending the next one.
54 | info!("Publishing events to EventBridge");
55 | let res = join_all(events.iter().collect::>().chunks(10).map(|chunk| {
56 | self.client
57 | .put_events()
58 | .set_entries(Some(
59 | chunk
60 | .iter()
61 | .map(|e| e.to_eventbridge(&self.bus_name))
62 | .collect::>(),
63 | ))
64 | .send()
65 | }))
66 | .await;
67 |
68 | // Retrieve errors from the response vector
69 | //
70 | // If any of the responses contained an error, we'll return an error.
71 | res.into_iter().collect::, _>>()?;
72 |
73 | Ok(())
74 | }
75 | }
76 |
77 | #[cfg(test)]
78 | mod tests {
79 | use super::*;
80 | use crate::{Event, Product};
81 | use aws_sdk_eventbridge::{Client, Config, Credentials, Region};
82 | use aws_smithy_client::{erase::DynConnector, test_connection::TestConnection};
83 | use aws_smithy_http::body::SdkBody;
84 |
85 | // Config for mocking EventBridge
86 | async fn get_mock_config() -> Config {
87 | let cfg = aws_config::from_env()
88 | .region(Region::new("eu-west-1"))
89 | .credentials_provider(Credentials::new(
90 | "accesskey",
91 | "privatekey",
92 | None,
93 | None,
94 | "dummy",
95 | ))
96 | .load()
97 | .await;
98 |
99 | Config::new(&cfg)
100 | }
101 |
102 | fn get_request_builder() -> http::request::Builder {
103 | http::Request::builder()
104 | .header("content-type", "application/x-amz-json-1.1")
105 | .uri(http::uri::Uri::from_static(
106 | "https://events.eu-west-1.amazonaws.com/",
107 | ))
108 | }
109 |
110 | #[tokio::test]
111 | async fn test_send_event() -> Result<(), Error> {
112 | // GIVEN a mock EventBridge client
113 | let conn = TestConnection::new(vec![(
114 | get_request_builder()
115 | .header("x-amz-target", "AWSEvents.PutEvents")
116 | .body(SdkBody::from(
117 | r#"{"Entries":[{"Source":"rust-products","Resources":["test-id"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id\",\"name\":\"test-name\",\"price\":10.0}}","EventBusName":"test-bus"}]}"#,
118 | ))
119 | .unwrap(),
120 | http::Response::builder()
121 | .status(200)
122 | .body(SdkBody::from("{}"))
123 | .unwrap(),
124 | )]);
125 | let client =
126 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
127 | let event_bus = EventBridgeBus::new(client, "test-bus".to_string());
128 |
129 | // WHEN we send an event
130 | let event = Event::Created {
131 | product: Product {
132 | id: "test-id".to_string(),
133 | name: "test-name".to_string(),
134 | price: 10.0,
135 | },
136 | };
137 | event_bus.send_event(&event).await?;
138 |
139 | // THEN the request should have been sent to EventBridge
140 | assert_eq!(conn.requests().len(), 1);
141 | conn.assert_requests_match(&vec![]);
142 |
143 | Ok(())
144 | }
145 |
146 | #[tokio::test]
147 | async fn test_send_events() -> Result<(), Error> {
148 | // GIVEN a mock EventBridge client
149 | let conn = TestConnection::new(vec![(
150 | get_request_builder()
151 | .header("x-amz-target", "AWSEvents.PutEvents")
152 | .body(SdkBody::from(
153 | r#"{"Entries":[{"Source":"rust-products","Resources":["test-id"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id\",\"name\":\"test-name\",\"price\":10.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-2"],"DetailType":"ProductDeleted","Detail":"{\"type\":\"Deleted\",\"product\":{\"id\":\"test-id-2\",\"name\":\"test-name-2\",\"price\":20.0}}","EventBusName":"test-bus"}]}"#,
154 | ))
155 | .unwrap(),
156 | http::Response::builder()
157 | .status(200)
158 | .body(SdkBody::from("{}"))
159 | .unwrap(),
160 | )]);
161 | let client =
162 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
163 | let event_bus = EventBridgeBus::new(client, "test-bus".to_string());
164 |
165 | // WHEN we send a batch of events
166 | let events = vec![
167 | Event::Created {
168 | product: Product {
169 | id: "test-id".to_string(),
170 | name: "test-name".to_string(),
171 | price: 10.0,
172 | },
173 | },
174 | Event::Deleted {
175 | product: Product {
176 | id: "test-id-2".to_string(),
177 | name: "test-name-2".to_string(),
178 | price: 20.0,
179 | },
180 | },
181 | ];
182 | event_bus.send_events(&events).await?;
183 |
184 | // THEN the request should have been sent to EventBridge
185 | assert_eq!(conn.requests().len(), 1);
186 | conn.assert_requests_match(&vec![]);
187 |
188 | Ok(())
189 | }
190 |
191 | #[tokio::test]
192 | async fn test_send_events0() -> Result<(), Error> {
193 | // GIVEN a mock EventBridge client
194 | let conn: TestConnection = TestConnection::new(vec![]);
195 | let client =
196 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
197 | let event_bus = EventBridgeBus::new(client, "test-bus".to_string());
198 |
199 | // WHEN we send zero events
200 | event_bus.send_events(&vec![]).await?;
201 |
202 | // THEN no request should have been sent to EventBridge
203 | assert_eq!(conn.requests().len(), 0);
204 | conn.assert_requests_match(&vec![]);
205 |
206 | Ok(())
207 | }
208 |
209 | #[tokio::test]
210 | async fn test_send_events15() -> Result<(), Error> {
211 | // GIVEN a mock EventBridge client
212 | let conn = TestConnection::new(vec![(
213 | get_request_builder()
214 | .header("x-amz-target", "AWSEvents.PutEvents")
215 | .body(SdkBody::from(
216 | r#"{"Entries":[{"Source":"rust-products","Resources":["test-id-0"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-0\",\"name\":\"test-name-0\",\"price\":10.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-1"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-1\",\"name\":\"test-name-1\",\"price\":11.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-2"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-2\",\"name\":\"test-name-2\",\"price\":12.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-3"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-3\",\"name\":\"test-name-3\",\"price\":13.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-4"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-4\",\"name\":\"test-name-4\",\"price\":14.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-5"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-5\",\"name\":\"test-name-5\",\"price\":15.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-6"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-6\",\"name\":\"test-name-6\",\"price\":16.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-7"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-7\",\"name\":\"test-name-7\",\"price\":17.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-8"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-8\",\"name\":\"test-name-8\",\"price\":18.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-9"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-9\",\"name\":\"test-name-9\",\"price\":19.0}}","EventBusName":"test-bus"}]}"#,
217 | ))
218 | .unwrap(),
219 | http::Response::builder()
220 | .status(200)
221 | .body(SdkBody::from("{}"))
222 | .unwrap(),
223 | ), (
224 | get_request_builder()
225 | .header("x-amz-target", "AWSEvents.PutEvents")
226 | .body(SdkBody::from(
227 | r#"{"Entries":[{"Source":"rust-products","Resources":["test-id-10"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-10\",\"name\":\"test-name-10\",\"price\":20.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-11"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-11\",\"name\":\"test-name-11\",\"price\":21.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-12"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-12\",\"name\":\"test-name-12\",\"price\":22.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-13"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-13\",\"name\":\"test-name-13\",\"price\":23.0}}","EventBusName":"test-bus"},{"Source":"rust-products","Resources":["test-id-14"],"DetailType":"ProductCreated","Detail":"{\"type\":\"Created\",\"product\":{\"id\":\"test-id-14\",\"name\":\"test-name-14\",\"price\":24.0}}","EventBusName":"test-bus"}]}"#,
228 | ))
229 | .unwrap(),
230 | http::Response::builder()
231 | .status(200)
232 | .body(SdkBody::from("{}"))
233 | .unwrap(),
234 | )]);
235 | let client =
236 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
237 | let event_bus = EventBridgeBus::new(client, "test-bus".to_string());
238 |
239 | // WHEN we send 15 events
240 | let events = (0..15)
241 | .map(|i| Event::Created {
242 | product: Product {
243 | id: format!("test-id-{}", i),
244 | name: format!("test-name-{}", i),
245 | price: 10.0 + i as f64,
246 | },
247 | })
248 | .collect::>();
249 | event_bus.send_events(&events).await?;
250 |
251 | // THEN two requests should have been sent to EventBridge
252 | assert_eq!(conn.requests().len(), 2);
253 | conn.assert_requests_match(&vec![]);
254 |
255 | Ok(())
256 | }
257 | }
258 |
--------------------------------------------------------------------------------
/src/event_bus/mod.rs:
--------------------------------------------------------------------------------
1 | use crate::Error;
2 | use async_trait::async_trait;
3 |
4 | mod eventbridge;
5 | mod void;
6 |
7 | pub use eventbridge::EventBridgeBus;
8 | pub use void::VoidBus;
9 |
10 | #[async_trait]
11 | pub trait EventBus {
12 | type E;
13 |
14 | async fn send_event(&self, event: &Self::E) -> Result<(), Error>;
15 | async fn send_events(&self, events: &[Self::E]) -> Result<(), Error>;
16 | }
17 |
--------------------------------------------------------------------------------
/src/event_bus/void.rs:
--------------------------------------------------------------------------------
1 | use super::EventBus;
2 | use crate::{Error, Event};
3 | use async_trait::async_trait;
4 |
5 | #[derive(Default)]
6 | pub struct VoidBus;
7 |
8 | impl VoidBus {
9 | pub fn new() -> Self {
10 | Self
11 | }
12 | }
13 |
14 | #[async_trait]
15 | impl EventBus for VoidBus {
16 | type E = Event;
17 |
18 | async fn send_event(&self, _: &Self::E) -> Result<(), Error> {
19 | Err(Error::InternalError("send_event is not supported"))
20 | }
21 |
22 | async fn send_events(&self, _: &[Self::E]) -> Result<(), Error> {
23 | Err(Error::InternalError("send_events is not supported"))
24 | }
25 | }
26 |
27 | #[cfg(test)]
28 | mod tests {
29 | use super::*;
30 | use crate::Product;
31 |
32 | #[tokio::test]
33 | async fn test_send_event() {
34 | let bus = VoidBus;
35 | let event = Event::Created {
36 | product: Product {
37 | id: "123".to_string(),
38 | name: "test".to_string(),
39 | price: 10.0,
40 | },
41 | };
42 | let result = bus.send_event(&event).await;
43 | assert!(result.is_err());
44 | }
45 |
46 | #[tokio::test]
47 | async fn test_send_events() {
48 | let bus = VoidBus;
49 | let event = Event::Created {
50 | product: Product {
51 | id: "123".to_string(),
52 | name: "test".to_string(),
53 | price: 10.0,
54 | },
55 | };
56 | let result = bus.send_events(&[event]).await;
57 | assert!(result.is_err());
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | //! # Domain logic for the service
2 |
3 | pub mod domain;
4 | pub mod entrypoints;
5 | mod error;
6 | pub mod event_bus;
7 | mod model;
8 | pub mod store;
9 | pub mod utils;
10 |
11 | pub use error::Error;
12 | use event_bus::EventBus;
13 | pub use model::{Event, Product, ProductRange};
14 |
15 | /// Event Service
16 | ///
17 | /// This service takes events and publishes them to the event bus.
18 | pub struct EventService {
19 | event_bus: Box + Send + Sync>,
20 | }
21 |
22 | impl EventService {
23 | pub fn new(event_bus: Box + Send + Sync>) -> Self {
24 | Self { event_bus }
25 | }
26 |
27 | // Send a batch of events
28 | pub async fn send_events(&self, events: &[Event]) -> Result<(), Error> {
29 | self.event_bus.send_events(events).await
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/model.rs:
--------------------------------------------------------------------------------
1 | //! Data models
2 | //!
3 | //! This module contains the representations of the products.
4 |
5 | use serde::{Deserialize, Serialize};
6 |
7 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
8 | pub struct Product {
9 | pub id: String,
10 | pub name: String,
11 | pub price: f64,
12 | }
13 |
14 | #[derive(Debug, Default, Deserialize, Serialize)]
15 | pub struct ProductRange {
16 | pub products: Vec,
17 | #[serde(skip_serializing_if = "Option::is_none")]
18 | pub next: Option,
19 | }
20 |
21 | #[derive(Debug, Deserialize, Serialize)]
22 | #[serde(tag = "type")]
23 | pub enum Event {
24 | Created { product: Product },
25 | Updated { old: Product, new: Product },
26 | Deleted { product: Product },
27 | }
28 |
29 | impl Event {
30 | pub fn id(&self) -> &str {
31 | match self {
32 | Event::Created { product } => product.id.as_str(),
33 | Event::Updated { new, .. } => new.id.as_str(),
34 | Event::Deleted { product } => product.id.as_str(),
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/store/dynamodb/ext.rs:
--------------------------------------------------------------------------------
1 | //! # Extension traits for `DynamoDbStore`.
2 |
3 | use aws_sdk_dynamodb::model::AttributeValue;
4 | use std::collections::HashMap;
5 |
6 | /// Trait to extract concrete values from a DynamoDB item
7 | ///
8 | /// The DynamoDB client returns AttributeValues, which are enums that contain
9 | /// the concrete values. This trait provides additional methods to the HashMap
10 | /// to extract those values.
11 | pub trait AttributeValuesExt {
12 | fn get_s(&self, key: &str) -> Option;
13 | fn get_n(&self, key: &str) -> Option;
14 | }
15 |
16 | impl AttributeValuesExt for HashMap {
17 | /// Return a string from a key
18 | ///
19 | /// E.g. if you run `get_s("id")` on a DynamoDB item structured like this,
20 | /// you will retrieve the value `"foo"`.
21 | ///
22 | /// ```json
23 | /// {
24 | /// "id": {
25 | /// "S": "foo"
26 | /// }
27 | /// }
28 | /// ```
29 | fn get_s(&self, key: &str) -> Option {
30 | Some(self.get(key)?.as_s().ok()?.to_owned())
31 | }
32 |
33 | /// Return a number from a key
34 | ///
35 | /// E.g. if you run `get_n("price")` on a DynamoDB item structured like this,
36 | /// you will retrieve the value `10.0`.
37 | ///
38 | /// ```json
39 | /// {
40 | /// "price": {
41 | /// "N": "10.0"
42 | /// }
43 | /// }
44 | /// ```
45 | fn get_n(&self, key: &str) -> Option {
46 | self.get(key)?.as_n().ok()?.parse::().ok()
47 | }
48 | }
49 |
50 | #[cfg(test)]
51 | mod tests {
52 | use super::*;
53 |
54 | #[test]
55 | fn attributevalue_get_s() {
56 | let mut item = HashMap::new();
57 | item.insert("id".to_owned(), AttributeValue::S("foo".to_owned()));
58 |
59 | assert_eq!(item.get_s("id"), Some("foo".to_owned()));
60 | }
61 |
62 | #[test]
63 | fn attributevalue_get_s_missing() {
64 | let mut item = HashMap::new();
65 | item.insert("id".to_owned(), AttributeValue::S("foo".to_owned()));
66 |
67 | assert_eq!(item.get_s("foo"), None);
68 | }
69 |
70 | #[test]
71 | fn attributevalue_get_n() {
72 | let mut item = HashMap::new();
73 | item.insert("price".to_owned(), AttributeValue::N("10.0".to_owned()));
74 |
75 | assert_eq!(item.get_n("price"), Some(10.0));
76 | }
77 |
78 | #[test]
79 | fn attributevalue_get_n_missing() {
80 | let mut item = HashMap::new();
81 | item.insert("price".to_owned(), AttributeValue::N("10.0".to_owned()));
82 |
83 | assert_eq!(item.get_n("foo"), None);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/store/dynamodb/mod.rs:
--------------------------------------------------------------------------------
1 | //! # DynamoDB store implementation
2 | //!
3 | //! Store implementation using the AWS SDK for DynamoDB.
4 |
5 | use super::{Store, StoreDelete, StoreGet, StoreGetAll, StorePut};
6 | use crate::{Error, Product, ProductRange};
7 | use async_trait::async_trait;
8 | use aws_sdk_dynamodb::{model::AttributeValue, Client};
9 | use std::collections::HashMap;
10 | use tracing::{info, instrument};
11 |
12 | mod ext;
13 | use ext::AttributeValuesExt;
14 |
15 | /// DynamoDB store implementation.
16 | pub struct DynamoDBStore {
17 | client: Client,
18 | table_name: String,
19 | }
20 |
21 | impl DynamoDBStore {
22 | pub fn new(client: Client, table_name: String) -> DynamoDBStore {
23 | DynamoDBStore { client, table_name }
24 | }
25 | }
26 |
27 | impl Store for DynamoDBStore {}
28 |
29 | #[async_trait]
30 | impl StoreGetAll for DynamoDBStore {
31 | /// Get all items
32 | #[instrument(skip(self))]
33 | async fn all(&self, next: Option<&str>) -> Result {
34 | // Scan DynamoDB table
35 | info!("Scanning DynamoDB table");
36 | let mut req = self.client.scan().table_name(&self.table_name).limit(20);
37 | req = if let Some(next) = next {
38 | req.exclusive_start_key("id", AttributeValue::S(next.to_owned()))
39 | } else {
40 | req
41 | };
42 | let res = req.send().await?;
43 |
44 | // Build response
45 | let products = match res.items {
46 | Some(items) => items
47 | .into_iter()
48 | .map(|v| v.try_into())
49 | .collect::, Error>>()?,
50 | None => Vec::default(),
51 | };
52 | let next = res.last_evaluated_key.map(|m| m.get_s("id").unwrap());
53 | Ok(ProductRange { products, next })
54 | }
55 | }
56 |
57 | #[async_trait]
58 | impl StoreGet for DynamoDBStore {
59 | /// Get item
60 | #[instrument(skip(self))]
61 | async fn get(&self, id: &str) -> Result, Error> {
62 | info!("Getting item with id '{}' from DynamoDB table", id);
63 | let res = self
64 | .client
65 | .get_item()
66 | .table_name(&self.table_name)
67 | .key("id", AttributeValue::S(id.to_owned()))
68 | .send()
69 | .await?;
70 |
71 | Ok(match res.item {
72 | Some(item) => Some(item.try_into()?),
73 | None => None,
74 | })
75 | }
76 | }
77 |
78 | #[async_trait]
79 | impl StorePut for DynamoDBStore {
80 | /// Create or update an item
81 | #[instrument(skip(self))]
82 | async fn put(&self, product: &Product) -> Result<(), Error> {
83 | info!("Putting item with id '{}' into DynamoDB table", product.id);
84 | self.client
85 | .put_item()
86 | .table_name(&self.table_name)
87 | .set_item(Some(product.into()))
88 | .send()
89 | .await?;
90 |
91 | Ok(())
92 | }
93 | }
94 |
95 | #[async_trait]
96 | impl StoreDelete for DynamoDBStore {
97 | /// Delete item
98 | #[instrument(skip(self))]
99 | async fn delete(&self, id: &str) -> Result<(), Error> {
100 | info!("Deleting item with id '{}' from DynamoDB table", id);
101 | self.client
102 | .delete_item()
103 | .table_name(&self.table_name)
104 | .key("id", AttributeValue::S(id.to_owned()))
105 | .send()
106 | .await?;
107 |
108 | Ok(())
109 | }
110 | }
111 |
112 | impl From<&Product> for HashMap {
113 | /// Convert a &Product into a DynamoDB item
114 | fn from(value: &Product) -> HashMap {
115 | let mut retval = HashMap::new();
116 | retval.insert("id".to_owned(), AttributeValue::S(value.id.clone()));
117 | retval.insert("name".to_owned(), AttributeValue::S(value.name.clone()));
118 | retval.insert(
119 | "price".to_owned(),
120 | AttributeValue::N(format!("{:}", value.price)),
121 | );
122 |
123 | retval
124 | }
125 | }
126 | impl TryFrom> for Product {
127 | type Error = Error;
128 |
129 | /// Try to convert a DynamoDB item into a Product
130 | ///
131 | /// This could fail as the DynamoDB item might be missing some fields.
132 | fn try_from(value: HashMap) -> Result {
133 | Ok(Product {
134 | id: value
135 | .get_s("id")
136 | .ok_or(Error::InternalError("Missing id"))?,
137 | name: value
138 | .get_s("name")
139 | .ok_or(Error::InternalError("Missing name"))?,
140 | price: value
141 | .get_n("price")
142 | .ok_or(Error::InternalError("Missing price"))?,
143 | })
144 | }
145 | }
146 |
147 | #[cfg(test)]
148 | mod tests {
149 | use super::*;
150 | use crate::Error;
151 | use aws_sdk_dynamodb::{Client, Config, Credentials, Region};
152 | use aws_smithy_client::{erase::DynConnector, test_connection::TestConnection};
153 | use aws_smithy_http::body::SdkBody;
154 |
155 | /// Config for mocking DynamoDB
156 | async fn get_mock_config() -> Config {
157 | let cfg = aws_config::from_env()
158 | .region(Region::new("eu-west-1"))
159 | .credentials_provider(Credentials::new(
160 | "accesskey",
161 | "privatekey",
162 | None,
163 | None,
164 | "dummy",
165 | ))
166 | .load()
167 | .await;
168 |
169 | Config::new(&cfg)
170 | }
171 |
172 | fn get_request_builder() -> http::request::Builder {
173 | http::Request::builder()
174 | .header("content-type", "application/x-amz-json-1.0")
175 | .uri(http::uri::Uri::from_static(
176 | "https://dynamodb.eu-west-1.amazonaws.com/",
177 | ))
178 | }
179 |
180 | #[tokio::test]
181 | async fn test_all_empty() -> Result<(), Error> {
182 | // GIVEN a DynamoDBStore with no items
183 | let conn = TestConnection::new(vec![(
184 | get_request_builder()
185 | .header("x-amz-target", "DynamoDB_20120810.Scan")
186 | .body(SdkBody::from(r#"{"TableName":"test","Limit":20}"#))
187 | .unwrap(),
188 | http::Response::builder()
189 | .status(200)
190 | .body(SdkBody::from(r#"{"Items": []}"#))
191 | .unwrap(),
192 | )]);
193 | let client =
194 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
195 | let store = DynamoDBStore::new(client, "test".to_string());
196 |
197 | // WHEN getting all items
198 | let res = store.all(None).await?;
199 |
200 | // THEN the response is empty
201 | assert_eq!(res.products.len(), 0);
202 | // AND the request matches the expected request
203 | conn.assert_requests_match(&vec![]);
204 |
205 | Ok(())
206 | }
207 |
208 | #[tokio::test]
209 | async fn test_all() -> Result<(), Error> {
210 | // GIVEN a DynamoDBStore with one item
211 | let conn = TestConnection::new(vec![(
212 | get_request_builder()
213 | .header("x-amz-target", "DynamoDB_20120810.Scan")
214 | .body(SdkBody::from(r#"{"TableName":"test","Limit":20}"#)).unwrap(),
215 | http::Response::builder()
216 | .status(200)
217 | .body(SdkBody::from(r#"{"Items": [{"id": {"S": "1"}, "name": {"S": "test1"}, "price": {"N": "1.0"}}]}"#))
218 | .unwrap(),
219 | )]);
220 | let client =
221 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
222 | let store = DynamoDBStore::new(client, "test".to_string());
223 |
224 | // WHEN getting all items
225 | let res = store.all(None).await?;
226 |
227 | // THEN the response has one item
228 | assert_eq!(res.products.len(), 1);
229 | // AND the item has the correct id
230 | assert_eq!(res.products[0].id, "1");
231 | // AND the item has the correct name
232 | assert_eq!(res.products[0].name, "test1");
233 | // AND the item has the correct price
234 | assert_eq!(res.products[0].price, 1.0);
235 | // AND the request matches the expected request
236 | conn.assert_requests_match(&vec![]);
237 |
238 | Ok(())
239 | }
240 |
241 | #[tokio::test]
242 | async fn test_all_next() -> Result<(), Error> {
243 | // GIVEN a DynamoDBStore with a last evaluated key
244 | let conn = TestConnection::new(vec![(
245 | get_request_builder()
246 | .header("x-amz-target", "DynamoDB_20120810.Scan")
247 | .body(SdkBody::from(r#"{"TableName":"test","Limit":20}"#))
248 | .unwrap(),
249 | http::Response::builder()
250 | .status(200)
251 | .body(SdkBody::from(
252 | r#"{"Items": [], "LastEvaluatedKey": {"id": {"S": "1"}}}"#,
253 | ))
254 | .unwrap(),
255 | )]);
256 | let client =
257 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
258 | let store = DynamoDBStore::new(client, "test".to_string());
259 |
260 | // WHEN getting all items
261 | let res = store.all(None).await?;
262 |
263 | // THEN the response has a next key
264 | assert_eq!(res.next, Some("1".to_string()));
265 | // AND the request matches the expected request
266 | conn.assert_requests_match(&vec![]);
267 |
268 | Ok(())
269 | }
270 |
271 | #[tokio::test]
272 | async fn test_delete() -> Result<(), Error> {
273 | // GIVEN a DynamoDBStore
274 | let conn = TestConnection::new(vec![(
275 | get_request_builder()
276 | .header("x-amz-target", "DynamoDB_20120810.DeleteItem")
277 | .body(SdkBody::from(
278 | r#"{"TableName": "test", "Key": {"id": {"S": "1"}}}"#,
279 | ))
280 | .unwrap(),
281 | http::Response::builder()
282 | .status(200)
283 | .body(SdkBody::from("{}"))
284 | .unwrap(),
285 | )]);
286 | let client =
287 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
288 | let store = DynamoDBStore::new(client, "test".to_string());
289 |
290 | // WHEN deleting an item
291 | store.delete("1").await?;
292 |
293 | // THEN the request matches the expected request
294 | conn.assert_requests_match(&vec![]);
295 |
296 | Ok(())
297 | }
298 |
299 | #[tokio::test]
300 | async fn test_get() -> Result<(), Error> {
301 | // GIVEN a DynamoDBStore with one item
302 | let conn = TestConnection::new(vec![(
303 | get_request_builder()
304 | .header("x-amz-target", "DynamoDB_20120810.GetItem")
305 | .body(SdkBody::from(r#"{"TableName": "test", "Key": {"id": {"S": "1"}}}"#))
306 | .unwrap(),
307 | http::Response::builder()
308 | .status(200)
309 | .body(SdkBody::from(r#"{"Item": {"id": {"S": "1"}, "name": {"S": "test1"}, "price": {"N": "1.0"}}}"#))
310 | .unwrap(),
311 | )]);
312 | let client =
313 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
314 | let store = DynamoDBStore::new(client, "test".to_string());
315 |
316 | // WHEN getting an item
317 | let res = store.get("1").await?;
318 |
319 | // THEN the response has the correct values
320 | if let Some(product) = res {
321 | assert_eq!(product.id, "1");
322 | assert_eq!(product.name, "test1");
323 | assert_eq!(product.price, 1.0);
324 | } else {
325 | panic!("Expected product to be Some");
326 | }
327 | // AND the request matches the expected request
328 | conn.assert_requests_match(&vec![]);
329 |
330 | Ok(())
331 | }
332 |
333 | #[tokio::test]
334 | async fn test_put() -> Result<(), Error> {
335 | // GIVEN an empty DynamoDBStore and a product
336 | let conn = TestConnection::new(vec![(
337 | get_request_builder()
338 | .header("x-amz-target", "DynamoDB_20120810.PutItem")
339 | .body(SdkBody::from(r#"{"TableName":"test","Item":{"id":{"S":"1"},"name":{"S":"test1"},"price":{"N":"1.5"}}}"#))
340 | .unwrap(),
341 | http::Response::builder()
342 | .status(200)
343 | .body(SdkBody::from(r#"{"Attributes": {"id": {"S": "1"}, "name": {"S": "test1"}, "price": {"N": "1.5"}}}"#))
344 | .unwrap(),
345 | )]);
346 | let client =
347 | Client::from_conf_conn(get_mock_config().await, DynConnector::new(conn.clone()));
348 | let store = DynamoDBStore::new(client, "test".to_string());
349 | let product = Product {
350 | id: "1".to_string(),
351 | name: "test1".to_string(),
352 | price: 1.5,
353 | };
354 |
355 | // WHEN putting an item
356 | store.put(&product).await?;
357 |
358 | // THEN the request matches the expected request
359 | conn.assert_requests_match(&vec![]);
360 |
361 | Ok(())
362 | }
363 |
364 | #[test]
365 | fn product_from_dynamodb() {
366 | let mut value = HashMap::new();
367 | value.insert("id".to_owned(), AttributeValue::S("id".to_owned()));
368 | value.insert("name".to_owned(), AttributeValue::S("name".to_owned()));
369 | value.insert("price".to_owned(), AttributeValue::N("1.0".to_owned()));
370 |
371 | let product = Product::try_from(value).unwrap();
372 | assert_eq!(product.id, "id");
373 | assert_eq!(product.name, "name");
374 | assert_eq!(product.price, 1.0);
375 | }
376 |
377 | #[test]
378 | fn product_to_dynamodb() -> Result<(), Error> {
379 | let product = Product {
380 | id: "id".to_owned(),
381 | name: "name".to_owned(),
382 | price: 1.5,
383 | };
384 |
385 | let value: HashMap = (&product).into();
386 | assert_eq!(value.get("id").unwrap().as_s().unwrap(), "id");
387 | assert_eq!(value.get("name").unwrap().as_s().unwrap(), "name");
388 | assert_eq!(value.get("price").unwrap().as_n().unwrap(), "1.5");
389 |
390 | Ok(())
391 | }
392 | }
393 |
--------------------------------------------------------------------------------
/src/store/memory.rs:
--------------------------------------------------------------------------------
1 | //! # In-memory store implementation
2 | //!
3 | //! This is a simple in-memory store implementation. It is not intended to be
4 | //! used in production, but rather as a simple implementation for local
5 | //! testing purposes.
6 |
7 | use super::{Store, StoreDelete, StoreGet, StoreGetAll, StorePut};
8 | use crate::{Error, Product, ProductRange};
9 | use async_trait::async_trait;
10 | use std::collections::HashMap;
11 | use std::sync::RwLock;
12 |
13 | #[derive(Default)]
14 | pub struct MemoryStore {
15 | data: RwLock>,
16 | }
17 |
18 | impl MemoryStore {
19 | pub fn new() -> Self {
20 | Default::default()
21 | }
22 | }
23 |
24 | impl Store for MemoryStore {}
25 |
26 | #[async_trait]
27 | impl StoreGetAll for MemoryStore {
28 | async fn all(&self, _: Option<&str>) -> Result {
29 | Ok(ProductRange {
30 | products: self
31 | .data
32 | .read()
33 | .unwrap()
34 | .iter()
35 | .map(|(_, v)| v.clone())
36 | .collect(),
37 | next: None,
38 | })
39 | }
40 | }
41 |
42 | #[async_trait]
43 | impl StoreGet for MemoryStore {
44 | async fn get(&self, id: &str) -> Result, Error> {
45 | Ok(self.data.read().unwrap().get(id).cloned())
46 | }
47 | }
48 |
49 | #[async_trait]
50 | impl StorePut for MemoryStore {
51 | async fn put(&self, product: &Product) -> Result<(), Error> {
52 | self.data
53 | .write()
54 | .unwrap()
55 | .insert(product.id.clone(), product.clone());
56 | Ok(())
57 | }
58 | }
59 |
60 | #[async_trait]
61 | impl StoreDelete for MemoryStore {
62 | async fn delete(&self, id: &str) -> Result<(), Error> {
63 | self.data.write().unwrap().remove(id);
64 | Ok(())
65 | }
66 | }
67 |
68 | #[cfg(test)]
69 | mod tests {
70 | use super::*;
71 | use crate::Error;
72 |
73 | struct ConstProduct<'a> {
74 | id: &'a str,
75 | name: &'a str,
76 | price: f64,
77 | }
78 |
79 | impl Into for ConstProduct<'_> {
80 | fn into(self) -> Product {
81 | Product {
82 | id: self.id.to_string(),
83 | name: self.name.to_string(),
84 | price: self.price,
85 | }
86 | }
87 | }
88 |
89 | const PRODUCT_0: ConstProduct = ConstProduct {
90 | id: "1",
91 | name: "foo",
92 | price: 10.0,
93 | };
94 | const PRODUCT_1: ConstProduct = ConstProduct {
95 | id: "2",
96 | name: "foo",
97 | price: 10.0,
98 | };
99 |
100 | #[tokio::test]
101 | async fn test_new() -> Result<(), Error> {
102 | // GIVEN an empty store
103 | let store = MemoryStore::new();
104 |
105 | // WHEN we get the length of all products
106 | // THEN we get 0
107 | assert_eq!(store.data.read().unwrap().len(), 0);
108 | Ok(())
109 | }
110 |
111 | #[tokio::test]
112 | async fn test_all_empty() -> Result<(), Error> {
113 | // GIVEN an empty store
114 | let store = MemoryStore::new();
115 |
116 | // WHEN we get all products
117 | let all = store.all(None).await?;
118 |
119 | // THEN we get an empty list
120 | assert_eq!(all.products.len(), 0);
121 |
122 | Ok(())
123 | }
124 |
125 | #[tokio::test]
126 | async fn test_all1() -> Result<(), Error> {
127 | // GIVEN a store with one product
128 | let product0: Product = PRODUCT_0.into();
129 | let store = MemoryStore::new();
130 | {
131 | let mut data = store.data.write().unwrap();
132 | data.insert(product0.id.clone(), product0.clone());
133 | }
134 |
135 | // WHEN we get all products
136 | let all = store.all(None).await?;
137 |
138 | // THEN we get the product
139 | assert_eq!(all.products.len(), 1);
140 | assert_eq!(all.products[0], product0);
141 |
142 | Ok(())
143 | }
144 |
145 | #[tokio::test]
146 | async fn test_all2() -> Result<(), Error> {
147 | // GIVEN a store with two products
148 | let product0: Product = PRODUCT_0.into();
149 | let product1: Product = PRODUCT_1.into();
150 | let store = MemoryStore::new();
151 | {
152 | let mut data = store.data.write().unwrap();
153 | data.insert(product0.id.clone(), product0.clone());
154 | data.insert(product1.id.clone(), product1.clone());
155 | }
156 |
157 | // WHEN we get all products
158 | let all = store.all(None).await?;
159 |
160 | // THEN we get the products
161 | assert_eq!(all.products.len(), 2);
162 | assert!(all.products.contains(&product0));
163 | assert!(all.products.contains(&product1));
164 |
165 | Ok(())
166 | }
167 |
168 | #[tokio::test]
169 | async fn test_delete() -> Result<(), Error> {
170 | // GIVEN a store with a product
171 | let product0: Product = PRODUCT_0.into();
172 | let store = MemoryStore::new();
173 | {
174 | let mut data = store.data.write().unwrap();
175 | data.insert(product0.id.clone(), product0.clone());
176 | }
177 |
178 | // WHEN deleting the product
179 | store.delete(&product0.id).await?;
180 |
181 | // THEN the length of the store is 0
182 | assert_eq!(store.data.read().unwrap().len(), 0);
183 | // AND the product is not returned
184 | assert_eq!(store.get(&product0.id).await?, None);
185 |
186 | Ok(())
187 | }
188 |
189 | #[tokio::test]
190 | async fn test_delete2() -> Result<(), Error> {
191 | // GIVEN a store with two products
192 | let product0: Product = PRODUCT_0.into();
193 | let product1: Product = PRODUCT_1.into();
194 | let store = MemoryStore::new();
195 | {
196 | let mut data = store.data.write().unwrap();
197 | data.insert(product0.id.clone(), product0.clone());
198 | data.insert(product1.id.clone(), product1.clone());
199 | }
200 |
201 | // WHEN deleting the first product
202 | store.delete(&product0.id).await?;
203 |
204 | // THEN the length of the store is 1
205 | assert_eq!(store.data.read().unwrap().len(), 1);
206 | // AND the product is not returned
207 | assert_eq!(store.get(&product0.id).await?, None);
208 | // AND the second product is returned
209 | assert_eq!(store.get(&product1.id).await?, Some(product1));
210 |
211 | Ok(())
212 | }
213 |
214 | #[tokio::test]
215 | async fn test_get() -> Result<(), Error> {
216 | // GIVEN a store with a product
217 | let product0: Product = PRODUCT_0.into();
218 | let store = MemoryStore::new();
219 | {
220 | let mut data = store.data.write().unwrap();
221 | data.insert(product0.id.clone(), product0.clone());
222 | }
223 |
224 | // WHEN getting the product
225 | let product = store.get(&product0.id).await?;
226 |
227 | // THEN the product is returned
228 | assert_eq!(product, Some(product0));
229 |
230 | Ok(())
231 | }
232 |
233 | #[tokio::test]
234 | async fn test_put() -> Result<(), Error> {
235 | // GIVEN an empty store and a product
236 | let store = MemoryStore::new();
237 | let product0: Product = PRODUCT_0.into();
238 |
239 | // WHEN inserting a product
240 | store.put(&product0).await?;
241 |
242 | // THEN the length of the store is 1
243 | assert_eq!(store.data.read().unwrap().len(), 1);
244 | // AND the product is returned
245 | assert_eq!(store.get(&product0.id).await?, Some(product0));
246 |
247 | Ok(())
248 | }
249 |
250 | #[tokio::test]
251 | async fn test_put2() -> Result<(), Error> {
252 | // GIVEN an empty store and two products
253 | let store = MemoryStore::new();
254 | let product0: Product = PRODUCT_0.into();
255 | let product1: Product = PRODUCT_1.into();
256 |
257 | // WHEN inserting two products
258 | store.put(&product0).await?;
259 | store.put(&product1).await?;
260 |
261 | // THEN the length of the store is 2
262 | assert_eq!(store.data.read().unwrap().len(), 2);
263 | // AND the products are returned
264 | assert_eq!(store.get(&product0.id).await?, Some(product0));
265 | assert_eq!(store.get(&product1.id).await?, Some(product1));
266 |
267 | Ok(())
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/src/store/mod.rs:
--------------------------------------------------------------------------------
1 | use crate::{Error, Product, ProductRange};
2 | use async_trait::async_trait;
3 |
4 | mod dynamodb;
5 | mod memory;
6 |
7 | pub use dynamodb::DynamoDBStore;
8 | pub use memory::MemoryStore;
9 |
10 | pub trait Store: StoreGetAll + StoreGet + StorePut + StoreDelete {}
11 |
12 | /// Trait for retrieving all products
13 | ///
14 | /// This trait is implemented by the different storage backends. It provides
15 | /// the basic interface for retrieving all products.
16 | ///
17 | /// A given store could return only a partial list of all the products. If
18 | /// this is the case, the `next` parameter should be used to retrieve the
19 | /// next page of products.
20 | #[async_trait]
21 | pub trait StoreGetAll: Send + Sync {
22 | async fn all(&self, next: Option<&str>) -> Result;
23 | }
24 |
25 | /// Trait for retrieving a single product
26 | #[async_trait]
27 | pub trait StoreGet: Send + Sync {
28 | async fn get(&self, id: &str) -> Result, Error>;
29 | }
30 |
31 | /// Trait for storing a single product
32 | #[async_trait]
33 | pub trait StorePut: Send + Sync {
34 | async fn put(&self, product: &Product) -> Result<(), Error>;
35 | }
36 |
37 | /// Trait for deleting a single product
38 | #[async_trait]
39 | pub trait StoreDelete: Send + Sync {
40 | async fn delete(&self, id: &str) -> Result<(), Error>;
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils.rs:
--------------------------------------------------------------------------------
1 | use crate::{event_bus, store};
2 | use tracing::{info, instrument};
3 |
4 | /// Setup tracing
5 | pub fn setup_tracing() {
6 | let subscriber = tracing_subscriber::fmt()
7 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
8 | .json()
9 | .finish();
10 | tracing::subscriber::set_global_default(subscriber).expect("failed to set tracing subscriber");
11 | }
12 |
13 | /// Initialize a store
14 | #[instrument]
15 | pub async fn get_store() -> impl store::Store {
16 | // Get AWS Configuration
17 | let config = aws_config::load_from_env().await;
18 |
19 | // Initialize a DynamoDB store
20 | let table_name = std::env::var("TABLE_NAME").expect("TABLE_NAME must be set");
21 | info!(
22 | "Initializing DynamoDB store with table name: {}",
23 | table_name
24 | );
25 | let client = aws_sdk_dynamodb::Client::new(&config);
26 | store::DynamoDBStore::new(client, table_name)
27 | }
28 |
29 | /// Create an event service
30 | #[instrument]
31 | pub async fn get_event_bus() -> impl event_bus::EventBus {
32 | // Get AWS Configuration
33 | let config = aws_config::load_from_env().await;
34 |
35 | // Initialize an EventBridge if the environment variable is set
36 | let event_bus_name = std::env::var("EVENT_BUS_NAME").expect("EVENT_BUS_NAME must be set");
37 | info!("Initializing EventBridge bus with name: {}", event_bus_name);
38 | let client = aws_sdk_eventbridge::Client::new(&config);
39 | event_bus::EventBridgeBus::new(client, event_bus_name)
40 | }
41 |
--------------------------------------------------------------------------------
/template.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: '2010-09-09'
2 | Transform: AWS::Serverless-2016-10-31
3 |
4 | Globals:
5 | Function:
6 | MemorySize: 128
7 | Architectures: ["arm64"]
8 | Handler: bootstrap
9 | Runtime: provided.al2023
10 | Timeout: 5
11 | Tracing: Active
12 | Environment:
13 | Variables:
14 | RUST_LOG: info
15 | TABLE_NAME: !Ref Table
16 |
17 | Resources:
18 | GetProductsFunction:
19 | Type: AWS::Serverless::Function
20 | Properties:
21 | CodeUri: target/lambda/get-products/
22 | Events:
23 | Api:
24 | Type: HttpApi
25 | Properties:
26 | Path: /
27 | Method: GET
28 | Policies:
29 | - Version: "2012-10-17"
30 | Statement:
31 | - Effect: Allow
32 | Action: dynamodb:Scan
33 | Resource: !GetAtt Table.Arn
34 | Metadata:
35 | BuildMethod: makefile
36 |
37 | GetProductFunction:
38 | Type: AWS::Serverless::Function
39 | Properties:
40 | CodeUri: target/lambda/get-product/
41 | Events:
42 | Api:
43 | Type: HttpApi
44 | Properties:
45 | Path: /{id}
46 | Method: GET
47 | Policies:
48 | - Version: "2012-10-17"
49 | Statement:
50 | - Effect: Allow
51 | Action: dynamodb:GetItem
52 | Resource: !GetAtt Table.Arn
53 | Metadata:
54 | BuildMethod: makefile
55 |
56 | PutProductFunction:
57 | Type: AWS::Serverless::Function
58 | Properties:
59 | CodeUri: target/lambda/put-product/
60 | Events:
61 | Api:
62 | Type: HttpApi
63 | Properties:
64 | Path: /{id}
65 | Method: PUT
66 | Policies:
67 | - Version: "2012-10-17"
68 | Statement:
69 | - Effect: Allow
70 | Action: dynamodb:PutItem
71 | Resource: !GetAtt Table.Arn
72 | Metadata:
73 | BuildMethod: makefile
74 |
75 | DeleteProductFunction:
76 | Type: AWS::Serverless::Function
77 | Properties:
78 | CodeUri: target/lambda/delete-product/
79 | Events:
80 | Api:
81 | Type: HttpApi
82 | Properties:
83 | Path: /{id}
84 | Method: DELETE
85 | Policies:
86 | - Version: "2012-10-17"
87 | Statement:
88 | - Effect: Allow
89 | Action: dynamodb:DeleteItem
90 | Resource: !GetAtt Table.Arn
91 | Metadata:
92 | BuildMethod: makefile
93 |
94 | DDBStreamsFunction:
95 | Type: AWS::Serverless::Function
96 | Properties:
97 | CodeUri: target/lambda/dynamodb-streams/
98 | Timeout: 10
99 | Events:
100 | TableStream:
101 | Type: DynamoDB
102 | Properties:
103 | BatchSize: 1000
104 | MaximumBatchingWindowInSeconds: 10
105 | StartingPosition: TRIM_HORIZON
106 | Stream: !GetAtt Table.StreamArn
107 | Environment:
108 | Variables:
109 | EVENT_BUS_NAME: !Ref EventBus
110 | Policies:
111 | - Version: "2012-10-17"
112 | Statement:
113 | - Effect: Allow
114 | Action: events:PutEvents
115 | Resource: !GetAtt EventBus.Arn
116 |
117 | Table:
118 | Type: AWS::DynamoDB::Table
119 | Properties:
120 | AttributeDefinitions:
121 | - AttributeName: id
122 | AttributeType: S
123 | BillingMode: PAY_PER_REQUEST
124 | KeySchema:
125 | - AttributeName: id
126 | KeyType: HASH
127 | StreamSpecification:
128 | StreamViewType: NEW_AND_OLD_IMAGES
129 |
130 | EventBus:
131 | Type: AWS::Events::EventBus
132 | Properties:
133 | Name: !Ref AWS::StackName
134 |
135 | Outputs:
136 | ApiUrl:
137 | Description: "API Gateway endpoint URL"
138 | Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
139 |
--------------------------------------------------------------------------------
/tests/aws_test.rs:
--------------------------------------------------------------------------------
1 | //! Testing resources on AWS
2 | //! ========================
3 | //!
4 | //! This file contains the tests for the AWS resources.
5 | //!
6 | //! This assumes that there is an environment variable called `REST_API`
7 | //! which points to the endpoint of the Amazon API Gateway API.
8 |
9 | use float_cmp::approx_eq;
10 | use products::{Product, ProductRange};
11 | use rand::distributions::Alphanumeric;
12 | use rand::prelude::*;
13 | use reqwest::StatusCode;
14 | use std::env;
15 |
16 | type E = Box;
17 |
18 | fn get_random_string(length: usize) -> String {
19 | let mut rng = rand::thread_rng();
20 | return (0..length)
21 | .map(|_| rng.sample(Alphanumeric) as char)
22 | .collect();
23 | }
24 |
25 | fn get_random_product() -> Product {
26 | let mut rng = rand::thread_rng();
27 | Product {
28 | id: get_random_string(16),
29 | name: get_random_string(16),
30 | // Price with 2 decimal digits
31 | price: (rng.gen::() * 25600.0).round() / 100.0,
32 | }
33 | }
34 |
35 | #[tokio::test]
36 | async fn test_flow() -> Result<(), E> {
37 | let client = reqwest::Client::new();
38 | let api_url: String = env::var("API_URL").expect("API_URL not set");
39 |
40 | let product = get_random_product();
41 |
42 | // Put new product
43 | println!("PUT new product");
44 | let res = client
45 | .put(format!("{}/{}", api_url, product.id))
46 | .json(&product)
47 | .send()
48 | .await?;
49 | assert_eq!(res.status(), StatusCode::CREATED);
50 |
51 | // Get product
52 | println!("GET product");
53 | let res = client
54 | .get(format!("{}/{}", api_url, product.id))
55 | .send()
56 | .await?;
57 | assert_eq!(res.status(), StatusCode::OK);
58 | let res_product: Product = res.json().await?;
59 | assert_eq!(res_product.id, product.id);
60 | assert_eq!(res_product.name, product.name);
61 | assert!(approx_eq!(f64, res_product.price, product.price));
62 |
63 | // Get all products
64 | println!("GET all products");
65 | let res = client.get(&api_url).send().await?;
66 | assert_eq!(res.status(), StatusCode::OK);
67 | let res_products: ProductRange = res.json().await?;
68 | // At least one product should be returned
69 | assert!(res_products.products.len() >= 1);
70 |
71 | // Delete product
72 | println!("DELETE product");
73 | let res = client
74 | .delete(format!("{}/{}", api_url, product.id))
75 | .send()
76 | .await?;
77 | assert_eq!(res.status(), StatusCode::OK);
78 |
79 | // Get product again
80 | println!("GET product again");
81 | let res = client
82 | .get(format!("{}/{}", api_url, product.id))
83 | .send()
84 | .await?;
85 | assert_eq!(res.status(), StatusCode::NOT_FOUND);
86 |
87 | Ok(())
88 | }
89 |
90 | #[tokio::test]
91 | async fn test_put_product_with_invalid_id() -> Result<(), E> {
92 | let client = reqwest::Client::new();
93 | let api_url: String = env::var("API_URL").expect("API_URL not set");
94 |
95 | let product = Product {
96 | id: "invalid id".to_string(),
97 | name: get_random_string(16),
98 | price: 0.0,
99 | };
100 |
101 | // Put new product
102 | println!("PUT new product");
103 | let res = client
104 | .put(format!("{}/not-the-same-id", api_url))
105 | .json(&product)
106 | .send()
107 | .await?;
108 | assert_eq!(res.status(), StatusCode::BAD_REQUEST);
109 | assert!(res
110 | .text()
111 | .await?
112 | .contains("Product ID in path does not match product ID in body"));
113 |
114 | Ok(())
115 | }
116 |
117 | #[tokio::test]
118 | async fn test_put_product_empty() -> Result<(), E> {
119 | let client = reqwest::Client::new();
120 | let api_url: String = env::var("API_URL").expect("API_URL not set");
121 |
122 | // Put new product
123 | println!("PUT new product");
124 | let res = client.put(format!("{}/empty-id", api_url)).send().await?;
125 | assert_eq!(res.status(), StatusCode::BAD_REQUEST);
126 | assert!(res
127 | .text()
128 | .await?
129 | .contains("Missing product in request body"));
130 |
131 | Ok(())
132 | }
133 |
134 | #[tokio::test]
135 | async fn test_put_product_invalid_body() -> Result<(), E> {
136 | let client = reqwest::Client::new();
137 | let api_url: String = env::var("API_URL").expect("API_URL not set");
138 |
139 | // Put new product
140 | println!("PUT new product");
141 | let res = client
142 | .put(format!("{}/invalid-body", api_url))
143 | .json(&"invalid body")
144 | .send()
145 | .await?;
146 | assert_eq!(res.status(), StatusCode::BAD_REQUEST);
147 | assert!(res
148 | .text()
149 | .await?
150 | .contains("Failed to parse product from request body"));
151 |
152 | Ok(())
153 | }
154 |
--------------------------------------------------------------------------------
/tests/generator.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 |
3 | const COLORS = [
4 | "Red", "Green", "Blue", "Yellow", "Orange", "Purple", "Pink", "Brown",
5 | "Black", "White", "Gray", "Silver", "Gold", "Cyan", "Magenta", "Maroon",
6 | "Navy", "Olive", "Teal", "Aqua", "Lime", "Coral", "Aquamarine",
7 | "Turquoise", "Violet", "Indigo", "Plum", "Crimson", "Salmon", "Coral",
8 | "Khaki", "Beige",
9 | ];
10 |
11 | const PRODUCTS = [
12 | "Shoes", "Sweatshirts", "Hats", "Pants", "Shirts", "T-Shirts", "Trousers",
13 | "Jackets", "Shorts", "Skirts", "Dresses", "Coats", "Jeans", "Blazers",
14 | "Socks", "Gloves", "Belts", "Bags", "Shoes", "Sunglasses", "Watches",
15 | "Jewelry", "Ties", "Hair Accessories", "Makeup", "Accessories",
16 | ];
17 |
18 | module.exports = {
19 | generateProduct: function(context, events, done) {
20 | const color = COLORS[Math.floor(Math.random() * COLORS.length)];
21 | const name = PRODUCTS[Math.floor(Math.random() * PRODUCTS.length)];
22 |
23 | context.vars.id = crypto.randomUUID();
24 | context.vars.name = `${color} ${name}`;
25 | context.vars.price = Math.round(Math.random() * 10000) / 100;
26 |
27 | return done();
28 | },
29 | };
--------------------------------------------------------------------------------
/tests/load-test.yml:
--------------------------------------------------------------------------------
1 | config:
2 | target: "{{ $processEnvironment.API_URL }}"
3 | processor: "generator.js"
4 | phases:
5 | - duration: 600
6 | arrivalRate: 300
7 |
8 | scenarios:
9 | - name: "Generate products"
10 | weight: 8
11 | flow:
12 | - function: "generateProduct"
13 | - put:
14 | url: "/{{ id }}"
15 | headers:
16 | Content-Type: "application/json"
17 | json:
18 | id: "{{ id }}"
19 | name: "{{ name }}"
20 | price: "{{ price }}"
21 | - get:
22 | url: "/{{ id }}"
23 | - think: 3
24 | - delete:
25 | url: "/{{ id }}"
26 | - name: "Get products"
27 | weight: 2
28 | flow:
29 | - get:
30 | url: "/"
--------------------------------------------------------------------------------