├── .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 | ![build](https://github.com/aws-samples/serverless-rust-demo/actions/workflows/ci.yml/badge.svg) 4 | 5 |

6 | Architecture diagram 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 | ![Load Test Results](imgs/load-test.png) 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: "/" --------------------------------------------------------------------------------