From 42cc40776e835eb235222e013489a5bb3e581299 Mon Sep 17 00:00:00 2001 From: Evan Rittenhouse Date: Wed, 3 Apr 2024 17:26:24 -0400 Subject: [PATCH] Contribute Rust template to engineering domains --- ...Language-Specific-Microservice-Starters.md | 1 + .../.cargo/config.toml | 2 + .../.circleci/config.yml | 72 + .../examples/rust-microservice-template/.env | 2 + .../rust-microservice-template/.gitignore | 2 + ...5c3c15e52c8f067e6fff52ea0bb04e3bf5c1e.json | 34 + ...91271493aad0f4b66c55c89398cb13aab5c20.json | 23 + ...0285924e1856cf550d12a84738eac6dad1b3b.json | 23 + ...c939e615e3cdc3dad5539ac5a8c3e66f2bf3f.json | 14 + ...681a27d0f47c2f6a18c39383634192b092bbd.json | 34 + ...5d5409ab133ef614ce0c660ea8578052c5a38.json | 35 + ...9acbb7397821d281268054cd6a9c39f47e2c4.json | 22 + ...c341d5a765d6a4091321b42da5a203612883c.json | 23 + ...cfd92332c67ca3a70af742f6e9d68361b9629.json | 15 + ...7cebd6161a26482d131c970776a638a5475ae.json | 32 + .../rust-microservice-template/Cargo.lock | 2630 +++++++++++++++++ .../rust-microservice-template/Cargo.toml | 39 + .../rust-microservice-template/README.md | 92 + .../rust-microservice-template/doc/README.md | 18 + .../doc/api_documentation.md | 237 ++ .../doc/architecture_layers.md | 609 ++++ .../doc/configuration.md | 41 + .../doc/database.md | 65 + .../doc/img/hexagonal_arch_diagram.png | Bin 0 -> 59220 bytes .../rust-microservice-template/doc/logging.md | 15 + .../rust-microservice-template/doc/testing.md | 500 ++++ .../docker-compose.yml | 11 + .../postgres-scripts/postgres-setup.sql | 13 + .../rust-microservice-template/src/api/mod.rs | 6 + .../src/api/swagger_main.rs | 22 + .../src/api/test_util.rs | 23 + .../src/api/todo.rs | 260 ++ .../src/api/user.rs | 757 +++++ .../rust-microservice-template/src/app_env.rs | 10 + .../rust-microservice-template/src/db.rs | 15 + .../src/domain/mod.rs | 7 + .../src/domain/test_util.rs | 151 + .../src/domain/todo.rs | 880 ++++++ .../src/domain/user.rs | 589 ++++ .../rust-microservice-template/src/dto.rs | 238 ++ .../src/external_connections.rs | 257 ++ .../src/integration_test/mod.rs | 2 + .../src/integration_test/test_util.rs | 135 + .../src/integration_test/user_api.rs | 73 + .../rust-microservice-template/src/main.rs | 68 + .../src/persistence/db_todo_driven_ports.rs | 132 + .../src/persistence/db_user_driven_ports.rs | 134 + .../src/persistence/mod.rs | 135 + .../src/routing_utils.rs | 90 + 49 files changed, 8588 insertions(+) create mode 100644 practices/development/examples/rust-microservice-template/.cargo/config.toml create mode 100644 practices/development/examples/rust-microservice-template/.circleci/config.yml create mode 100644 practices/development/examples/rust-microservice-template/.env create mode 100644 practices/development/examples/rust-microservice-template/.gitignore create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-1b925eb9a8029b0f6ace794670c5c3c15e52c8f067e6fff52ea0bb04e3bf5c1e.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-1ddd6e803d254afe113618d1e6a91271493aad0f4b66c55c89398cb13aab5c20.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-39a7bae5028672bb027cdad6c9c0285924e1856cf550d12a84738eac6dad1b3b.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-3a1243d6828b52b5a122a042d57c939e615e3cdc3dad5539ac5a8c3e66f2bf3f.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-458042e043927d1ac9ffa77182d681a27d0f47c2f6a18c39383634192b092bbd.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-6469d799658f9cc1a8aa80152ce5d5409ab133ef614ce0c660ea8578052c5a38.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-714c1429fd0b7c3fb8d6a6a9eec9acbb7397821d281268054cd6a9c39f47e2c4.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-7ef65c00695274e87ddb7fda4dac341d5a765d6a4091321b42da5a203612883c.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-a590f10f9618632d5fd170a03decfd92332c67ca3a70af742f6e9d68361b9629.json create mode 100644 practices/development/examples/rust-microservice-template/.sqlx/query-fa40e4e1f2f816a110e3d1e0aa87cebd6161a26482d131c970776a638a5475ae.json create mode 100644 practices/development/examples/rust-microservice-template/Cargo.lock create mode 100644 practices/development/examples/rust-microservice-template/Cargo.toml create mode 100644 practices/development/examples/rust-microservice-template/README.md create mode 100644 practices/development/examples/rust-microservice-template/doc/README.md create mode 100644 practices/development/examples/rust-microservice-template/doc/api_documentation.md create mode 100644 practices/development/examples/rust-microservice-template/doc/architecture_layers.md create mode 100644 practices/development/examples/rust-microservice-template/doc/configuration.md create mode 100644 practices/development/examples/rust-microservice-template/doc/database.md create mode 100644 practices/development/examples/rust-microservice-template/doc/img/hexagonal_arch_diagram.png create mode 100644 practices/development/examples/rust-microservice-template/doc/logging.md create mode 100644 practices/development/examples/rust-microservice-template/doc/testing.md create mode 100644 practices/development/examples/rust-microservice-template/docker-compose.yml create mode 100644 practices/development/examples/rust-microservice-template/postgres-scripts/postgres-setup.sql create mode 100644 practices/development/examples/rust-microservice-template/src/api/mod.rs create mode 100644 practices/development/examples/rust-microservice-template/src/api/swagger_main.rs create mode 100644 practices/development/examples/rust-microservice-template/src/api/test_util.rs create mode 100644 practices/development/examples/rust-microservice-template/src/api/todo.rs create mode 100644 practices/development/examples/rust-microservice-template/src/api/user.rs create mode 100644 practices/development/examples/rust-microservice-template/src/app_env.rs create mode 100644 practices/development/examples/rust-microservice-template/src/db.rs create mode 100644 practices/development/examples/rust-microservice-template/src/domain/mod.rs create mode 100644 practices/development/examples/rust-microservice-template/src/domain/test_util.rs create mode 100644 practices/development/examples/rust-microservice-template/src/domain/todo.rs create mode 100644 practices/development/examples/rust-microservice-template/src/domain/user.rs create mode 100644 practices/development/examples/rust-microservice-template/src/dto.rs create mode 100644 practices/development/examples/rust-microservice-template/src/external_connections.rs create mode 100644 practices/development/examples/rust-microservice-template/src/integration_test/mod.rs create mode 100644 practices/development/examples/rust-microservice-template/src/integration_test/test_util.rs create mode 100644 practices/development/examples/rust-microservice-template/src/integration_test/user_api.rs create mode 100644 practices/development/examples/rust-microservice-template/src/main.rs create mode 100644 practices/development/examples/rust-microservice-template/src/persistence/db_todo_driven_ports.rs create mode 100644 practices/development/examples/rust-microservice-template/src/persistence/db_user_driven_ports.rs create mode 100644 practices/development/examples/rust-microservice-template/src/persistence/mod.rs create mode 100644 practices/development/examples/rust-microservice-template/src/routing_utils.rs diff --git a/docs/development/templates/Language-Specific-Microservice-Starters.md b/docs/development/templates/Language-Specific-Microservice-Starters.md index cf7bf72..cc85d17 100644 --- a/docs/development/templates/Language-Specific-Microservice-Starters.md +++ b/docs/development/templates/Language-Specific-Microservice-Starters.md @@ -5,3 +5,4 @@ The development practice has a suite of language-specific templates for developi Here are the language templates that are currently available: * [Go](https://github.com/FearlessSolutions/engineering-practice-domains/tree/main/practices/development/examples/go-microservice-monorepo) +* [Rust](https://github.com/FearlessSolutions/engineering-practice-domains/tree/main/practices/development/examples/rust-microservice-template) diff --git a/practices/development/examples/rust-microservice-template/.cargo/config.toml b/practices/development/examples/rust-microservice-template/.cargo/config.toml new file mode 100644 index 0000000..b23105b --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.crates-io] +protocol = "sparse" \ No newline at end of file diff --git a/practices/development/examples/rust-microservice-template/.circleci/config.yml b/practices/development/examples/rust-microservice-template/.circleci/config.yml new file mode 100644 index 0000000..cb8bbf2 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.circleci/config.yml @@ -0,0 +1,72 @@ +version: 2.1 + +orbs: + rust: circleci/rust@1.6 + +commands: + rust_build: + parameters: + cache_bust_version: + type: string + default: v1 + steps: + - restore_cache: + keys: + - debug-compile + - run: + name: Build code + command: cargo build + - save_cache: + key: debug-compile-{{ checksum "Cargo.lock" }}-<< parameters.cache_bust_version >> + paths: + - target/debug + - ~/.cargo + +jobs: + build-and-test: + docker: + - image: ghcr.io/emanguy/rust-ci:1.75-v1.1.0 + - image: postgres:14-alpine + name: test-db + environment: + POSTGRES_PASSWORD: sample123 + environment: + # This is needed for sqlx to verify queries + DATABASE_URL: "postgresql://postgres:sample123@test-db:5432" + # This is needed for the integration tests to actually connect to the database + TEST_DB_URL: "postgresql://postgres:sample123@test-db:5432" + DB_TABLE_URL: "postgresql://postgres:sample123@test-db:5432/postgres" + steps: + - checkout + - run: + name: Provision database + command: | + until psql -f postgres-scripts/postgres-setup.sql $DB_TABLE_URL + do + echo "Trying again in a few seconds..." + sleep 5 + done + echo "Migrations complete!" + - rust_build + - run: + name: "Run tests" + command: cargo test --features integration_test + + validate-quality: + docker: + - image: ghcr.io/emanguy/rust-ci:1.75-v1.1.0 + environment: + SQLX_OFFLINE: "true" + steps: + - checkout + - rust/clippy: + flags: --tests --features integration_test -- -D warnings + - run: + name: "Validate formatting" + command: cargo fmt --check + +workflows: + standard: + jobs: + - build-and-test + - validate-quality diff --git a/practices/development/examples/rust-microservice-template/.env b/practices/development/examples/rust-microservice-template/.env new file mode 100644 index 0000000..8984e43 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.env @@ -0,0 +1,2 @@ +DATABASE_URL=postgres://postgres:sample123@127.0.0.1/postgres +TEST_DB_URL=postgres://postgres:sample123@127.0.0.1 diff --git a/practices/development/examples/rust-microservice-template/.gitignore b/practices/development/examples/rust-microservice-template/.gitignore new file mode 100644 index 0000000..3a8cabc --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.gitignore @@ -0,0 +1,2 @@ +/target +.idea diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-1b925eb9a8029b0f6ace794670c5c3c15e52c8f067e6fff52ea0bb04e3bf5c1e.json b/practices/development/examples/rust-microservice-template/.sqlx/query-1b925eb9a8029b0f6ace794670c5c3c15e52c8f067e6fff52ea0bb04e3bf5c1e.json new file mode 100644 index 0000000..53d85ac --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-1b925eb9a8029b0f6ace794670c5c3c15e52c8f067e6fff52ea0bb04e3bf5c1e.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ti.* FROM todo_item ti WHERE ti.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "item_desc", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "1b925eb9a8029b0f6ace794670c5c3c15e52c8f067e6fff52ea0bb04e3bf5c1e" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-1ddd6e803d254afe113618d1e6a91271493aad0f4b66c55c89398cb13aab5c20.json b/practices/development/examples/rust-microservice-template/.sqlx/query-1ddd6e803d254afe113618d1e6a91271493aad0f4b66c55c89398cb13aab5c20.json new file mode 100644 index 0000000..53ab2ae --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-1ddd6e803d254afe113618d1e6a91271493aad0f4b66c55c89398cb13aab5c20.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) from todo_user tu WHERE tu.first_name = $1 AND tu.last_name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1ddd6e803d254afe113618d1e6a91271493aad0f4b66c55c89398cb13aab5c20" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-39a7bae5028672bb027cdad6c9c0285924e1856cf550d12a84738eac6dad1b3b.json b/practices/development/examples/rust-microservice-template/.sqlx/query-39a7bae5028672bb027cdad6c9c0285924e1856cf550d12a84738eac6dad1b3b.json new file mode 100644 index 0000000..0d1d09f --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-39a7bae5028672bb027cdad6c9c0285924e1856cf550d12a84738eac6dad1b3b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO todo_item(user_id, item_desc) VALUES ($1, $2) RETURNING todo_item.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "39a7bae5028672bb027cdad6c9c0285924e1856cf550d12a84738eac6dad1b3b" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-3a1243d6828b52b5a122a042d57c939e615e3cdc3dad5539ac5a8c3e66f2bf3f.json b/practices/development/examples/rust-microservice-template/.sqlx/query-3a1243d6828b52b5a122a042d57c939e615e3cdc3dad5539ac5a8c3e66f2bf3f.json new file mode 100644 index 0000000..14fe673 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-3a1243d6828b52b5a122a042d57c939e615e3cdc3dad5539ac5a8c3e66f2bf3f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM todo_item WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "3a1243d6828b52b5a122a042d57c939e615e3cdc3dad5539ac5a8c3e66f2bf3f" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-458042e043927d1ac9ffa77182d681a27d0f47c2f6a18c39383634192b092bbd.json b/practices/development/examples/rust-microservice-template/.sqlx/query-458042e043927d1ac9ffa77182d681a27d0f47c2f6a18c39383634192b092bbd.json new file mode 100644 index 0000000..23366c6 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-458042e043927d1ac9ffa77182d681a27d0f47c2f6a18c39383634192b092bbd.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM todo_user tu WHERE tu.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "first_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "last_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "458042e043927d1ac9ffa77182d681a27d0f47c2f6a18c39383634192b092bbd" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-6469d799658f9cc1a8aa80152ce5d5409ab133ef614ce0c660ea8578052c5a38.json b/practices/development/examples/rust-microservice-template/.sqlx/query-6469d799658f9cc1a8aa80152ce5d5409ab133ef614ce0c660ea8578052c5a38.json new file mode 100644 index 0000000..34bc98f --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-6469d799658f9cc1a8aa80152ce5d5409ab133ef614ce0c660ea8578052c5a38.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ti.* FROM todo_item ti WHERE ti.user_id = $1 AND ti.id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "item_desc", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "6469d799658f9cc1a8aa80152ce5d5409ab133ef614ce0c660ea8578052c5a38" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-714c1429fd0b7c3fb8d6a6a9eec9acbb7397821d281268054cd6a9c39f47e2c4.json b/practices/development/examples/rust-microservice-template/.sqlx/query-714c1429fd0b7c3fb8d6a6a9eec9acbb7397821d281268054cd6a9c39f47e2c4.json new file mode 100644 index 0000000..cce7ee2 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-714c1429fd0b7c3fb8d6a6a9eec9acbb7397821d281268054cd6a9c39f47e2c4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM todo_user tu WHERE tu.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "714c1429fd0b7c3fb8d6a6a9eec9acbb7397821d281268054cd6a9c39f47e2c4" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-7ef65c00695274e87ddb7fda4dac341d5a765d6a4091321b42da5a203612883c.json b/practices/development/examples/rust-microservice-template/.sqlx/query-7ef65c00695274e87ddb7fda4dac341d5a765d6a4091321b42da5a203612883c.json new file mode 100644 index 0000000..82975fa --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-7ef65c00695274e87ddb7fda4dac341d5a765d6a4091321b42da5a203612883c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO todo_user(first_name, last_name) VALUES ($1, $2) RETURNING todo_user.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7ef65c00695274e87ddb7fda4dac341d5a765d6a4091321b42da5a203612883c" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-a590f10f9618632d5fd170a03decfd92332c67ca3a70af742f6e9d68361b9629.json b/practices/development/examples/rust-microservice-template/.sqlx/query-a590f10f9618632d5fd170a03decfd92332c67ca3a70af742f6e9d68361b9629.json new file mode 100644 index 0000000..34cc218 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-a590f10f9618632d5fd170a03decfd92332c67ca3a70af742f6e9d68361b9629.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE todo_item SET item_desc = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "a590f10f9618632d5fd170a03decfd92332c67ca3a70af742f6e9d68361b9629" +} diff --git a/practices/development/examples/rust-microservice-template/.sqlx/query-fa40e4e1f2f816a110e3d1e0aa87cebd6161a26482d131c970776a638a5475ae.json b/practices/development/examples/rust-microservice-template/.sqlx/query-fa40e4e1f2f816a110e3d1e0aa87cebd6161a26482d131c970776a638a5475ae.json new file mode 100644 index 0000000..0f6cf75 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/.sqlx/query-fa40e4e1f2f816a110e3d1e0aa87cebd6161a26482d131c970776a638a5475ae.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM todo_user", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "first_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "last_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "fa40e4e1f2f816a110e3d1e0aa87cebd6161a26482d131c970776a638a5475ae" +} diff --git a/practices/development/examples/rust-microservice-template/Cargo.lock b/practices/development/examples/rust-microservice-template/Cargo.lock new file mode 100644 index 0000000..5578b09 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/Cargo.lock @@ -0,0 +1,2630 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "anyhow" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" + +[[package]] +name = "async-trait" +version = "0.1.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.0", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cpufeatures" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd435b205a4842da59efd07628f921c096bc1cc0a156835b4fa0bcb9a19bcce" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +dependencies = [ + "serde", +] + +[[package]] +name = "env_logger" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "js-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "libc" +version = "0.2.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "lock_api" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "matchit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mockall" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nom" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" +dependencies = [ + "memchr", + "minimal-lexical", + "version_check", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c6602fda94a57c990fe0df199a035d83576b496aa29f4e634a8ac6004e68a6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.36.1", +] + +[[package]] +name = "paste" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0744126afe1a6dd7f394cb50a716dbe086cb06e255e53d8d0185d82828358fb5" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "float-cmp", + "itertools 0.10.3", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb78f46d0066053d16d4ca7b898e9343bc3530f71c61d5ad84cd404ada068745" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91ac2a3c6c0520a3fb3dd89321177c3c692937c4eb21893378219da10c44fc8" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.48", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f69089032567ffff4eada41c573fc43ff466c7db7c5688b2e7969584345581" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +dependencies = [ + "bitflags 2.4.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sample-rest" +version = "1.0.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum-macros", + "derive_more", + "dotenv", + "env_logger", + "futures", + "futures-core", + "hyper", + "lazy_static", + "log", + "mockall", + "rand", + "serde", + "serde_json", + "speculoos", + "sqlx", + "thiserror", + "tokio", + "tower", + "utoipa", + "utoipa-swagger-ui", + "validator", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568a8e6258aa33c13358f81fd834adb854c6f7c9468520910a9b1e8fac068012" + +[[package]] +name = "serde" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "serde_json" +version = "1.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2593d31f82ead8df961d8bd23a64c2ccf2eb5dd34b0a34bfb4dd54011c72009e" + +[[package]] +name = "socket2" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "speculoos" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65881c9270d6157f30a09233305da51bed97eef9192d0ea21e57b1c8f05c3620" +dependencies = [ + "num", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" +dependencies = [ + "itertools 0.12.1", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64", + "bitflags 2.4.2", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64", + "bitflags 2.4.2", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384595c11a4e2969895cad5a8c4029115f5ab956a9e5ef4de79d11a426e5f20c" + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + +[[package]] +name = "thiserror" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "tinyvec" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "tokio-stream" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" + +[[package]] +name = "unicode-ident" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utoipa" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "272ebdfbc99111033031d2f10e018836056e4d2c8e2acda76450ec7974269fa7" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3c9f4d08338c1bfa70dde39412a040a884c6f318b3d09aaaf3437a1e52027fc" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "zip", +] + +[[package]] +name = "validator" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f07b0a1390e01c0fc35ebb26b28ced33c9a3808f7f9fbe94d3cc01e233bfeed5" +dependencies = [ + "idna", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea7ed5e8cf2b6bdd64a6c4ce851da25388a89327b17b88424ceced6bd5017923" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ddf34293296847abfc1493b15c6e2f5d3cd19f57ad7d22673bf4c6278da329" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn 1.0.109", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2" + +[[package]] +name = "web-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "whoami" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524b58fa5a20a2fb3014dd6358b70e6579692a56ef6fce928834e488f42f65e8" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] diff --git a/practices/development/examples/rust-microservice-template/Cargo.toml b/practices/development/examples/rust-microservice-template/Cargo.toml new file mode 100644 index 0000000..5c7a3ea --- /dev/null +++ b/practices/development/examples/rust-microservice-template/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "sample-rest" +version = "1.0.0" +authors = ["Evan Rittenhouse"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +env_logger = "0.9.0" +log = "0.4.14" +dotenv = "0.15.0" +sqlx = { version = "0.7.3", features = [ "runtime-tokio-rustls", "postgres" ] } +serde = "1.0" +serde_json = "1.0" +thiserror = "1.0.31" +derive_more = "0.99.17" +validator = { version = "0.15.0", features = ["derive"] } +axum = "0.7.4" +axum-macros = "0.4.1" +tokio = { version = "1.19.2", features = ["full"] } +async-trait = "0.1.68" +anyhow = "1.0.70" +futures = "0.3.30" +utoipa = { version = "4.2.0" } +utoipa-swagger-ui = { version = "6.0.0", features = ["axum"] } + +[dev-dependencies] +futures-core = "0.3.29" +hyper = "1.2.0" +lazy_static = "1.4.0" +mockall = "0.11.4" +rand = "0.8.5" +speculoos = "0.11.0" +tokio = { version = "1.19.2", features = ["sync"] } +tower = "0.4.13" + +[features] +integration_test = [] diff --git a/practices/development/examples/rust-microservice-template/README.md b/practices/development/examples/rust-microservice-template/README.md new file mode 100644 index 0000000..ee1599b --- /dev/null +++ b/practices/development/examples/rust-microservice-template/README.md @@ -0,0 +1,92 @@ +# Rust REST Server Template + +This repository contains a starting point for a testable Rust microservice using Hexagonal Architecture. Here's how to get started: + +1. Run `docker compose up` to start the PostgeSQL server that the microservice depends on. +2. Run `cargo run` to start the microservice. + +Additional documentation and "getting started" material can be found in the [template documentation](./doc/README.md). + +This template includes: +* Unit testing for both HTTP routers and business logic +* Integration tests against a real Postgres database +* CI via CircleCI which runs unit and integration tests, lints the code, and verifies formatting +* OpenAPI documentation via Swagger UI +* A configurable logger +* Validation for incoming HTTP request DTOs +* Tons of documentation on the coding patterns used in the template + +Try it out for yourself, contributions are welcome! + +## Benchmark + +Did a quick load test on the server using `oha` running for 5 minutes: + +``` +❯ oha -z 5m http://localhost:8080/users/1/tasks +Summary: + Success rate: 1.0000 + Total: 300.0010 secs + Slowest: 0.6709 secs + Fastest: 0.0054 secs + Average: 0.0324 secs + Requests/sec: 1543.8113 + + Total data: 63.60 MiB + Size/request: 144 B + Size/sec: 217.10 KiB + +Response time histogram: + 0.014 [1972] | + 0.022 [16050] |■■ + 0.030 [169591] |■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.039 [212188] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.047 [46930] |■■■■■■■ + 0.055 [11667] |■ + 0.064 [3211] | + 0.072 [952] | + 0.080 [289] | + 0.089 [49] | + 0.097 [246] | + +Latency distribution: + 10% in 0.0245 secs + 25% in 0.0279 secs + 50% in 0.0317 secs + 75% in 0.0353 secs + 90% in 0.0409 secs + 95% in 0.0451 secs + 99% in 0.0556 secs + +Details (average, fastest, slowest): + DNS+dialup: 0.0012 secs, 0.0007 secs, 0.0014 secs + DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0001 secs + +Status code distribution: + [200] 463145 responses +``` + +Most frequently, the server responds in about 39ms and it was able to process 463k requests in 5 minutes. Needless to say Rust web servers are FAST. + +## Swagger Docs + +The Swagger UI (provided by the [utoipa](https://github.com/juhaku/utoipa) crate) can be accessed at http://localhost:8080/swagger-ui when starting the application. + +## Tests + +Unit tests for both API routers and business logic can be run via `cargo test`. + +## Integration tests + +Provided on this repo is a framework for integration testing. By default, the integration tests are skipped via the `#[cfg_attr()]` declaration which requires the `integration_test` feature to be enabled. + +To run the tests with the integration tests, run the following: + +```bash +# Create a postgres database to test against +docker-compose up -d +# Run all tests, including integration tests +cargo test --features integration_test +``` + +More information on integration testing can be found in the [testing documentation](./doc/testing.md#writing-integration-tests). diff --git a/practices/development/examples/rust-microservice-template/doc/README.md b/practices/development/examples/rust-microservice-template/doc/README.md new file mode 100644 index 0000000..5095d11 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/doc/README.md @@ -0,0 +1,18 @@ +# Rust REST template Documentation + +This set of documents aims to give you an overview of how this REST microservice template is structured and how to customize +and use it for your own needs. + +This microservice template is built using [Hexagonal Architecture](https://medium.com/ssense-tech/hexagonal-architecture-there-are-always-two-sides-to-every-story-bc0780ed7d9c) principles. **Please read that document before reading +anything else**!! + +This template has a MSRV of Rust 1.75, as it requires official async-function-in-trait support. + +## Areas of interest + +1. [Architecture layers](./architecture_layers.md) +2. [Logging](./logging.md) +3. [Testing](./testing.md) +4. [Database connectivity](./database.md) +5. [Documenting the API](./api_documentation.md) +6. [Configuration](./configuration.md) \ No newline at end of file diff --git a/practices/development/examples/rust-microservice-template/doc/api_documentation.md b/practices/development/examples/rust-microservice-template/doc/api_documentation.md new file mode 100644 index 0000000..6667e85 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/doc/api_documentation.md @@ -0,0 +1,237 @@ +# API Documentation + +This template uses the [utoipa crate](https://crates.io/crates/utoipa) to generate swagger documentation for the +microservice's API at compile time. When the application is started, you can access the documentation +at http://localhost:8080/swagger-ui/index.html + +## Documenting DTOs + +Documenting DTOs can be done in two stages: deriving `ToSchema` on the DTO and adding it to the `components` list on `OpenApiSchemas`. + +### Deriving ToSchema + +`ToSchema` is a trait used by utoipa to read the fields in a DTO into an OpenAPI schema that can be used in the swagger UI. +For request DTOs, nothing needs to be done beyond adding the derive macro to the struct. For response DTOs, it is recommended +to add sample value annotations to the fields so the response looks nice in the swagger UI. + +Here's how that might look using the [DTO examples in the architecture documentation](./architecture_layers.md#dtos-and-validation): + +```rust +// in dto.rs + +// Note the ToSchema derivation here +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PlayerCreateRequest { + pub full_name: String, + pub username: String, +} + +// Note the ToSchema derivation here +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PlayerCreateResponse { + // This annotation provides the value for this field that will + // show up in an example response on Swagger + #[schema(example = 3)] + pub new_player_id: i32, +} +``` + +### Adding the DTO to OpenApiSchemas + +Once `ToSchema` is implemented on your new DTOs, you can attach the schema to utoipa by adding it to the list of schemas +defined on the `OpenApiSchemas` empty struct at the top of `dto.rs`: + +```rust +// in dto.rs + +#[derive(OpenApi)] +#[openapi(components( + schemas( + // Add the schemas here + PlayerCreateRequest, + PlayerCreateResponse, + + // ...other schema definitions + ), + + // ...other OpenAPI information +))] +pub struct OpenApiSchemas; +// ...dto definitions + +``` + +## Documenting API Endpoints + +API endpoints are documented using schema information defined on DTOs and [canned responses](#defining-canned-responses), +if applicable. Each API has its own OpenAPI struct that then gets composed into the main OpenAPI definition in the +`api::swagger_main` module. + +### Adding OpenAPI information to a route logic function + +OpenAPI information can be added to a [request logic function](./architecture_layers.md#request-logic-function) +with the `utoipa::path` annotation. It includes information on the API group a route belongs to, request and response +DTO information, the HTTP verb used, and more. A full description of all the options on the annotation can be found +[in the utoipa documentation](https://docs.rs/utoipa/4.2.0/utoipa/attr.path.html). + +With that information, let's see how we would document the "player create" endpoint defined in the above link: + +
+Code example for documenting an endpoint + +```rust +// in api/player.rs + +// We define a constant at the top of the file so we can group all of this API's endpoints together in Swagger +// and rename the group all at once if need be +pub const PLAYER_API_GROUP: &str = "Players"; + +// ...router definition + +// This path definition states the following: +// - You can hit the route with a POST to /players +// - It's part of the PLAYER_API_GROUP +// - The request body is the PlayerCreateRequest schema defined in dto.rs +// - The endpoint will respond in the following ways: +// - With a 201 CREATED, containing the PlayerCreateResponse schema +// - With a 400 BAD REQUEST, using the 400 validation error canned response +// - With a 409 CONFLICT, containing a BasicError schema with the error code "username_in_use" if the +// username is taken +// - With a 500 INTERNAL SERVER ERROR, using the 500 error canned response +// +// The doc comment on this endpoint will get rendered as the description in the swagger UI +// +/// Create a new player in the game +#[utoipa::path( + post, + path = "/players", + tag = PLAYER_API_GROUP, + request_body = PlayerCreateRequest, + responses( + (status = 201, description = "Player successfully created", body = PlayerCreateResponse), + (status = 400, response = dto::err_resps::BasicError400Validation), + ( + status = 409, + description = "Username is already taken", + body = BasicError, + example = json!({ + "error_code": "username_in_use", + "error_description": "The given username is already taken by another player.", + "extra_info": null, + }), + ), + (status = 500, response = dto::err_resps::BasicError500), + ), +)] +async fn create_player( + // ...parameters +) -> Result<(StatusCode, Json(dto::PlayerCreateResponse)), ErrorResponse> { + // ...route logic implementation +} +``` +
+ +### Attaching OpenAPI route information to a router's API information + +Each router under the `api` module has its own set of OpenAPI definitions. The routes under that API get attached +to the definitions under that router so they can be joined to the main OpenAPI spec for the microservice. + +As long as your route logic functions are decorated with `utoipa::path`, they can be added to the OpenAPI schema +for the given API group via the name of the route logic function: + +```rust +// in api/player.rs + +// Function names for route logic functions go under the "paths" list in this annotation +#[derive(OpenApi)] +#[openapi(paths( + create_player, +))] +pub struct PlayersApi; + +// ...rest of the API definition +``` + +### Merging an API's data into the main OpenAPI definition + +Once you have the OpenAPI spec defined for an API group, it can then be merged into the main swagger spec defined +in `api/swagger_main.rs`. The main OpenAPI spec containing the title and description of the overall API can be edited in +that file too. + +Here's how you can add a new API group to the main OpenAPI schema: + +```rust +// in api/swagger_main.rs + +// ...main OpenAPI definition + +// This function is already defined +pub fn build_documentation() -> SwaggerUi { + let mut api_docs = TodoApi::openapi(); + api_docs.merge(dto::OpenApiSchemas::openapi()); + // The OpenAPI definition for your API group gets merged with the .merge() function: + api_docs.merge(super::player::PlayersApi::openapi()); + + // ...rest of the swagger UI setup +} +``` + +## Defining canned responses + +In some cases, the same common error response may be returned across multiple different API endpoints. Rather than needing +to redefine the entire response every time that response is used, you can define a canned error response in the `dto::err_resps` +module. For these, rather than deriving `ToSchema`, you derive `ToResponse`. In doing so you include a response description +and example value that can be reused across multiple API endpoints. The response is then added to `OpenApiSchemas` like a DTO. +Note that a canned response must contain a type implementing `ToSchema` which describes the response body before `ToResponse` +can be derived. + +Here's how you might define one: + +```rust +// in dto.rs + +// ...dto definitions + +// This submodule already exists, no need to redefine it +pub mod err_resps { + + // We need to derive ToResponse on this type to make it a canned response + // The type that derives this trait must contain a schema, which is the schema used + // for the canned response body. + // + // In the response annotation, we describe the canned response and provide an example response body + #[derive(ToResponse)] + #[response( + description = "Conflicting data already exists in the system", + example = json!({ + "error_code": "conflicting_data", + "error_description": "Other data already present in the system conflicts with the new data.", + "extra_info": null + }) + )] + pub struct BasicError409(BasicError); + + // ...other canned responses +} +``` + +Once the canned response is defined, we can add it to `OpenApiSchemas` just like DTO definitions: + +```rust +// in dto.rs + +#[derive(OpenApi)] +#[openapi(components( + // ...other OpenAPI data + + responses( + // Add the canned response here + err_resps::BasicError409, + + // ...other canned responses + ), +))] +pub struct OpenApiSchemas; +``` diff --git a/practices/development/examples/rust-microservice-template/doc/architecture_layers.md b/practices/development/examples/rust-microservice-template/doc/architecture_layers.md new file mode 100644 index 0000000..825bc85 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/doc/architecture_layers.md @@ -0,0 +1,609 @@ +# Architecture Layers + +This microservice template is built using [Hexagonal Architecture](https://medium.com/ssense-tech/hexagonal-architecture-there-are-always-two-sides-to-every-story-bc0780ed7d9c). +**Please read that overview before moving forward, terminology from it will be used in this document.** + +The app is built using the standard hexagon layout: +* The HTTP-based driving adapters are implemented in the [API Module](../src/api/mod.rs) +* The domain logic is implemented in the [Domain Module](../src/domain/mod.rs) +* The driven adapters (just SQL connections for now) are implemented in the [Persistence Module](../src/persistence/mod.rs) + +## Layer Interactions + +### Domain layer +The domain layer should define all its interactions with the other layers. This means defining submodules for some set of +functionality called `driving_ports` and `driven_ports`, including the return types and error types that are either received +from or passed to adapters interacting with the domain. + +Functions able to be called from driving adapters should be exposed through the driving port and implemented in the +domain. Other functions can be defined to support the domain logic, but they don't need to be included on the driving port +unless they need to be called from a driving adapter. + +For testability, driving ports should accept driven port implementations so ownership plays nicely with your code (basically, +ownership of everything trickles down from the driving adapter). + +### Driving adapters (HTTP routing) +Driving adapters should handle protocol-specific details for triggering logic in the domain. This means accepting and responding +with DTOs (Data Transfer Objects) and converting these DTOs into domain types before invoking domain functionality through +the driving port. Conversely, when a domain type or domain error is passed back to a driving adapter, they should be converted +into DTOs before they go over the wire. The HTTP response code should be determined based on the semantics of either a successful +operation or the specific domain error received from the driving port. + +Driving adapters have a "special" method of interaction with driven ports on the other side of the hexagon - only driving +adapters may start database transactions, as the domain must be ignorant of underlying technologies it interacts with. +This also supports propagating the same database transaction across multiple calls into the domain. + +This is all facilitated through the **ExternalConnectivity** trait, which provides accessors for driven adapters to contact +external systems. It also provides an abstraction over SQLX's [Transaction](https://docs.rs/sqlx/latest/sqlx/struct.Transaction.html) +and [PoolConnection](https://docs.rs/sqlx/latest/sqlx/pool/struct.PoolConnection.html) types so database +connection code can be written in a way that's agnostic of whether the app communicates with the database in the middle +of a transaction or via a standard database connection. + +### Driven adapters (database and other external systems) +Driven adapters should handle protocol-specific details to help facilitate the domain's communication with external systems. +They should implement the driven port trait defined by the domain and convert domain types into DTOs (Data Transfer Objects) +before sending data over the wire. Acquiring the connection to the outside world can be done by accepting something implementing +the **ExternalConnectivity** trait. + +The driving adapter should then translate the DTO it receives back into a domain type, or if an error is received it should +either be translated into a predefined domain error or be returned as a catch-all "port error". This way the domain +can respond appropriately to errors without having knowledge of the underlying implementation. + +### TL;DR + +![A diagram depicting the interaction between layers of the application as described in previous paragraphs.](img/hexagonal_arch_diagram.png "Too Long - Didn't Read Diagram") + +## Implementation of each layer + +To demonstrate how to write out each layer, we'll walk through writing the domain logic and the ports associated with +it. Let's imagine we're defining an API where we're registering players for a game. The incoming DTO will have some +basic validation, but the user may not have the same username as another player. + +### Implementing the domain layer + +Ports should have a narrow focus and **shouldn't grow to more than about 8 to 10 available functions**. If you go beyond that, +it's likely that the port is doing too much, and it should be decomposed into more focused ports. As a reminder, a **driven port** +allows the domain logic to communicate with external systems. It will need an `ExternalConnectivity` instance in order to do so. +Additionally, a **driving port** will expose the domain's functionality to driving adapters in higher layers. + +#### Driven Port + +Let's start with the driven port. We'll need to define our inputs and outputs for our business logic, including any +expected errors. Each set of ports will be defined in their own submodule. + +
+Code example for driven port definition + +```rust +// Pretend this file is domain/player.rs + +// Domain objects are defined at the module level +// Structs containing data for specific operations should be defined +// separately from structs that contain a full set of data. +pub struct PlayerCreate { + pub username: String, + pub full_name: String, +} + +// This struct can be consumed later, but contains all +// pertinent information about a player +pub struct Player { + pub id: i32, + pub username: String, + pub full_name: String, + pub level: i32, + pub in_good_standing: bool, +} + +// All driven port traits are defined in this submodule +pub mod driven_ports { + use super::*; + + // Making trait implementations require the Sync trait makes + // tons of ownership errors go away + // PlayerDetector detects the presence of users + pub trait PlayerDetector: Sync { + // If the only expected error is a port error, just return anyhow::Error in your result. It can be refactored + // later if need be. + async fn player_with_username_exists( + &self, + username: &str, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result; + } + + // PlayerWriter writes new player data to an external system + pub trait PlayerWriter: Sync { + // order of parameters is: + // 1. Necessary data + // 2. External connectivity implementation + async fn new_player( + &self, + creation_data: &PlayerCreate, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result; + } + + // Here's another example of a driven port + // PlayerReader retrieves player data + pub trait PlayerReader: Sync { + async fn all_players(&self, ext_cxn: &mut impl ExternalConnectivity) -> Result, anyhow::Error>; + async fn player_by_username(&self, username: &str, ext_cxn: &mut impl ExternalConnectivity) -> Result, anyhow::Error>; + } +} +``` +
+ +#### Driving Port + +Once we have the driven port trait defined, we can use the driven ports as parameters in our driving port, allowing us +to [inject fakes during testing](./testing.md#unit-testing-business-logic). As mentioned previously, the driving adapter has ownership +of the connections the code makes to the outside world, so we need to pass something implementing `ExternalConnectivity` into +the driving port. We'll need to define the set of errors that can be produced from the driving port functions, too. + +Here's how we can define the driving port with a function for creating the new player: + +
+Driving port code example + +```rust +// This is in another section of domain/player.rs + +// We'll define a module to group the driving port definition here +pub mod driving_ports { + use super::*; + use thiserror::Error; + + // Now that we have specific error cases we want to call out, we should define + // an error specific to player creation. You may share these domain errors across multiple + // functions, or compose re-usable error data structures across multiple error enums + // to make your error definitions DRY (don't repeat yourself). + + // We'll use the "thiserror" crate to make the error variants display human-readable + // error messages if need be. + #[derive(Debug, Error)] + pub enum PlayerCreateError { + // Because we can only validate if the username was taken by hitting the database, + // we have to check username usage as part of the business logic + #[error("The given username was already taken")] + UsernameTaken, + + // "Transparent" makes this error variant just display the error message from the wrapped error + // "From" on the inner error adds an implementation of From for PlayerCreateError + // + // Port errors encapsulate any other error that gets returned other than the ones we expect. These + // might be database-specific errors or connectivity errors. + #[transparent] + PortError(#[from] anyhow::Error), + } + + // This trait is what the driving adapter will invoke to trigger the business logic + // we write in this file + // + // Again, requiring Sync on the implementer gets rid of a ton of lifetime issues + pub trait PlayerPort: Sync { + // The conventional order of parameters in driving port functions is: + // 1. Actual imports + // 2. The external connections implementation to pass to lower layers + // 3. The driven port implementations, so this function can invoke them without the implementation + // needing to own instances of the trait implementations or access them through Arcs + // + // Passing the driven ports this way will also allow callers to know exactly what kinds of operations a piece + // of business logic intends to invoke. + async fn new_player( + &self, + player_create: &PlayerCreate, + ext_cxn: &mut impl ExternalConnectivity, + p_detect: &impl driven_ports::PlayerDetector, + p_write: &impl driven_ports::PlayerWriter + ) -> Result; // The driven port function will just return the ID of the new player or our error set + } +} +``` +
+ +#### Business logic (driving port implementation) + +The driving port implementation is the entrypoint from outside the system for our business logic. We can call into passed +driven ports to access data from outside the system if need be, so technically we can actually write and test our business +logic without starting the server or connecting to any external systems/writing out database migrations. + +The driving adapter is defined as an empty struct and should not have any fields (therefore, it holds no state). This empty +struct can later be used to pass the real adapter when the app starts or a mock when testing the HTTP router. (Also, since +we use an empty struct the port implementations we define consume no memory, and thus is equivalent to passing around a single +pointer for a bundle of functions! Gotta love monomorphic generics.) + +Keep in mind that your business logic pretty much just consists of a bunch of functions floating around in the "domain" +module. **Adding functions to the driving port isn't necessary unless you need business logic to be called from a driving adapter.** + +Now, let's look at how we'd implement the business logic on the other side of the driving port: + +
+Code example for business logic + +```rust +// This example would also be part of domain/player.rs + +// We define the other end of the driving port as an empty struct. +// This will let us swap out the real implementation in tests. +pub struct PlayerService; + +// Here's our impl block for the driving port logic +impl driving_ports::PlayerPort for PlayerService { + + // And now we can implement the logic for creating the new player + async fn new_player( + &self, + player_create: &PlayerCreate, + ext_cxn: &mut impl ExternalConnectivity, + p_detect: &impl driven_ports::PlayerDetector, + p_write: &impl driven_ports::PlayerWriter, + ) -> Result { + // We can just use the logger here to say what it is we're doing + info!("Attempting to create a new user with the username {}.", player_create.username); + + // First, we need to check for our error condition - is the username taken? + // + // In order to re-use our borrowed external connectivity implementation, we'll need to re-borrow the reference + // to pass it into driven ports (this creates a new mutable reference to the data pointed to by our mutable reference + // and doesn't violate any ownership rules. In Rust, & is Copy while &mut is not. That's what the "&mut *" is.) + let username_taken = p_detect.player_with_username_exists(&player_create.username, &mut *ext_cxn) + .await + // Since we have an anyhow::Error here, we'll add some context on what we were doing for debuggability. + // Then we've got the good ole question mark operator to auto-handle our PortError case. + // + // Remember how we have From implemented on PlayerCreateError? The question mark will auto-transform + // the anyhow::Error into a PlayerCreateError::PortError due to that implementation. + .context("checking username existence during player creation")?; + + // Now return the expected error, if need be. + if username_taken { + warn!("Oops, the username {} was already taken.", player_create.username); + + return Err(driving_ports::PlayerCreateError::UsernameTaken); + } + + // Our check is passed! Let's create the new player. + Ok(p_write.new_player(player_create, &mut *ext_cxn).await?) + } +} +``` +
+ +With that, the core of the hexagon is complete! Now we just need to be able to access the database and to expose our +business logic via the HTTP server. + +### Implementing the driven adapter (database) + +To implement the driven adapter, we'll need to create a couple of empty structs and use them to implement the `PlayerDetector` +and `PlayerWriter` structs. From the `ExternalConnectivity` struct, we can acquire a connection to the database and use it +with `sqlx` to retrieve data from the database. Remember that we need to convert domain types into DTOs before sending them +to the database, and only respond to the domain with domain types and errors. This enforces a strict boundary between +the business logic and the actual implementation detail of connecting to the database as an external data source. This +decoupling allows for easy swapping of underlying implementations if need be. + +Note that the `persistence` module includes utilities to transform anything that implements the `Debug` and `Display` traits +into an `anyhow::Error` called `anyhowify()`. There are also utilities for extracting the ID of inserted data, such as the +`NewId` struct. Similarly, `Count` stores the output of the `count()` SQL function. + +The really neat thing about using SQLx for queries is that it will automatically verify type compatibility between your +DTOs and the database schema during build time automatically. Just another layer of correctness for your API. (Side note: +this typechecking can be done without an active database connection. See the [database documentation](./database.md#updating-offline-typechecking-for-ci) +for more info.) + +Here's how we can implement those driven adapters: + +
+Implementation of the driven adapters + +```rust +// This example takes place in persistence/db_player_driven_ports.rs +use sqlx::query_as; + +// Define a struct for the implementation of the driven adapter +pub struct DbPlayerWriter; + +impl domain::player::driven_ports::PlayerWriter for DbPlayerWriter { + async fn new_player(&self, creation_data: &domain::player::PlayerCreate, ext_cxn: &mut impl ExternalConnectivity) -> Result { + // Acquire a database connection + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + // Make the database query. In this case, NewId is our re-usable DTO + // used for acquiring the id of the information added into the database + let new_id = query_as!( + super::NewId, + "INSERT INTO players(username, full_name) VALUES ($1, $2) RETURNING players.id", + creation_data.username, + creation_data.full_name, + ) + .fetch_one(cxn.borrow_connection()) + .await + .context("trying to insert a new player into the database")?; + + // Convert the DTO into the domain type and return (typically this is a more complex data structure, + // but we only need the ID here) + Ok(new_id.id) + } +} + +// This struct implements the other driven adapter +pub struct DbPlayerDetector; + +impl domain::player::driven_ports::PlayerDetector for DbPlayerDetector { + async fn player_with_username_exists(&self, username: &str, ext_cxn: &mut impl ExternalConnectivity) -> Result { + // Acquire a database connection + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + // Make the query + let username_count = query_as!( + super::Count, + "SELECT count(*) FROM players WHERE username = $1", + username + ) + .fetch_one(cxn.borrow_connection()) + .await + .context("detecting existing players with a given username")?; + + // Return the result + Ok(username_count.count() > 0) + } +} +``` +
+ +### Implementing the driving adapter (HTTP Routing) + +With the business logic and driven adapters in place, we can now implement an HTTP-based driving adapter to trigger +everything end-to-end. The driven adapter is implemented in 2 parts - the request extractor function and the request logic +itself. This separation is in place to make it easy to mock out the business logic in tests. We'll first look at the code +for the request logic, then integrate it into the Axum router with the request extractor function. + +#### Request Logic + +The request logic starts by taking in any required information, then an implementation of both `ExternalConnectivity` and +the driving adapter. + +##### DTOs and Validation + +In order to get that required information, we'll need to define a DTO for our request body. Request bodies only need to +implement [serde](https://serde.rs/)'s `Deserialize` trait, while responses need to implement `Serialize`. Let's define our request and response +bodies. We can also use the `rename_all` piece of the serde annotation to make the data structure accept and output fields +in camel case, which is typical for JS/TS code which would consume the API. + +```rust +// in dto.rs +use serde::{Serialize, Deserialize}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerCreateRequest { + pub full_name: String, + pub username: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerCreateResponse { + pub new_player_id: i32, +} +``` + +Now, recall that one of our requirements for creating the player was that there should be some basic validation on the +incoming data. That can be done by deriving [validator::Validate](https://docs.rs/validator/latest/validator/index.html) +on our request. Let's add some validation which requires the full name to be 1-256 characters, and the username to be +1-32 characters in length. + +```rust +// in dto.rs +use serde::{Serialize, Deserialize}; +use validator::Validate; + +// We're now also deriving Validate so we get the .validate() function on our DTO +#[derive(Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct PlayerCreateRequest { + // The full name should be 1-256 characters in length + #[validate(length(min = 1, max = 256))] + full_name: String, + + // The username can only be 1-32 characters in length + #[validate(length(min = 1, max = 32))] + username: String, +} + +// ...response DTO +``` + +It can also be useful to implement the `From` trait on domain types so we can convert DTOs into domain types in a single line +of code. Let's do that while we're here: + +```rust +// in dto.rs + +// ...definition of PlayerCreateRequest + +impl From for domain::player::PlayerCreate { + fn from(value: PlayerCreateRequest) -> Self { + Self { + full_name: value.full_name, + username: value.username, + } + } +} + +// ...response DTO +``` + +##### Request logic function + +Now that we have DTOs for our request, let's actually implement the request function. The conventional order of parameters +for a request function is: + +1. DTOs and required data +2. ExternalConnectivity implementation +3. Driving Port for the business logic + +Because the business logic is mocked out in tests, we can actually create instances of the driven adapters inside the logic +of the function as they won't ever be invoked. + +Many responses are common across various endpoints on the microservice, so canned responses are available in the `routing_utils` module. +We'll use some of these for generic 500 errors and validation errors. Otherwise, [axum-compatible response types](https://docs.rs/axum/0.7.5/axum/response/index.html#building-responses) +should be returned from the routing logic function. + +We'll also need to interpret the result from the business logic and turn it into an appropriate HTTP response, complete with +appropriate status codes and DTOs. **NOTE: be sure to use the `routing_utils` version of the `Json` type, as it is customized to +return the common error data structure defined in this template when used for extracting JSON in the request.** +Here's how the logic would be implemented: + +
+Code example for implementing the request logic function + +```rust +// In api/player.rs +use crate::router_util::ValidationErrorResponse; + +async fn create_player( + new_player: dto::PlayerCreateRequest, + ext_cxn: &mut impl ExternalConnectivity, + player_service: &impl domain::player::driving_ports::PlayerPort, +) -> Result<(StatusCode, Json(dto::PlayerCreateResponse)), ErrorResponse> { + info!("Creating a new player with the username {}.", new_player.username); + + // First, we need to validate the incoming request. + // On an error, we can use the pre-built routing_utils::ValidationErrorResponse type + // to report the error to the user + new_player.validate().map_err(ValidationErrorResponse::from)?; + + // Now that we have a valid payload, we can convert it into a domain type to pass through the driving port. + let player_create_domain = domain::player::PlayerCreate::from(new_player); + + // Next, we can create instances of the driven adapters to pass to the business logic and + // attempt to create the player. + let player_detector = persistence::db_player_driven_ports::DbPlayerDetector; + let player_writer = persistence::db_player_driven_ports::DbPlayerWriter; + + // We have to reborrow the ExternalConnectivity instance to retain ownership of it, as mutable references + // don't implement the Copy trait. + // + // Notice that you can immediately tell from the invocation that new_player both detects the existence of a player + // and writes a player to an external system. Beyond ownership and testing benefits, passing the driven adapters here + // provides a level of transparency to the operations you perform. + let player_create_result = player_service.new_player(&player_create_domain, &mut *ext_cxn, &player_detector, &player_writer).await; + + // We now need to handle any domain errors that cropped up, or retrieve the result + let new_player_id = match player_create_result { + Ok(id) => id, + // If the username was already taken, we should return a 409 Conflict with an appropriate error message. + // The error_code field here is used to differentiate between unique errors in a class of errors, such + // as determining what part of a URL was missing for a 404 error + Err(PlayerCreateError::UsernameTaken) => { + warn!("Username {} was already in use.", player_create_domain.username); + + // A tuple of (StatusCode, Json) can be converted into a response, and the into() on the end converts it + // into an ErrorResponse + return Err(( + StatusCode::CONFLICT, + + // BasicError is the error DTO type used throughout this template + Json(dto::BasicError { + // The error code is a differentiator that consumers can match on to differentiate different errors + // produced under the same HTTP status code + error_code: "username_in_use".to_owned(), + + // The error description is a human-readable error that can be presented to users via consumers of the API + error_description: format!(r#"The username "{}" is already in use. Please choose another."#, player_create_domain.username), + + // Extra info is used to provide contextual information in some cases, such as the set of failed + // validations produced by a DTO's validate() function. It is intended to be extended, so feel free! + extra_info: None, + }) + ).into()); + }, + + // This is the "unexpected error" case. We'll just use routing_utils::GenericErrorResponse to report it. + Err(PlayerCreateError::PortError(cause)) => { + error!("An unexpected error went wrong creating the player {}. Error: {}", player_create_domain.username, cause); + + // GenericErrorResponse can also be converted into ErrorResponse + return Err(GenericErrorResponse(cause).into()); + }, + }; + + // Now that all the errors are handled, we can create our response DTO and provide a response to the user + let response_body = dto::PlayerCreateResponse { + new_player_id, + }; + + Ok((StotusCode::CREATED, Json(response_body))) +} +``` + +
+ +#### Request Extractor + +Now that the business logic is in place, we can set up a function on an Axum router by defining a function which +creates an attachable router and setting it up to be invoked on certain HTTP routes. + +##### The router function + +Each group of APIs can be defined piecemeal and exposed via a central function which attaches the defined routes to a +router, which can then be attached to the main Axum app. Here's how we can define that router and attach it: + +```rust +// In api/player.rs +use axum::Router; +// This import is important!!! This version of the JSON extractor uses BasicError +// for an error when a caller passes invalid JSON rather than Axum's error type +use routing_util::Json; + +// The generic type in the return type defines the expected application +// state that we're allowed to extract from the main Axum app. +// This will allow us to get access to the shared ExternalConnectivity instance +pub fn player_routes() -> Router> { + Router::new() + // Off of the router, we define a new route which will invoke our route logic + // You may assume that when the router is attached to the main app, a prefix will + // be added for this group of routes + .route( + "/", + // HTTP verb functions specify which HTTP verb will invoke the following function + // + // We're using some axum extractors here to get the application state for the + // ExternalConnectivity instance and the request body, which we're getting via + // the Json extractor + post(|State(app_data): AppState, + Json(player_create): Json| async move { + // In order to invoke the route logic, we need ExternalConnectivity and + // the business logic instance. We'll create both, then invoke the route logic. + let player_service = domain::player::PlayerService; + let mut ext_cxn = app_data.ext_cxn.clone(); + + // Remember to await the call. You'll get a nasty error otherwise. + create_player(player_create, &mut ext_cxn, &player_service).await + }) + ) +} + +// ...route logic function we defined earlier +``` + +##### Attaching to the main Axum app + +With the router implemented, all that's left to do is attach it to the main axum app! + +```rust +// in main.rs + +#[tokio::main] +async fn main() { + // ...setup in the main function + + let router = Router::new() + // This nest() call attaches the player router to the app. Now we're ready to serve! + .nest("/players", api::player::player_routes()) + .with_state(Arc::new(SharedData { ext_cxn })); + + // ...axum app starts listening +} +``` diff --git a/practices/development/examples/rust-microservice-template/doc/configuration.md b/practices/development/examples/rust-microservice-template/doc/configuration.md new file mode 100644 index 0000000..15f88bd --- /dev/null +++ b/practices/development/examples/rust-microservice-template/doc/configuration.md @@ -0,0 +1,41 @@ +# Configuration + +This template is configured via environment variables, as that is typically how microservices are configured when deployed +with Docker. For both the ability to rename environment variables across the microservice and to track environment variable +usage within the application, environment variable names are defined as constants in the `app_env` module. Environment +variables used specifically in tests are defined in the `app_env::test` module. This can also be used as a way to document +environment variables. + +Here's an example of how these environment variables might be defined: + +```rust +// in app_env.rs + +/// Defines whether the big red button should be pushed during app startup +pub const PRESS_BUTTON: &str = "PRESS_BUTTON"; + +// ...other environment variables + +#[cfg(test)] +pub mod test { + /// The state of the big red button in tests + pub const BUTTON_STATE: &str = "BUTTON_STATE"; +} +``` + +Once those constants are defined, you can use them with `env::var()` to read the configuration value: + +```rust +// Somewhere else in the application + +use std::env; + +fn maybe_press_button() { + let press_button = match env::var(app_env::PRESS_BUTTON) { + Ok(state) => state, + Err(_) => "false".to_owned(), + }; + + // ...do something with that config option +} +``` \ No newline at end of file diff --git a/practices/development/examples/rust-microservice-template/doc/database.md b/practices/development/examples/rust-microservice-template/doc/database.md new file mode 100644 index 0000000..c546fbd --- /dev/null +++ b/practices/development/examples/rust-microservice-template/doc/database.md @@ -0,0 +1,65 @@ +# Database + +This microservice template is not prescriptive about how you manage your database or migrate its schema. +For convenience, a database setup script is provided in the [postgres-scripts](../postgres-scripts) folder +for you to populate the database as you see fit. It is recommended that you set up a separate database migration +solution such as [liquibase](https://www.liquibase.com/) for managing migrations on your database during deployment. + +## Executing a database transaction across business logic + +According to Hexagonal Architecture principles, the domain/business logic should be built agnostic of the external +technologies it interfaces with. That's the responsibility of the adapters that help the hexagon interface with the +outside world. In order to support this, a `with_transaction` function is provided to initiate database transactions +across business logic invocations at the driving adapter level. + +Here's an example which builds off of the [request logic function example](./architecture_layers.md#request-logic-function) +to create a new player in a database transaction: + +```rust +// in api/player.rs +use routing_utils::Json; +use axum::ErrorResponse; +use external_connections::{TransactableExternalConnectivity, with_transaction}; + +async fn create_player( + new_player: dto::PlayerCreateRequest, + + // If you want to perform database transactions, you need to use TransactableExternalConnectivity + // rather than just ExternalConnectivity to make sure you can initiate database transactions. + // + // Because of the implementation of with_transaction, you only need an immutable borrow of ext_cxn + ext_cxn: &impl TransactableExternalConnectivity, + + player_service: &impl domain::player::driving_ports::PlayerPort, +) -> Result<(StatusCode, Json(dto::PlayerCreateResponse)), ErrorResponse> { + // ...validation and DTO creation + + // You can just wrap the call to the service inside with_transaction! + // The lambda you provide gives you a new version of ext_cxn which is inside the database transaction + // + // with_transaction will also wrap errors returned from the domain in a custom error type that reports + // issues with committing the transaction, so you'll need to handle for that too + let player_create_result = with_transaction(ext_cxn, |tx_cxn| async { + // tx_cxn is a mutable reference to an ExternalConnectivity instance with an initiated + // database transaction. The transaction will be committed after the end of the lambda + // if the lambda does not return Result::Err. + player_service.new_player(&player_create_domain, &mut *tx_cxn, &player_detector, &player_writer).await + + // Any business logic you want to run in the same database transaction can be added here. Just keep using tx_cxn. + }).await; + + // ...error handling and response +} +``` + +## Updating offline typechecking for CI + +In CI, some steps utilize SQLx's "offline typechecking" capability to allow building the code without having access to +an active database running. Anytime you update a SQL query in your code or write a new one, you'll need to update the +offline cache of type information for the database to make sure offline typechecking stays accurate (offline typechecking +is enabled by setting the `SQLX_OFFLINE` environment variable to `true` if you want to do this yourself). + +In order to update the offline typecheck information, you'll need the SQLx cargo plugin. This can be installed +by running `cargo install sqlx-cli`. + +Once you have that installed, you can update the offline type data by running `cargo sqlx prepare` with an active database. diff --git a/practices/development/examples/rust-microservice-template/doc/img/hexagonal_arch_diagram.png b/practices/development/examples/rust-microservice-template/doc/img/hexagonal_arch_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..e2406cc555155ce6bdbc7d9e367c184914983885 GIT binary patch literal 59220 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 +Fake implementation example + +```rust +// in domain/player.rs + +// ...business logic/domain objects/port definitions + +// Re-usable test utilities are defined under a test_util submodule below the module where relevant traits are defined +#[cfg(test)] +pub mod test_util { + use std::sync::RwLock; + use super::*; + + pub struct InMemoryPlayerPersistence { + // This will determine if we're actively "connected" to the thing on the other end of the port + pub connectivity: Connectivity, + + // We'll store the created players in this vector + pub players: Vec, + } + + impl InMemoryPlayerPersistence { + // Define a constructor for the fake + pub fn new() -> InMemoryPlayerPersistence { + Self { + connectivity: Connectivity::Connected, + players: Vec::new(), + } + } + + // It can also be handy to provide a constructor which wraps the type inside the synchronization + // primitive + pub fn new_locked() -> RwLock { + RwLock::new(Self::new()) + } + } + + // Now we can implement the driven port trait on the fake, specifically when it's wrapped + // in the synchronization primitive + impl driven_ports::PlayerDetector for RwLock { + async fn player_with_username_exists( + &self, + username: &str, + _ext_cxn: &mut impl ExternalConnectivity + ) -> Result { + // First, we need to acquire a lock on the fake + let self_locked = self.read().unwrap(); + + // Next, blow up in the event the port is in a disconnected state + self_locked.connectivity.blow_up_if_disconnected()?; + + // Next, implement the fake's logic + let matching_username_exists = self_locked.players.iter().any(|player| player.username == username); + Ok(matching_username_exists) + } + } + + // We can then implement PlayerWriter and other driven ports on InMemoryPlayerPersistence down here +} +``` + + + +### Using the fake in a test + +With the fake defined, we can use it to fake the functionality of driven ports in business logic tests. We'll define a +simple happy path test and error test for PlayerService this way. + +The `external_connections::test_util` module defines a fake `ExternalConnectivity` instance we can use for testing +on top of the fake we just implemented. + +
+Code example for testing business logic with a fake + +```rust +// in domain/player.rs + +// ...business logic/domain objects/port definitions + +#[cfg(test)] +mod tests { + // As much as possible, try to keep your imports at the top level of the test module and inherit + // them into submodules + use super::*; + use speculoos::prelude::*; + use std::sync::RwLock; + + // Keeping short test names is easier if you create a test submodule + // for every function you want to test + mod player_service_new_player { + use super::*; + + // It can help to have factory methods for common sets of data + fn player_create_default() -> PlayerCreate { + PlayerCreate { + full_name: "John Smith".to_owned(), + username: "jsmith22".to_owned(), + } + } + + #[tokio::test] + async fn happy_path() { + // First, we need to define our fakes and the service to test against + let in_memory_players = test_util::InMemoryPlayerPersistence::new_locked(); + let mut ext_cxn = external_connectivity::test_util::FakeExternalConnectivity::new(); + let svc = PlayerService; + + // Now we can define our input to the business logic + let new_player = player_create_default(); + + // Now let's invoke the business logic! + let player_create_result = svc.new_player(&new_player, &mut ext_cxn, &in_memory_players, &in_memory_players).await; + + // With Speculoos, we can chain some assertions together to verify we got what we expected (successful creation, ID 1) + assert_that!(player_create_result).is_ok().is_equal_to(1); + } + + #[tokio::test] + async fn returns_port_error_on_port_fail() { + // Next, we'll do a test where the port is disconnected. + let mut raw_players = test_util::InMemoryPlayerPersistence::new(); + // After creating the fake, we can set its connectivity property to "disconnected" to force a failure + raw_players.connectivity = Connectivity::Disconnected; + + let in_memory_players = RwLock::new(raw_players); + let mut ext_cxn = external_connectivity::test_util::FakeExternalConnectivity::new(); + let new_player = player_create_default(); + let svc = PlayerService; + + // Now invoke the business logic and assert + let player_create_result = svc.new_player(&new_player, &mut ext_cxn, &in_memory_players, &in_memory_players).await; + + assert_that!(player_create_result) + .is_err() + .matches(|err| { + // You can use the matches! macro to pattern match against the error value + matches!(err, driving_ports::PlayerCreateError::PortError(_)) + }); + } + + // ...more tests + } + + // ...more test submodules +} + +// ...implementation of test_util module +``` + +
+ +## Unit Testing API Routes + +Testing API routes is a little more complex because it can involve deserializing the response produced from the +request logic in error cases. Typically, it is sufficient to just mock the business logic to verify values it produces +convert to expected HTTP responses. + +As mentioned previously, `mockall::automock` doesn't work particularly well with traits containing async functions. +Instead, `domain::test_util` contains a composable `FakeImplementation` type that can be used to easily implement mocks +for both sync and async trait implementations. + +### Defining a mock with FakeImplementation + +Similar to fake implementations, trait implementations for mocks should be done inside a synchronization primitive to +allow for interior mutability while using immutable references to pass around the driving port. + +Mocks are implemented by composing together `FakeConnectivity` instances to set return values on specific functions. It +does this by utilizing generics to both record inputs and fake outputs of a function. Here's how that can be done, +implementing `domain::player::driving_ports::PlayerPort` as a mock: + +```rust +// in domain/player.rs + +// ...rest of the module +#[cfg(test)] +pub mod test_util { + use std::sync::Mutex; + use super::*; + + // ...other test utility definitions + + pub struct MockPlayerService { + // Define one FakeConnectivity instance for every function on the trait you're mocking + // + // The first generic is the type containing arguments you want to capture (usually a tuple or basic data type + // but it must implement Clone) + // + // The second generic is the return type of the function you're mocking, which also must do one of 3 things: + // 1. Implement the Clone trait + // 2. Be a Result which returns cloneable types for both the Ok and Err variants + // 3. Be a result which returns a cloneable type for the Ok variant but an anyhow::Error for the error + // + // These traits can be conditionally derived on a type specifically during tests via #[cfg_attr(test, derive(...))] + pub new_player_response: FakeImplementation>, + } + + impl MockPlayerService { + // We'll need a constructor for the mock + pub fn new() -> MockPlayerService { + Self { + new_player_response: FakeImplementation::new(), + } + } + + // It's also useful to have a builder constructor, so you can configure the mocks and wrap the mock in a mutex in one + // function call + pub fn build_locked(builder: impl FnOnce(&mut MockPlayerService)) -> Mutex { + let mut instance = Self::new(); + // The builder function here will allow developers to set mock return types before wrapping the mock in a lock + builder(&mut instance); + + Mutex::new(instance) + } + } + + // Now, let's implement the mock for PlayerService + // + // With fakes, we'll typically have cases where we'll either be reading the data inside the fake or + // actually writing new data to the fake. In that case, an RwLock makes more sense since it has "read" and "write" + // locking modes. For mocks, we tend to be writing into the mock on just about every call to save arguments, so + // Mutexes make more sense for mocks. + impl driving_ports::PlayerPort for Mutex { + async fn new_player( + &self, + player_create: &PlayerCreate, + _: &mut impl ExternalConnectivity, + _: &impl driven_ports::PlayerDetector, + _: &impl driven_ports::PlayerWriter + ) -> Result { + // First, lock the sync primitive + let mut locked_self = self.lock().unwrap(); + + // Next, use FakeImplementation to record the call + locked_self.new_player_response.save_arguments(player_create.clone()); + + // Then, use FakeImplementation to return the mock result. The available functions + // to return with are varied based on return type, so keep an eye on your autocomplete. + locked_self.new_player_response.return_value_result() + } + } +} +``` + +### Using the mock to test an API route + +Now that we have a mock for the player service, we can force specific results from the business logic to verify every +response from the endpoint. On happy path tests, you can easily extract the raw value from the result returned from the +function. + +For tests verifying error responses, there are a number of different data structures that could have been +transformed into `ErrorResponse` for the `Err` variant of the request logic. To make it easy to verify the body of these +error results, the `api::test_util` module provides the `deserialize_body()` helper function, which takes a raw `axum::body::Body`, +turns it into a set of bytes, then deserializes it from JSON into a DTO. + +Speaking of DTOs, we'll need to make sure response DTOs we interact with can be deserialized. We'll walk through router +testing by following these 3 steps: + +1. Derive `serde::Deserialize` on response DTOs specifically during tests +2. Write a happy path test to verify the behavior of the endpoint +3. Write a test verifying the `409 Conlfict` response, checking the error body with the `deserialize_body()` helper function. + +#### Making response DTOs deserializable in tests + +It's a good idea to only make response DTOs deserializable in tests. This makes it so developers don't accidentally try to +use a response DTO for a request instead! However, response DTOs may need to be deserialized back into Rust structs during +tests so they can be asserted against. This can be done via the `cfg_attr()` macro, building off of +[this example in the architecture documentation](architecture_layers.md#dtos-and-validation): + +```rust +// in dto.rs + +// ...request implementation + +#[derive(Serialize)] +// This cfg_attr line will only implement Deserialize if we're running cargo tests. +#[cfg_attr(test, derive(Deserialize))] +#[serde(rename_all = "camelCase")] +pub struct PlayerCreateResponse { + pub new_player_id: i32, +} +``` + +#### Writing the tests + +With the DTOs configured, we can now implement tests against the router logic to verify that we receive the expected +HTTP responses based on return values from the business logic. The first test will be a simple happy path test, while +the second one will verify the `409 Conflict` response defined in [this example in the microservice documentation](./architecture_layers.md#request-logic-function). + +Here's how we'd write those tests: + +
+Code example for testing router logic + +```rust +// in api/player.rs + +// ...route implementation + +#[cfg(test)] +mod tests { + use super::*; + + // Similar to the domain logic tests, it can be helpful to organize your test functions + // by grouping route tests in separate test submodules + mod create_player { + use super::*; + + // The first test verifies the success path, producing a 201 and returning an ID of the new player + #[tokio::test] + async fn happy_path() { + // First, we need to set up our mocks. We'll make the player service mock return a successful result to + // the driving adapter. + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + // Using the builder function we defined earlier, we can set mock responses via the mutable reference in the + // closure before the mock service gets wrapped in a mutex - no locking necessary! + let player_service = domain::player::test_util::MockPlayerService::build_locked(|svc| { + // We can use the FakeImplementation property to set the mock return value + svc.new_player_response.set_returned_result(Ok(5)); + }); + + // Now we just need to set up the DTO and invoke the route logic function + let new_player_info = dto::PlayerCreateRequest { + full_name: "John Smith".to_owned(), + username: "jsmith22".to_owned(), + }; + let (status_code, Json(api_response)) = create_player(new_player_info, &mut ext_cxn, &player_service) + .await + .expect("Didn't get a successful response from the HTTP route!"); + + // Now we can verify we got the right status code and ID from the business logic + assert_eq!(StatusCode::CREATED, status_code); + assert_eq!(5, api_response.new_player_id); + + // The mock also captured passed arguments, so we can verify those too if we want + let locked_player_service = player_service.lock().unwrap(); + let service_calls = locked_player_service.new_player_response.calls(); + + assert_eq!(1, service_calls.len()); + assert_eq!("John Smith", service_calls[0].full_name); + assert_eq!("jsmith22", service_calls[0].username); + } + + // The second test verifies we return a 409 conflict with appropriate error code if the + // requested username is taken + #[tokio::test] + async fn responds_409_if_username_is_taken() { + // Again, set up the mocks. This time we'll return an expected domain error to trigger the 409 + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let player_service = domain::player::test_util::MockPlayerService::build_locked(|svc| { + svc.new_player_response.set_returned_result(Err(domain::player::driving_ports::PlayerCreateError::UsernameTaken)); + }); + + // Create the request DTO and trigger route logic + let new_player_info = dto::PlayerCreateRequest { + full_name: "John Smith".to_owned(), + username: "jsmith22".to_owned(), + }; + let error_response = create_player(new_player_info, &mut ext_cxn, &player_service) + .await + .expect_err("Should have received an unsuccessful result from the endpoint"); + + // Now we need to convert error_response into something we can assert against. + // We know it's supposed to return a dto::BasicError, so we'll first need to convert + // the ErrorResponse into a response, then test the status code and deserialize the body. + let (parts, resp_body) = error_response.into_response().into_parts(); + + assert_eq!(StatusCode::CONFLICT, parts.status); + + let body: dto::BasicError = deserialize_body(resp_body).await; + // Since the error_code on the body is supposed to be read by API consumers, we should + // verify the appropriate error code was sent. + assert_eq!("username_in_use", body.error_code); + } + } +} +``` + +
+ +## Writing Integration Tests + +Integration tests are written very similarly to API Route tests, but they actually test the whole app running against +an active Postgres database. + +### Running and marking integration tests + +Because other systems are involved with integration tests, these tests are disabled by default and can be enabled +via a Cargo feature flag called "integration_test". To run both the unit and integration tests, you'll need to start +the database and run cargo tests, enabling the integration_test feature: + +1. `docker-compose up -d` +2. `cargo test --features integration_test` + +Integration tests go under the `integration_test` module. In that module, integration tests can be defined like normal +tests, but with a `cfg_attr` annotation to exclude them from a normal `cargo test` run without the integration_test feature: + +```rust +// in integration_test/player_api.rs + +#[tokio::test] +// This cfg_attr annotation excludes the test unless the integration_test feature is enabled +#[cfg_attr(not(feature = "integration_test"), ignore)] +async fn sample_test() { + // ...test logic +} +``` + +### Implementing the integration test + +The `integration_test::test_util` module defines a utility function, `prepare_application()`, which prepares an Axum +application and a standalone schema for the active unit test, which is derived from the default schema by turning it +into a template and copying it. Using these utilities, you can inject test data into the database and attach necessary +routes to the Axum app to perform the integration test. It also provides an active database connection if you wish to +inject test data during the test. + +You can create requests to the Axum application using `axum::http::Request::builder()`. The +`deserialize_body()` helper from `api::test_util` can be used to read API responses just like API tests, and +the same module provides a `dto_to_body()` helper to help pass DTOs into the request builder for requests. + +With all that being said, here's how we can write a happy path integration test for the [player create endpoint](./architecture_layers.md#the-router-function): + +
+Code example for implementing an integration test + +```rust +// in integration_test/player_api.rs + +#[tokio::test] +#[cfg_attr(not(feature = "integration_test"), ignore)] +async fn can_create_player() { + // First off, let's use our router function from the API module to attach the player routes to a test router + let router = Router::new().nest("/players", api::player::player_routes()); + // Next, let's use the integration test utilities to scaffold the app and a database connection. + // We won't need the database connection here, so we can just ignore it. Preparing the application + // starts us with a fresh schema copied from the "postgres" schema so tests won't interfere with one another. + let (mut app, _) = test_util::prepare_application(router).await; + + // Now, let's create a request (assuming we already conditionally cause the DTO to implement serde::Serialize during tests) + let request = Request::builder() + .method(Method::POST) + .uri("/players") + .header(header::CONTENT_TYPE, "application/json") + .body(dto_to_body( + &dto::PlayerCreateRequest { + full_name: "John Smith".to_owned(), + username: "jsmith22".to_owned(), + }, + )).unwrap(); + + // With the request in hand, we can pass it to the Axum app and get a response + let response = app.call(request).await.unwrap(); + + // Now that we have the response we can verify the received status code and check the received response DTO. + let (res_parts, res_body) = response.into_parts(); + + assert_eq!(StatusCode::CREATED, res_parts.status); + + // There's no guaranteed ID, so we can verify the response by just checking that the new player ID is not 0. + let parsed_body: dto::PlayerCreateResponse = deserialize_body(res_body).await; + assert!(parsed_body.new_player_id > 0); +} +``` + +
\ No newline at end of file diff --git a/practices/development/examples/rust-microservice-template/docker-compose.yml b/practices/development/examples/rust-microservice-template/docker-compose.yml new file mode 100644 index 0000000..5cb55db --- /dev/null +++ b/practices/development/examples/rust-microservice-template/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.0" + +services: + postgres-db: + image: "postgres:14-alpine" + environment: + POSTGRES_PASSWORD: sample123 + ports: + - "5432:5432" + volumes: + - "./postgres-scripts:/docker-entrypoint-initdb.d" \ No newline at end of file diff --git a/practices/development/examples/rust-microservice-template/postgres-scripts/postgres-setup.sql b/practices/development/examples/rust-microservice-template/postgres-scripts/postgres-setup.sql new file mode 100644 index 0000000..95dedc4 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/postgres-scripts/postgres-setup.sql @@ -0,0 +1,13 @@ +create table todo_user ( + id serial primary key not null, + first_name varchar(128) not null, + last_name varchar(128) not null +); + +create table todo_item ( + id serial primary key not null, + user_id integer not null, + item_desc text not null, + + constraint todo_item_user_id_fk foreign key(user_id) references todo_user(id) +); diff --git a/practices/development/examples/rust-microservice-template/src/api/mod.rs b/practices/development/examples/rust-microservice-template/src/api/mod.rs new file mode 100644 index 0000000..a71ba87 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/api/mod.rs @@ -0,0 +1,6 @@ +pub mod swagger_main; +pub mod todo; +pub mod user; + +#[cfg(test)] +pub mod test_util; diff --git a/practices/development/examples/rust-microservice-template/src/api/swagger_main.rs b/practices/development/examples/rust-microservice-template/src/api/swagger_main.rs new file mode 100644 index 0000000..b686bdd --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/api/swagger_main.rs @@ -0,0 +1,22 @@ +use crate::dto; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +#[derive(OpenApi)] +#[openapi(info( + title = "Rust Todo API", + description = "A sample to-do list API written in Rust" +))] +struct TodoApi; + +/// Constructs the route on the API that renders the swagger UI and returns the OpenAPI schema. +/// Merges in OpenAPI definitions from other locations in the app, such as the [dto] package +/// and submodules of [api][crate::api] +pub fn build_documentation() -> SwaggerUi { + let mut api_docs = TodoApi::openapi(); + api_docs.merge(dto::OpenApiSchemas::openapi()); + api_docs.merge(super::user::UsersApi::openapi()); + api_docs.merge(super::todo::TaskApi::openapi()); + + SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api_docs) +} diff --git a/practices/development/examples/rust-microservice-template/src/api/test_util.rs b/practices/development/examples/rust-microservice-template/src/api/test_util.rs new file mode 100644 index 0000000..06cde31 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/api/test_util.rs @@ -0,0 +1,23 @@ +use axum::body; +use serde::de::DeserializeOwned; +use serde::Serialize; + +/// Used in tests to both extract the raw bytes from the HTTP response body and then deserialize them into the +/// requested type. Will panic and fail the test if either step fails somehow. +pub async fn deserialize_body(response_body: body::Body) -> T { + let bytes = body::to_bytes(response_body, usize::MAX) + .await + .expect("Could not read data from response body!"); + + serde_json::from_slice(&bytes).unwrap_or_else(|err| { + panic!( + "Could not parse body content into data structure! Error: {}, Received body: {:?}", + err, bytes + ) + }) +} + +/// Used in tests to convert a request DTO into an Axum body for a request +pub fn dto_to_body(request_body: &T) -> body::Body { + body::Body::from(serde_json::to_string(request_body).expect("Could not serialize request body")) +} diff --git a/practices/development/examples/rust-microservice-template/src/api/todo.rs b/practices/development/examples/rust-microservice-template/src/api/todo.rs new file mode 100644 index 0000000..7450d14 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/api/todo.rs @@ -0,0 +1,260 @@ +use crate::external_connections::ExternalConnectivity; +use crate::routing_utils::{GenericErrorResponse, Json, ValidationErrorResponse}; +use crate::{domain, dto, persistence, AppState, SharedData}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{ErrorResponse, IntoResponse, Response}; +use axum::routing::patch; +use axum::Router; +use log::{error, info}; +use std::sync::Arc; +use utoipa::OpenApi; +use validator::Validate; + +#[derive(OpenApi)] +#[openapi(paths(update_task, delete_task,))] +/// Defines the OpenAPI documentation for the tasks API +pub struct TaskApi; +/// Constant used to group task endpoints in OpenAPI documentation +pub const TASK_API_GROUP: &str = "Tasks"; + +/// Creates a router for endpoints under the "/tasks" group of APIs +pub fn task_routes() -> Router> { + Router::new().route( + "/:task_id", + patch( + |State(app_state): AppState, + Path(task_id): Path, + Json(update): Json| async move { + let mut ext_cxn = app_state.ext_cxn.clone(); + let task_service = domain::todo::TaskService; + + update_task(task_id, update, &mut ext_cxn, &task_service).await + }, + ) + .delete( + |State(app_state): AppState, Path(task_id): Path| async move { + let mut ext_cxn = app_state.ext_cxn.clone(); + let task_service = domain::todo::TaskService; + + delete_task(task_id, &mut ext_cxn, &task_service).await + }, + ), + ) +} + +/// Updates the content of a task +#[utoipa::path( + patch, + path = "/tasks/{task_id}", + tag = TASK_API_GROUP, + params( + ("task_id" = i32, Path, description = "The ID of the task to update"), + ), + request_body = UpdateTask, + responses( + (status = 200, description = "Task successfully updated"), + (status = 400, response = dto::err_resps::BasicError400Validation), + (status = 500, response = dto::err_resps::BasicError500), + ), +)] +async fn update_task( + task_id: i32, + task_data: dto::UpdateTask, + ext_cxn: &mut impl ExternalConnectivity, + task_service: &impl domain::todo::driving_ports::TaskPort, +) -> Result { + info!("Updating task {task_id}"); + task_data + .validate() + .map_err(ValidationErrorResponse::from)?; + + let domain_update = domain::todo::UpdateTask::from(task_data); + let task_writer = persistence::db_todo_driven_ports::DbTaskWriter; + + let update_result = task_service + .update_task(task_id, &domain_update, &mut *ext_cxn, &task_writer) + .await; + match update_result { + Ok(_) => Ok(StatusCode::OK), + Err(db_err) => { + error!("Update task failure: {db_err}"); + Err(GenericErrorResponse(db_err).into()) + } + } +} + +/// Deletes a task +#[utoipa::path( + delete, + path = "/tasks/{task_id}", + tag = TASK_API_GROUP, + params( + ("task_id" = i32, Path, description = "The ID of the task to delete") + ), + responses( + (status = 200, description = "Task successfully deleted"), + (status = 500, response = dto::err_resps::BasicError500), + ), +)] +async fn delete_task( + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + task_service: &impl domain::todo::driving_ports::TaskPort, +) -> Result { + info!("Deleting task {task_id}"); + let task_write = persistence::db_todo_driven_ports::DbTaskWriter; + + let delete_result = task_service + .delete_task(task_id, &mut *ext_cxn, &task_write) + .await; + match delete_result { + Ok(_) => Ok(StatusCode::OK), + Err(db_err) => { + error!("Failed to delete task: {db_err}"); + Err(GenericErrorResponse(db_err).into_response()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{domain, dto, external_connections}; + use anyhow::anyhow; + use speculoos::prelude::*; + use std::sync::Mutex; + + mod update_task { + use super::*; + use crate::api::test_util::deserialize_body; + + #[tokio::test] + async fn happy_path() { + let mut task_service_raw = domain::todo::test_util::MockTaskService::new(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + task_service_raw + .update_task_result + .set_returned_anyhow(Ok(())); + let task_service = Mutex::new(task_service_raw); + + let update_task_response = update_task( + 2, + dto::UpdateTask { + description: "Something to do".to_owned(), + }, + &mut ext_cxn, + &task_service, + ) + .await; + assert_that!(update_task_response).is_ok_containing(StatusCode::OK); + + let locked_task_service = task_service.lock().expect("task service mutex poisoned"); + assert!(matches!(locked_task_service.update_task_result.calls(), [ + (2, domain::todo::UpdateTask { + description, + }) + ] if description == "Something to do")) + } + + #[tokio::test] + async fn returns_500_on_failed_update() { + let mut task_service_raw = domain::todo::test_util::MockTaskService::new(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + task_service_raw + .update_task_result + .set_returned_anyhow(Err(anyhow!("Something went wrong!"))); + let task_service = Mutex::new(task_service_raw); + + let update_task_response = update_task( + 2, + dto::UpdateTask { + description: "Something to do".to_owned(), + }, + &mut ext_cxn, + &task_service, + ) + .await; + let real_response = update_task_response.into_response(); + + assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, real_response.status()); + + let deserialized_body: dto::BasicError = + deserialize_body(real_response.into_body()).await; + assert_eq!("internal_error", deserialized_body.error_code); + } + + #[tokio::test] + async fn returns_400_on_bad_input() { + let task_service = domain::todo::test_util::MockTaskService::new_locked(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let update_task_response = update_task( + 5, + dto::UpdateTask { + description: String::new(), + }, + &mut ext_cxn, + &task_service, + ) + .await; + let real_response = update_task_response.into_response(); + + assert_eq!(StatusCode::BAD_REQUEST, real_response.status()); + + let deserialized_body: dto::BasicError = + deserialize_body(real_response.into_body()).await; + assert_eq!("invalid_input", deserialized_body.error_code); + } + } + + mod delete_task { + use super::*; + use crate::api::test_util::deserialize_body; + + #[tokio::test] + async fn happy_path() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.delete_task_result.set_returned_anyhow(Ok(())); + }); + + // Verify we got the expected response + let delete_task_result = delete_task(5, &mut ext_cxn, &task_service).await; + let Ok(status) = delete_task_result else { + panic!( + "Didn't receive expected response: {:#?}", + delete_task_result + ); + }; + + assert_eq!(StatusCode::OK, status); + + // Verify the service was called with the right params + let locked_service = task_service.lock().unwrap(); + let calls = locked_service.delete_task_result.calls(); + assert_eq!(1, calls.len()); + assert_eq!(5, calls[0]); + } + + #[tokio::test] + async fn returns_500_when_service_blows_up() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.delete_task_result + .set_returned_anyhow(Err(anyhow!("Whoopsie daisy!"))); + }); + + // Verify we got the expected response + let delete_task_result = delete_task(5, &mut ext_cxn, &task_service).await; + let response = delete_task_result.into_response(); + + assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, response.status()); + + let deserialized_body: dto::BasicError = deserialize_body(response.into_body()).await; + assert_eq!("internal_error", deserialized_body.error_code); + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/api/user.rs b/practices/development/examples/rust-microservice-template/src/api/user.rs new file mode 100644 index 0000000..0317a4d --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/api/user.rs @@ -0,0 +1,757 @@ +use crate::domain::todo::driving_ports::TaskError; +use crate::domain::user::driving_ports::CreateUserError; +use crate::external_connections::ExternalConnectivity; +use crate::routing_utils::{GenericErrorResponse, Json, ValidationErrorResponse}; +use crate::{domain, dto, persistence, AppState, SharedData}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::ErrorResponse; +use axum::routing::get; +use axum::Router; +use log::{error, info}; +use serde::Deserialize; +use std::sync::Arc; +use utoipa::OpenApi; +use validator::Validate; + +#[derive(OpenApi)] +#[openapi(paths( + get_users, + create_user, + get_tasks_for_user, + get_task_for_user, + add_task_for_user, +))] +/// Defines the OpenAPI spec for user endpoints +pub struct UsersApi; + +/// Used to group user endpoints together in the OpenAPI documentation +pub const USER_API_GROUP: &str = "Users"; + +/// Builds a router for all the user routes +pub fn user_routes() -> Router> { + Router::new() + .route( + "/", + get(|State(app_data): AppState| async move { + let user_service = domain::user::UserService; + let mut external_connectivity = app_data.ext_cxn.clone(); + + get_users(&mut external_connectivity, &user_service).await + }) + .post( + |State(app_data): AppState, Json(new_user): Json| async move { + let user_service = domain::user::UserService; + let mut external_connectivity = app_data.ext_cxn.clone(); + + create_user(new_user, &mut external_connectivity, &user_service).await + }, + ), + ) + .route( + "/:user_id/tasks", + get( + |State(app_data): AppState, Path(user_id): Path| async move { + let task_service = domain::todo::TaskService; + let mut external_connectivity = app_data.ext_cxn.clone(); + + get_tasks_for_user(user_id, &mut external_connectivity, &task_service).await + }, + ) + .post( + |State(app_data): AppState, + Path(user_id): Path, + Json(new_task): Json| async move { + let task_service = domain::todo::TaskService; + let mut external_connectivity = app_data.ext_cxn.clone(); + + add_task_for_user(user_id, new_task, &mut external_connectivity, &task_service) + .await + }, + ), + ) + .route( + "/:user_id/tasks/:task_id", + get( + |State(app_data): AppState, Path(path): Path| async move { + let task_service = domain::todo::TaskService; + let mut external_connectivity = app_data.ext_cxn.clone(); + + get_task_for_user(path, &mut external_connectivity, &task_service).await + }, + ), + ) +} + +/// Retrieves a list of all the users in the system. +#[utoipa::path( + get, + path = "/users", + tag = USER_API_GROUP, + responses( + (status = 200, description = "User list successfully retrieved", body = Vec), + (status = 500, response = dto::err_resps::BasicError500) + ), +)] +async fn get_users( + ext_cxn: &mut impl ExternalConnectivity, + user_service: &impl domain::user::driving_ports::UserPort, +) -> Result>, ErrorResponse> { + info!("Requested users"); + let user_reader = persistence::db_user_driven_ports::DbReadUsers; + let users_result = user_service.get_users(&mut *ext_cxn, &user_reader).await; + if users_result.is_err() { + error!( + "Could not retrieve users: {}", + users_result.as_ref().unwrap_err() + ); + } + let response = users_result + .map_err(GenericErrorResponse)? + .into_iter() + .map(dto::TodoUser::from) + .collect::>(); + + Ok(Json(response)) +} + +/// Creates a user. +#[utoipa::path( + post, + path = "/users", + tag = USER_API_GROUP, + request_body = NewUser, + responses( + (status = 201, description = "User successfully created", body = InsertedUser), + (status = 400, response = dto::err_resps::BasicError400Validation), + ( + status = 409, + description = "User with matching data already exists (error code `user_exists`)", + body = BasicError, + example = json!({ + "error_code": "user_exists", + "error_description": "A user with the same information already exists.", + "extra_info": null, + }), + ), + (status = 500, response = dto::err_resps::BasicError500) + ) +)] +async fn create_user( + new_user: dto::NewUser, + ext_cxn: &mut impl ExternalConnectivity, + user_service: &impl domain::user::driving_ports::UserPort, +) -> Result<(StatusCode, Json), ErrorResponse> { + info!("Attempt to create user: {}", new_user); + new_user.validate().map_err(ValidationErrorResponse::from)?; + + let user_detector = persistence::db_user_driven_ports::DbDetectUser; + let user_writer = persistence::db_user_driven_ports::DbWriteUsers; + + let domain_user_create = domain::user::CreateUser { + first_name: new_user.first_name, + last_name: new_user.last_name, + }; + let creation_result = user_service + .create_user( + &domain_user_create, + &mut *ext_cxn, + &user_writer, + &user_detector, + ) + .await; + let user_id = + match creation_result { + Ok(id) => id, + Err(CreateUserError::UserAlreadyExists) => { + return Err(( + StatusCode::CONFLICT, + Json(dto::BasicError { + error_code: "user_exists".to_owned(), + error_description: + "A user already exists in the system with the given information." + .to_owned(), + extra_info: None, + }), + ) + .into()) + } + Err(CreateUserError::PortError(err)) => return Err(GenericErrorResponse(err).into()), + }; + + Ok((StatusCode::CREATED, Json(dto::InsertedUser { id: user_id }))) +} + +/// Handles [TaskError] instances coming from business logic +fn handle_todo_task_err(err: TaskError) -> ErrorResponse { + match err { + TaskError::UserDoesNotExist => ( + StatusCode::NOT_FOUND, + Json(dto::BasicError { + error_code: "no_matching_user".to_owned(), + error_description: "Could not find a user matching the given information." + .to_owned(), + extra_info: None, + }), + ) + .into(), + + TaskError::PortError(err) => { + error!("Encountered a problem fetching a task: {}", err); + GenericErrorResponse(err).into() + } + } +} + +/// Retrieves a set of tasks owned by a user +#[utoipa::path( + get, + path = "/users/{user_id}/tasks", + tag = super::todo::TASK_API_GROUP, + params( + ("user_id" = i32, Path, description = "Which user to look up tasks for") + ), + responses( + (status = 200, description = "Task list successfully retrieved", body = Vec), + ( + status = 404, + description = "The requested user does not exist in the system (error code `no_matching_user`)", + body = BasicError, + example = json!({ + "error_code": "no_matching_user", + "error_description": "No user exists in the system with the given id", + "extra_info": null, + }) + ), + (status = 500, response = dto::err_resps::BasicError500) + ), +)] +async fn get_tasks_for_user( + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + task_service: &impl domain::todo::driving_ports::TaskPort, +) -> Result>, ErrorResponse> { + info!("Get tasks for user {user_id}"); + // let tasks = db::get_tasks_for_user(db_cxn, user_id).await; + let user_detect = persistence::db_user_driven_ports::DbDetectUser; + let task_read = persistence::db_todo_driven_ports::DbTaskReader; + + let tasks_result = task_service + .tasks_for_user(user_id, &mut *ext_cxn, &user_detect, &task_read) + .await; + let tasks: Vec = match tasks_result { + Ok(tasks) => tasks.into_iter().map(dto::TodoTask::from).collect(), + Err(domain_err) => return Err(handle_todo_task_err(domain_err)), + }; + + Ok(Json(tasks)) +} + +/// Captures path variables from the "get task" endpoint +#[derive(Deserialize)] +struct GetTaskPath { + user_id: i32, + task_id: i32, +} + +/// Retrieves a specific task owned by a user +#[utoipa::path( + get, + path = "/users/{user_id}/tasks/{task_id}", + tag = super::todo::TASK_API_GROUP, + params( + ("user_id" = i32, Path, description = "The user ID to retrieve a task from"), + ("task_id" = i32, Path, description = "The task ID to retrieve from the user"), + ), + responses( + (status = 200, description = "Task successfully retrieved", body = TodoTask), + ( + status = 404, + description = "Specified user or task does not exist", + body = BasicError, + examples( + ("No user" = ( + summary = "User does not exist (error code no_matching_user)", + value = json!({ + "error_code": "no_matching_user", + "error_description": "There is no user in the system with the given ID.", + "extra_info": null, + }) + )), + + ("No task" = ( + summary = "Task does not exist (error code no_matching_task)", + value = json!({ + "error_code": "no_matching_task", + "error_description": "The given user does not have a task with the given ID.", + "extra_info": null, + }) + )) + ) + ), + (status = 500, response = dto::err_resps::BasicError500), + ) +)] +async fn get_task_for_user( + path: GetTaskPath, + ext_cxn: &mut impl ExternalConnectivity, + task_service: &impl domain::todo::driving_ports::TaskPort, +) -> Result, ErrorResponse> { + info!("Get task {} for user {}", path.task_id, path.user_id); + + let user_detect = persistence::db_user_driven_ports::DbDetectUser; + let task_read = persistence::db_todo_driven_ports::DbTaskReader; + + let task_result = task_service + .user_task_by_id( + path.user_id, + path.task_id, + &mut *ext_cxn, + &user_detect, + &task_read, + ) + .await; + let task = match task_result { + Ok(Some(tsk)) => tsk, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(dto::BasicError { + error_code: "no_matching_task".to_owned(), + error_description: "The specified task does not exist.".to_owned(), + extra_info: None, + }), + ) + .into()) + } + Err(domain_err) => return Err(handle_todo_task_err(domain_err)), + }; + + Ok(Json(dto::TodoTask::from(task))) +} + +/// Adds a new task for a user +#[utoipa::path( + post, + path = "/users/{user_id}/tasks", + tag = super::todo::TASK_API_GROUP, + params( + ("user_id" = i32, Path, description = "The user to add a task for") + ), + request_body = NewTask, + responses( + (status = 201, description = "Task successfully created", body = InsertedTask), + (status = 400, response = dto::err_resps::BasicError400Validation), + ( + status = 404, + description = "Specified user does not exist (error code `no_matching_user`)", + body = BasicError, + example = json!({ + "error_code": "no_matching_user", + "error_description": "No user in the system matches the given ID.", + "extra_info": null, + }) + ), + (status = 500, response = dto::err_resps::BasicError500), + ), +)] +async fn add_task_for_user( + user_id: i32, + new_task: dto::NewTask, + ext_cxn: &mut impl ExternalConnectivity, + task_service: &impl domain::todo::driving_ports::TaskPort, +) -> Result<(StatusCode, Json), ErrorResponse> { + info!("Adding task for user {user_id}"); + new_task.validate().map_err(ValidationErrorResponse::from)?; + + let user_detect = persistence::db_user_driven_ports::DbDetectUser; + let task_write = persistence::db_todo_driven_ports::DbTaskWriter; + let domain_new_task = domain::todo::NewTask::from(new_task); + + let inserted_task_result = task_service + .create_task_for_user( + user_id, + &domain_new_task, + &mut *ext_cxn, + &user_detect, + &task_write, + ) + .await; + let new_task_id = match inserted_task_result { + Ok(id) => id, + Err(domain_error) => return Err(handle_todo_task_err(domain_error)), + }; + + Ok(( + StatusCode::CREATED, + Json(dto::InsertedTask { id: new_task_id }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::test_util::deserialize_body; + use crate::api::user::get_users; + use crate::{domain, external_connections}; + use anyhow::anyhow; + use axum::response::IntoResponse; + use speculoos::prelude::*; + + mod get_users { + use super::*; + + #[tokio::test] + async fn happy_path() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_port = domain::user::test_util::MockUserService::build_locked(|svc| { + svc.get_users_response.set_returned_anyhow(Ok(vec![ + domain::user::TodoUser { + id: 1, + first_name: "John".to_owned(), + last_name: "Doe".to_owned(), + }, + domain::user::TodoUser { + id: 2, + first_name: "Jane".to_owned(), + last_name: "Doe".to_owned(), + }, + ])); + }); + + let endpoint_result = get_users(&mut ext_cxn, &user_port).await; + assert_that!(endpoint_result) + .is_ok() + .matches(|Json(user_list)| { + matches!(user_list.as_slice(), [ + dto::TodoUser { + id: 1, + first_name: f1, + last_name: l1, + }, + dto::TodoUser { + id: 2, + first_name: f2, + last_name: l2, + } + ] if f1 == "John" && + f2 == "Jane" && + l1 == "Doe" && + l2 == "Doe" + ) + }); + } + + #[tokio::test] + async fn returns_500_when_service_blows_up() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_service = domain::user::test_util::MockUserService::build_locked(|svc| { + // Configure what the service will return + svc.get_users_response + .set_returned_anyhow(Err(anyhow!("Whoopsy daisy"))); + }); + + // Execute endpoint, get response + let response_result = get_users(&mut ext_cxn, &user_service).await; + let (req_parts, response_body) = response_result.into_response().into_parts(); + + // Verify status code + assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, req_parts.status); + + // Extract raw bytes from HTTP body + let deserialized_body: dto::BasicError = deserialize_body(response_body).await; + // Verify error code is correct + assert_eq!("internal_error", deserialized_body.error_code); + } + } + + mod create_user { + use super::*; + + fn create_user_payload() -> dto::NewUser { + dto::NewUser { + first_name: "John".to_owned(), + last_name: "Doe".to_owned(), + } + } + + #[tokio::test] + async fn happy_path() { + let user = create_user_payload(); + + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_service = domain::user::test_util::MockUserService::build_locked(|svc| { + svc.create_user_response.set_returned_result(Ok(10)); + }); + + let create_user_result = create_user(user, &mut ext_cxn, &user_service).await; + let Ok((status, Json(inserted_user))) = create_user_result else { + panic!( + "Could not read response from router: {:#?}", + create_user_result + ); + }; + + assert_eq!(StatusCode::CREATED, status); + assert_eq!(10, inserted_user.id); + } + + #[tokio::test] + async fn responds_409_on_already_existing_user() { + let user = create_user_payload(); + + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_service = domain::user::test_util::MockUserService::build_locked(|svc| { + svc.create_user_response + .set_returned_result(Err(CreateUserError::UserAlreadyExists)); + }); + + let response = create_user(user, &mut ext_cxn, &user_service) + .await + .into_response(); + let (resp_parts, resp_body) = response.into_parts(); + + assert_eq!(StatusCode::CONFLICT, resp_parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(resp_body).await; + assert_eq!("user_exists", deserialized_body.error_code); + } + + #[tokio::test] + async fn responds_500_on_port_error() { + let payload = create_user_payload(); + + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_service = domain::user::test_util::MockUserService::build_locked(|svc| { + svc.create_user_response + .set_returned_result(Err(CreateUserError::PortError(anyhow!( + "Whoopsie daisy" + )))); + }); + + let response = create_user(payload, &mut ext_cxn, &user_service) + .await + .into_response(); + let (resp_parts, resp_body) = response.into_parts(); + + assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, resp_parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(resp_body).await; + assert_eq!("internal_error", deserialized_body.error_code); + } + } + + mod handle_todo_task_err { + use super::*; + + #[tokio::test] + async fn converts_missing_user_to_not_found() { + let produced_response = + Err::<(), _>(handle_todo_task_err(TaskError::UserDoesNotExist)).into_response(); + let (res_parts, res_body) = produced_response.into_parts(); + + assert_eq!(StatusCode::NOT_FOUND, res_parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(res_body).await; + assert_eq!("no_matching_user", deserialized_body.error_code); + } + + #[tokio::test] + async fn converts_port_error_to_500() { + let produced_response = Err::<(), _>(handle_todo_task_err(TaskError::PortError( + anyhow!("Whoopsie daisy"), + ))) + .into_response(); + let (res_parts, res_body) = produced_response.into_parts(); + + assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, res_parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(res_body).await; + assert_eq!("internal_error", deserialized_body.error_code); + } + } + + mod get_tasks_for_user { + use super::*; + + #[tokio::test] + async fn happy_path() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.tasks_for_user_result.set_returned_result(Ok(vec![ + domain::todo::TodoTask { + id: 3, + owner_user_id: 2, + item_desc: "Something to do".to_owned(), + }, + domain::todo::TodoTask { + id: 10, + owner_user_id: 2, + item_desc: "Another thing to do".to_owned(), + }, + ])); + }); + + let Json(tasks) = get_tasks_for_user(2, &mut ext_cxn, &task_service) + .await + .unwrap_or_else(|err| { + panic!("Didn't get the expected response! Error: {:#?}", err); + }); + + assert!(matches!(tasks.as_slice(), [ + dto::TodoTask{ + id: 3, + description: d1, + }, + dto::TodoTask { + id: 10, + description: d2, + } + ] if d1 == "Something to do" && + d2 == "Another thing to do" + )) + } + + #[tokio::test] + async fn returns_404_on_user_not_found() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.tasks_for_user_result + .set_returned_result(Err(TaskError::UserDoesNotExist)); + }); + + let response = get_tasks_for_user(2, &mut ext_cxn, &task_service) + .await + .into_response(); + let (parts, body) = response.into_parts(); + + assert_eq!(StatusCode::NOT_FOUND, parts.status); + + let body: dto::BasicError = deserialize_body(body).await; + assert_eq!("no_matching_user", body.error_code); + } + } + + mod get_task_for_user { + use super::*; + + fn path_variables() -> GetTaskPath { + GetTaskPath { + user_id: 2, + task_id: 10, + } + } + + #[tokio::test] + async fn happy_path() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let path_vars = path_variables(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.user_task_by_id_result + .set_returned_result(Ok(Some(domain::todo::TodoTask { + id: path_vars.task_id, + owner_user_id: path_vars.user_id, + item_desc: "Something to do".to_owned(), + }))); + }); + + let Json(task) = get_task_for_user(path_vars, &mut ext_cxn, &task_service) + .await + .unwrap_or_else(|err| { + panic!("Didn't get expected response, instead got this: {:#?}", err); + }); + + assert!(matches!(task, + dto::TodoTask { + id: 10, + description + } if description == "Something to do", + )); + } + + #[tokio::test] + async fn gives_appropriate_404_on_no_user() { + let path_vars = path_variables(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.user_task_by_id_result + .set_returned_result(Err(TaskError::UserDoesNotExist)); + }); + + let response = get_task_for_user(path_vars, &mut ext_cxn, &task_service) + .await + .into_response(); + let (parts, body) = response.into_parts(); + + assert_eq!(StatusCode::NOT_FOUND, parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(body).await; + assert_eq!("no_matching_user", deserialized_body.error_code); + } + + #[tokio::test] + async fn gives_appropriate_404_on_no_task() { + let path_vars = path_variables(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.user_task_by_id_result.set_returned_result(Ok(None)); + }); + + let response = get_task_for_user(path_vars, &mut ext_cxn, &task_service) + .await + .into_response(); + let (parts, body) = response.into_parts(); + + assert_eq!(StatusCode::NOT_FOUND, parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(body).await; + assert_eq!("no_matching_task", deserialized_body.error_code); + } + } + + mod add_task_for_user { + use super::*; + + fn new_task_payload() -> dto::NewTask { + dto::NewTask { + item_desc: "Something to do".to_owned(), + } + } + #[tokio::test] + async fn happy_path() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.create_task_for_user_result.set_returned_result(Ok(10)); + }); + + let (status, Json(new_task_info)) = + add_task_for_user(3, new_task_payload(), &mut ext_cxn, &task_service) + .await + .unwrap_or_else(|err| { + panic!("Didn't get a successful response: {:#?}", err); + }); + + assert_eq!(StatusCode::CREATED, status); + assert_eq!(10, new_task_info.id); + } + + #[tokio::test] + async fn gives_appropriate_404_on_no_user() { + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task_service = domain::todo::test_util::MockTaskService::build_locked(|svc| { + svc.create_task_for_user_result + .set_returned_result(Err(TaskError::UserDoesNotExist)); + }); + + let response = add_task_for_user(10, new_task_payload(), &mut ext_cxn, &task_service) + .await + .into_response(); + let (parts, body) = response.into_parts(); + + assert_eq!(StatusCode::NOT_FOUND, parts.status); + + let deserialized_body: dto::BasicError = deserialize_body(body).await; + assert_eq!("no_matching_user", deserialized_body.error_code); + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/app_env.rs b/practices/development/examples/rust-microservice-template/src/app_env.rs new file mode 100644 index 0000000..274c5be --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/app_env.rs @@ -0,0 +1,10 @@ +/// URL for accessing the PostrgeSQL database (should contain a schema name in the path) +pub const DB_URL: &str = "DATABASE_URL"; +/// Log level configuration for the application. For formatting info, see [env_logger's documentation](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) +pub const LOG_LEVEL: &str = "LOG_LEVEL"; + +#[cfg(test)] +pub mod test { + /// URL for accessing the PostgreSQL database during integration tests (should not contain a schema name in the path) + pub const TEST_DB_URL: &str = "TEST_DB_URL"; +} diff --git a/practices/development/examples/rust-microservice-template/src/db.rs b/practices/development/examples/rust-microservice-template/src/db.rs new file mode 100644 index 0000000..e6a9443 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/db.rs @@ -0,0 +1,15 @@ +use std::time::Duration; + +use sqlx::postgres::PgPoolOptions; + +/// Connects to a PostgreSQL database with the given `db_url`, returning a connection pool for accessing it +pub async fn connect_sqlx(db_url: &str) -> sqlx::PgPool { + PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(2)) + .idle_timeout(Duration::from_secs(30)) + .max_connections(32) + .min_connections(4) + .connect(db_url) + .await + .expect("Could not connect to the database") +} diff --git a/practices/development/examples/rust-microservice-template/src/domain/mod.rs b/practices/development/examples/rust-microservice-template/src/domain/mod.rs new file mode 100644 index 0000000..563f732 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/domain/mod.rs @@ -0,0 +1,7 @@ +use thiserror::Error; + +pub mod todo; +pub mod user; + +#[cfg(test)] +mod test_util; diff --git a/practices/development/examples/rust-microservice-template/src/domain/test_util.rs b/practices/development/examples/rust-microservice-template/src/domain/test_util.rs new file mode 100644 index 0000000..9e3a182 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/domain/test_util.rs @@ -0,0 +1,151 @@ +use anyhow::anyhow; + +/// Connectivity represents the "connected" state of a mocked driven port and provides +/// common behavior for returning an error if the port is configured to be in a disconnected state. +pub enum Connectivity { + Connected, + Disconnected, +} + +impl Connectivity { + /// Return an error if connectivity is in a "disconnected" state + pub fn blow_up_if_disconnected(&self) -> Result<(), anyhow::Error> { + match self { + Self::Connected => Ok(()), + Self::Disconnected => Err(anyhow!("could not connect to service!")), + } + } +} + +/// FakeImplementation is a quick drop-in property that helps mock a function and capture +/// arguments the function is called with. It's useful for mocking async functions since +/// popular rust mocking tools don't work well with async functions on traits. +/// +/// * [Args] represents the arguments passed to the function that should be captured on a call +/// * [Ret] represents the type of the function's return value +/// +/// # Example +/// +/// This data structure can be used in mock trait implementations like so: +/// +/// ``` +/// use domain::test_util::FakeImplementation; +/// use std::sync::Mutex; +/// +/// trait MyAsyncTrait { +/// async fn some_cool_function(&self, var_1: i32, var_2: i32) -> String; +/// } +/// +/// struct FakeTraitImplementation { +/// // The generics are (i32, i32) for captured arguments and String for the return value +/// some_cool_function_result: FakeImplementation<(i32, i32), String>; +/// } +/// +/// impl MyAsyncTrait for Mutex { +/// async fn some_cool_function(&self, var_1: i32, var_2: i32) -> String { +/// // We have to lock "self" so we can mutate the interior via an immutable reference +/// let mut self_locked = self.lock().unwrap(); +/// +/// // Capture the arguments of this invocation +/// self_locked.save_arguments((var_1, var_2)); +/// +/// // Return the configured return value +/// self_locked.return_value() +/// } +/// } +/// ``` +/// +pub struct FakeImplementation { + saved_arguments: Vec, + return_value: Option, +} + +impl FakeImplementation { + /// Creates a new FakeImplementation + pub fn new() -> FakeImplementation { + FakeImplementation { + saved_arguments: Vec::new(), + return_value: None, + } + } +} + +impl FakeImplementation { + /// Saves arguments from a single invocation of the FakeImplementation + pub fn save_arguments(&mut self, arguments: Args) { + self.saved_arguments.push(arguments) + } + + /// Returns the list of arguments passed on every call to this FakeImplementation + pub fn calls(&self) -> &[Args] { + self.saved_arguments.as_slice() + } +} + +#[allow(dead_code)] +impl FakeImplementation +where + Ret: Clone, +{ + /// Set the value that should be returned when this FakeImplementation is invoked + pub fn set_return_value(&mut self, return_value: Ret) { + self.return_value = Some(return_value) + } + + /// Retrieve the configured return value for this FakeImplementation + pub fn return_value(&self) -> Ret { + match self.return_value { + None => panic!("Tried to return from a function where the return value wasn't set!"), + Some(ref ret_val) => ret_val.clone(), + } + } +} + +impl FakeImplementation> +where + Success: Clone, + Fail: Clone, +{ + /// Set the result that should be returned when this FakeImplementation is invoked. + /// [Result] does not implement [Clone], so this function can be used when the contained values + /// can be cloned. + pub fn set_returned_result(&mut self, return_value: Result) { + match return_value { + Ok(ok_result) => self.return_value = Some(Ok(ok_result)), + Err(err) => self.return_value = Some(Err(err)), + } + } + + /// Retrieve the result that should be returned when this FakeImplementation is invoked (for [Result]s) + pub fn return_value_result(&self) -> Result { + match self.return_value { + Some(Ok(ref ok_result)) => Ok(ok_result.clone()), + Some(Err(ref err)) => Err(err.clone()), + None => panic!("Tried to return from a function where the return value wasn't set!"), + } + } +} + +impl FakeImplementation> +where + Success: Clone, +{ + /// Set the result that should be returned when this FakeImplementation is invoked. + /// This is used in a special case for [anyhow::Result], since [anyhow::Error] does not + /// implement [Clone]. + pub fn set_returned_anyhow(&mut self, return_value: anyhow::Result) { + match return_value { + Ok(ok_result) => self.return_value = Some(Ok(ok_result)), + Err(err) => self.return_value = Some(Err(anyhow!(format!("{}", err)))), + } + } + + /// Retrieve the result that should be returned when this FakeImplementation is invoked (for [anyhow::Result]s) + pub fn return_value_anyhow(&self) -> anyhow::Result { + match self.return_value { + None => panic!("Tried to return from a function where the value wasn't set!"), + Some(Ok(ref ok_result)) => Ok(ok_result.clone()), + Some(Err(ref err)) => Err(anyhow!(format!("{}", err))), + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/domain/todo.rs b/practices/development/examples/rust-microservice-template/src/domain/todo.rs new file mode 100644 index 0000000..81f8820 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/domain/todo.rs @@ -0,0 +1,880 @@ +use crate::domain; +use crate::domain::todo::driven_ports::{TaskReader, TaskWriter}; +use crate::domain::todo::driving_ports::TaskError; +use crate::external_connections::ExternalConnectivity; +use anyhow::{Context, Error}; +use log::error; + +#[derive(PartialEq, Eq, Debug)] +#[cfg_attr(test, derive(Clone))] +/// A task available for a user +pub struct TodoTask { + pub id: i32, + pub owner_user_id: i32, + pub item_desc: String, +} + +#[cfg_attr(test, derive(Clone))] +/// Contains information necessary to create a new task +pub struct NewTask { + pub description: String, +} + +#[cfg_attr(test, derive(Clone))] +/// Contains information which is allowed to be updated on a task +pub struct UpdateTask { + pub description: String, +} + +/// Contains the set of driven ports invoked by the business logic +pub mod driven_ports { + use super::*; + use crate::external_connections::ExternalConnectivity; + + /// An external system that can read a user's tasks + pub trait TaskReader { + /// Retrieve the set of tasks for a user + async fn tasks_for_user( + &self, + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error>; + + /// Retrieve a single task belonging to a user + async fn user_task_by_id( + &self, + user_id: i32, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error>; + } + + /// An external system that can edit the set of tasks for a user + pub trait TaskWriter { + /// Create a new task for a user + async fn create_task_for_user( + &self, + user_id: i32, + new_task: &NewTask, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result; + + /// Delete a task by its ID + async fn delete_task( + &self, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result<(), anyhow::Error>; + + /// Update the content of an existing task + async fn update_task( + &self, + task_id: i32, + update: &UpdateTask, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result<(), anyhow::Error>; + } +} + +/// Contains the driving port interface that exposes business logic entrypoints to driving adapters +/// such as HTTP routers +pub mod driving_ports { + use super::*; + use crate::domain; + use crate::external_connections::ExternalConnectivity; + use thiserror::Error; + + #[derive(Debug, Error)] + /// A set of things that can go wrong while dealing with tasks + pub enum TaskError { + #[error("The specified user did not exist.")] + UserDoesNotExist, + #[error(transparent)] + PortError(#[from] anyhow::Error), + } + + impl From for TaskError { + fn from(value: domain::user::UserExistsErr) -> Self { + match value { + domain::user::UserExistsErr::UserDoesNotExist(user_id) => { + error!("User {} didn't exist when fetching tasks.", user_id); + TaskError::UserDoesNotExist + } + domain::user::UserExistsErr::PortError(err) => { + TaskError::from(err.context("Fetching user tasks")) + } + } + } + } + + #[cfg(test)] + #[allow(clippy::items_after_test_module)] + mod task_error_clone { + use crate::domain::todo::driving_ports::TaskError; + use anyhow::anyhow; + + // Implements clone for TaskError so it can be used in mocks during API tests + impl Clone for TaskError { + fn clone(&self) -> Self { + match self { + Self::UserDoesNotExist => Self::UserDoesNotExist, + Self::PortError(err) => Self::PortError(anyhow!(format!("{}", err))), + } + } + } + } + + /// The driving port, or the set of business logic functions exposed to driving adapters + pub trait TaskPort { + /// Retrieve the set of tasks belonging to a user + async fn tasks_for_user( + &self, + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + u_detect: &impl domain::user::driven_ports::DetectUser, + task_read: &impl driven_ports::TaskReader, + ) -> Result, TaskError>; + + /// Retrieve a single task belonging to a user + async fn user_task_by_id( + &self, + user_id: i32, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + u_detect: &impl domain::user::driven_ports::DetectUser, + task_read: &impl driven_ports::TaskReader, + ) -> Result, TaskError>; + + /// Create a new task for a user + async fn create_task_for_user( + &self, + user_id: i32, + task: &NewTask, + ext_cxn: &mut impl ExternalConnectivity, + u_detect: &impl domain::user::driven_ports::DetectUser, + task_write: &impl driven_ports::TaskWriter, + ) -> Result; + + /// Delete a task by its ID + async fn delete_task( + &self, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + task_write: &impl driven_ports::TaskWriter, + ) -> Result<(), anyhow::Error>; + + /// Update the content of an existing task + async fn update_task( + &self, + task_id: i32, + update: &UpdateTask, + ext_cxn: &mut impl ExternalConnectivity, + task_write: &impl driven_ports::TaskWriter, + ) -> Result<(), anyhow::Error>; + } +} + +/// TaskService implements the driving port for tasks so driving adapters can access task business +/// logic +pub struct TaskService; + +impl driving_ports::TaskPort for TaskService { + async fn tasks_for_user( + &self, + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + u_detect: &impl domain::user::driven_ports::DetectUser, + task_read: &impl TaskReader, + ) -> Result, TaskError> { + domain::user::verify_user_exists(user_id, &mut *ext_cxn, u_detect).await?; + let tasks_result = task_read.tasks_for_user(user_id, &mut *ext_cxn).await?; + + Ok(tasks_result) + } + + async fn user_task_by_id( + &self, + user_id: i32, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + u_detect: &impl domain::user::driven_ports::DetectUser, + task_read: &impl TaskReader, + ) -> Result, TaskError> { + domain::user::verify_user_exists(user_id, &mut *ext_cxn, u_detect).await?; + let tasks_result = task_read + .user_task_by_id(user_id, task_id, &mut *ext_cxn) + .await?; + + Ok(tasks_result) + } + + async fn create_task_for_user( + &self, + user_id: i32, + task: &NewTask, + ext_cxn: &mut impl ExternalConnectivity, + u_detect: &impl domain::user::driven_ports::DetectUser, + task_write: &impl TaskWriter, + ) -> Result { + domain::user::verify_user_exists(user_id, &mut *ext_cxn, u_detect).await?; + let created_task_id = task_write + .create_task_for_user(user_id, task, &mut *ext_cxn) + .await?; + Ok(created_task_id) + } + + async fn delete_task( + &self, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + task_write: &impl TaskWriter, + ) -> Result<(), Error> { + task_write + .delete_task(task_id, &mut *ext_cxn) + .await + .context("deleting a task")?; + Ok(()) + } + + async fn update_task( + &self, + task_id: i32, + update: &UpdateTask, + ext_cxn: &mut impl ExternalConnectivity, + task_write: &impl TaskWriter, + ) -> Result<(), Error> { + task_write + .update_task(task_id, update, &mut *ext_cxn) + .await + .context("updating a task")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::test_util::*; + use super::*; + use crate::domain::todo::driving_ports::TaskPort; + use crate::domain::user::test_util::InMemoryUserPersistence; + use crate::domain::user::CreateUser; + use crate::external_connections; + use speculoos::prelude::*; + use std::sync::RwLock; + + mod tasks_for_user { + use super::*; + + #[tokio::test] + async fn happy_path() { + let user_persist = RwLock::new(InMemoryUserPersistence::new_with_users(&[ + domain::user::test_util::user_create_default(), + domain::user::test_util::user_create_default(), + ])); + let task_persist = RwLock::new(InMemoryUserTaskPersistence::new_with_tasks(&[ + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "Something to do".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 2, + task: NewTask { + description: "Another thing to do".to_owned(), + }, + }, + ])); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let fetched_tasks = TaskService {} + .tasks_for_user(1, &mut ext_cxn, &user_persist, &task_persist) + .await; + assert_that!(fetched_tasks).is_ok().matches(|tasks| { + matches!(tasks.as_slice(), [ + TodoTask { + id: 1, + owner_user_id: 1, + item_desc, + } + ] if item_desc == "Something to do") + }); + } + + #[tokio::test] + async fn returns_error_on_nonexistent_user() { + let user_persist = InMemoryUserPersistence::new_locked(); + let task_persist = InMemoryUserTaskPersistence::new_locked(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let fetched_task_result = TaskService {} + .tasks_for_user(1, &mut ext_cxn, &user_persist, &task_persist) + .await; + let Err(TaskError::UserDoesNotExist) = fetched_task_result else { + panic!( + "Got an unexpected result from task lookup: {:#?}", + fetched_task_result + ); + }; + } + } + + mod user_task_by_id { + use super::*; + + #[tokio::test] + async fn happy_path() { + let user_persist = RwLock::new(InMemoryUserPersistence::new_with_users(&[ + domain::user::test_util::user_create_default(), + domain::user::test_util::user_create_default(), + ])); + let task_persist = RwLock::new(InMemoryUserTaskPersistence::new_with_tasks(&[ + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "abcde".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "fghijk".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 2, + task: NewTask { + description: "lmnop".to_owned(), + }, + }, + ])); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let task_fetch_result = TaskService {} + .user_task_by_id(1, 2, &mut ext_cxn, &user_persist, &task_persist) + .await; + assert_that!(task_fetch_result) + .is_ok() + .is_some() + .matches(|task| { + matches!(task, TodoTask { + id: 2, + owner_user_id: 1, + item_desc + } if item_desc == "fghijk") + }); + } + + #[tokio::test] + async fn happy_path_not_found() { + let user_persist = RwLock::new(InMemoryUserPersistence::new_with_users(&[ + domain::user::test_util::user_create_default(), + domain::user::test_util::user_create_default(), + ])); + let task_persist = RwLock::new(InMemoryUserTaskPersistence::new_with_tasks(&[ + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "abcde".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "fghijk".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 2, + task: NewTask { + description: "lmnop".to_owned(), + }, + }, + ])); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let task_fetch_result = TaskService {} + .user_task_by_id(1, 3, &mut ext_cxn, &user_persist, &task_persist) + .await; + assert_that!(task_fetch_result).is_ok().is_none(); + } + + #[tokio::test] + async fn fails_if_user_doesnt_exist() { + let user_persist = InMemoryUserPersistence::new_locked(); + let task_persist = InMemoryUserTaskPersistence::new_locked(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let task_fetch_result = TaskService {} + .user_task_by_id(1, 5, &mut ext_cxn, &user_persist, &task_persist) + .await; + let Err(TaskError::UserDoesNotExist) = task_fetch_result else { + panic!( + "Didn't get expected error for user not existing: {:#?}", + task_fetch_result + ); + }; + } + } + + mod create_task_for_user { + use super::*; + + #[tokio::test] + async fn happy_path() { + let task_persist = InMemoryUserTaskPersistence::new_locked(); + let user_persist = + RwLock::new(InMemoryUserPersistence::new_with_users(&[CreateUser { + first_name: "John".to_owned(), + last_name: "Doe".to_owned(), + }])); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let task = NewTask { + description: "Something to do".to_owned(), + }; + let service = TaskService {}; + + let create_result = service + .create_task_for_user(1, &task, &mut ext_cxn, &user_persist, &task_persist) + .await; + assert_that!(create_result).is_ok_containing(1); + } + + #[tokio::test] + async fn does_not_allow_tasks_for_nonexistent_user() { + let writer = InMemoryUserTaskPersistence::new_locked(); + let user_detector = InMemoryUserPersistence::new_locked(); + let task = NewTask { + description: String::new(), + }; + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let service = TaskService {}; + + let create_result = service + .create_task_for_user(1, &task, &mut ext_cxn, &user_detector, &writer) + .await; + let Err(TaskError::UserDoesNotExist) = create_result else { + panic!("Did not get expected error, instead got this: {create_result:#?}"); + }; + } + } + + mod delete_task { + use super::*; + use crate::domain::test_util::Connectivity; + + #[tokio::test] + async fn happy_path() { + let writer = RwLock::new(InMemoryUserTaskPersistence::new_with_tasks(&[ + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "abcde".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "fghij".to_owned(), + }, + }, + ])); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let delete_result = TaskService {}.delete_task(2, &mut ext_cxn, &writer).await; + assert_that!(delete_result).is_ok(); + + let locked_writer = writer.read().expect("task writer rw lock poisoned"); + assert!(matches!(locked_writer.tasks.as_slice(), [ + TodoTask { + id: 1, + owner_user_id: 1, + item_desc, + } + ] if item_desc == "abcde")); + } + + #[tokio::test] + async fn happy_path_task_doesnt_exist() { + let writer = InMemoryUserTaskPersistence::new_locked(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let delete_result = TaskService {}.delete_task(5, &mut ext_cxn, &writer).await; + assert_that!(delete_result).is_ok(); + } + + #[tokio::test] + async fn returns_port_err() { + let writer = InMemoryUserTaskPersistence::new_locked(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + { + let mut locked_writer = writer.write().expect("writer rw lock poisoned"); + locked_writer.connected = Connectivity::Disconnected; + } + + let delete_result = TaskService {}.delete_task(1, &mut ext_cxn, &writer).await; + assert_that!(delete_result).is_err(); + } + } + + mod update_task { + use super::*; + use crate::domain::test_util::Connectivity; + + #[tokio::test] + async fn happy_path() { + let writer = RwLock::new(InMemoryUserTaskPersistence::new_with_tasks(&[ + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "abcde".to_owned(), + }, + }, + NewTaskWithOwner { + owner: 1, + task: NewTask { + description: "fghij".to_owned(), + }, + }, + ])); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let update_result = TaskService {} + .update_task( + 2, + &UpdateTask { + description: "Something to do".to_owned(), + }, + &mut ext_cxn, + &writer, + ) + .await; + + assert_that!(update_result).is_ok(); + + let locked_writer = writer.read().expect("rw lock poisoned"); + assert_eq!("Something to do", locked_writer.tasks[1].item_desc); + } + + #[tokio::test] + async fn happy_path_task_doesnt_exist() { + let writer = InMemoryUserTaskPersistence::new_locked(); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let update_result = TaskService {} + .update_task( + 5, + &UpdateTask { + description: "Something to do".to_owned(), + }, + &mut ext_cxn, + &writer, + ) + .await; + assert_that!(update_result).is_ok(); + } + + #[tokio::test] + async fn returns_port_err() { + let mut raw_writer = InMemoryUserTaskPersistence::new(); + raw_writer.connected = Connectivity::Disconnected; + let writer = RwLock::new(raw_writer); + let mut ext_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let update_result = TaskService {} + .update_task( + 1, + &UpdateTask { + description: "Something to do".to_owned(), + }, + &mut ext_cxn, + &writer, + ) + .await; + assert_that!(update_result).is_err(); + } + } +} + +#[cfg(test)] +pub mod test_util { + use super::*; + use crate::domain::test_util::{Connectivity, FakeImplementation}; + use crate::domain::user::driven_ports::DetectUser; + use std::sync::{Mutex, RwLock}; + + /// A fake providing task functionality for domain logic tests, as it implements + /// the traits for all task driven ports + pub struct InMemoryUserTaskPersistence { + pub tasks: Vec, + pub connected: Connectivity, + highest_task_id: i32, + } + + /// Represents a task with a specific owner + pub struct NewTaskWithOwner { + pub owner: i32, + pub task: NewTask, + } + + impl InMemoryUserTaskPersistence { + /// Constructor for InMemoryUserTaskPersistence + pub fn new() -> InMemoryUserTaskPersistence { + InMemoryUserTaskPersistence { + tasks: Vec::new(), + connected: Connectivity::Connected, + highest_task_id: 0, + } + } + + /// Constructor for InMemoryUserTaskPersistence which adds a set of already-existing tasks + pub fn new_with_tasks(tasks: &[NewTaskWithOwner]) -> InMemoryUserTaskPersistence { + InMemoryUserTaskPersistence { + tasks: tasks + .iter() + .enumerate() + .map(|(index, task_with_owner)| TodoTask { + id: index as i32 + 1, + owner_user_id: task_with_owner.owner, + item_desc: task_with_owner.task.description.clone(), + }) + .collect(), + connected: Connectivity::Connected, + highest_task_id: tasks.len() as i32, + } + } + + /// Constructor for InMemoryUserTaskPersistence which wraps it in an RwLock right away + /// for use as the set of task driven ports + pub fn new_locked() -> RwLock { + RwLock::new(Self::new()) + } + } + + impl driven_ports::TaskReader for RwLock { + async fn tasks_for_user( + &self, + user_id: i32, + _ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, Error> { + let persistence = self.read().expect("task persist rw lock poisoned"); + persistence.connected.blow_up_if_disconnected()?; + + let matching_tasks: Vec = persistence + .tasks + .iter() + .filter_map(|task| { + if task.owner_user_id == user_id { + Some(task.clone()) + } else { + None + } + }) + .collect(); + + Ok(matching_tasks) + } + + async fn user_task_by_id( + &self, + user_id: i32, + task_id: i32, + _ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, Error> { + let persistence = self.read().expect("task persist rw lock poisoned"); + persistence.connected.blow_up_if_disconnected()?; + + let task = persistence + .tasks + .iter() + .find(|task| task.owner_user_id == user_id && task.id == task_id) + .map(Clone::clone); + + Ok(task) + } + } + + impl driven_ports::TaskWriter for RwLock { + async fn create_task_for_user( + &self, + user_id: i32, + task: &NewTask, + _ext_cxn: &mut impl ExternalConnectivity, + ) -> Result { + let mut persistence = self.write().expect("task persist rw lock poisoned"); + persistence.connected.blow_up_if_disconnected()?; + + persistence.highest_task_id += 1; + let task_id = persistence.highest_task_id; + persistence + .tasks + .push(task_from_create(user_id, task_id, task)); + Ok(task_id) + } + + async fn delete_task( + &self, + task_id: i32, + _ext_cxn: &mut impl ExternalConnectivity, + ) -> Result<(), Error> { + let mut persistence = self.write().expect("task persist rw lock poisoned"); + persistence.connected.blow_up_if_disconnected()?; + + let item_index = persistence + .tasks + .iter() + .enumerate() + .find(|(_, task)| task.id == task_id) + .map(|(idx, _)| idx); + if let Some(idx) = item_index { + persistence.tasks.remove(idx); + } + + Ok(()) + } + + async fn update_task( + &self, + task_id: i32, + update: &UpdateTask, + _ext_cxn: &mut impl ExternalConnectivity, + ) -> Result<(), Error> { + let mut persistence = self.write().expect("task persist rw lock poisoned"); + persistence.connected.blow_up_if_disconnected()?; + + let item_index = persistence + .tasks + .iter() + .enumerate() + .find(|(_, task)| task.id == task_id) + .map(|(idx, _)| idx); + if let Some(idx) = item_index { + persistence.tasks[idx].item_desc = update.description.clone(); + } + + Ok(()) + } + } + + /// Creates a new [TodoTask] from a create payload plus some supplemental information + pub fn task_from_create(user_id: i32, task_id: i32, new_task: &NewTask) -> TodoTask { + TodoTask { + id: task_id, + owner_user_id: user_id, + item_desc: new_task.description.clone(), + } + } + + /// A mock of TaskService for use in API tests + pub struct MockTaskService { + pub tasks_for_user_result: FakeImplementation, TaskError>>, + pub user_task_by_id_result: + FakeImplementation<(i32, i32), Result, TaskError>>, + pub create_task_for_user_result: FakeImplementation<(i32, NewTask), Result>, + pub delete_task_result: FakeImplementation>, + pub update_task_result: FakeImplementation<(i32, UpdateTask), Result<(), anyhow::Error>>, + } + + impl MockTaskService { + /// Constructor for MockTaskService + pub fn new() -> MockTaskService { + MockTaskService { + tasks_for_user_result: FakeImplementation::new(), + user_task_by_id_result: FakeImplementation::new(), + create_task_for_user_result: FakeImplementation::new(), + delete_task_result: FakeImplementation::new(), + update_task_result: FakeImplementation::new(), + } + } + + /// Constructor for MockTaskService which accepts a builder function to configure + /// mock responses, wrapping the resulting mock in a mutex so it is ready for use + /// in API tests + pub fn build_locked(builder: impl FnOnce(&mut Self)) -> Mutex { + let mut new_svc = Self::new(); + builder(&mut new_svc); + + Mutex::new(new_svc) + } + + pub fn new_locked() -> Mutex { + Mutex::new(Self::new()) + } + } + + impl driving_ports::TaskPort for Mutex { + async fn tasks_for_user( + &self, + user_id: i32, + _ext_cxn: &mut impl ExternalConnectivity, + _u_detect: &impl DetectUser, + _task_read: &impl TaskReader, + ) -> Result, TaskError> { + let mut locked_self = self.lock().expect("mock task service mutex poisoned"); + locked_self.tasks_for_user_result.save_arguments(user_id); + + locked_self.tasks_for_user_result.return_value_result() + } + + async fn user_task_by_id( + &self, + user_id: i32, + task_id: i32, + _ext_cxn: &mut impl ExternalConnectivity, + _u_detect: &impl DetectUser, + _task_read: &impl TaskReader, + ) -> Result, TaskError> { + let mut locked_self = self.lock().expect("mock task service mutex poisoned"); + locked_self + .user_task_by_id_result + .save_arguments((user_id, task_id)); + + locked_self.user_task_by_id_result.return_value_result() + } + + async fn create_task_for_user( + &self, + user_id: i32, + task: &NewTask, + _ext_cxn: &mut impl ExternalConnectivity, + _u_detect: &impl DetectUser, + _task_write: &impl TaskWriter, + ) -> Result { + let mut locked_self = self.lock().expect("mock task service mutex poisoned"); + locked_self + .create_task_for_user_result + .save_arguments((user_id, task.clone())); + + locked_self + .create_task_for_user_result + .return_value_result() + } + + async fn delete_task( + &self, + task_id: i32, + _ext_cxn: &mut impl ExternalConnectivity, + _task_write: &impl TaskWriter, + ) -> Result<(), anyhow::Error> { + let mut locked_self = self.lock().expect("mock task service mutex poisoned"); + locked_self.delete_task_result.save_arguments(task_id); + + locked_self.delete_task_result.return_value_anyhow() + } + + async fn update_task( + &self, + task_id: i32, + update: &UpdateTask, + _ext_cxn: &mut impl ExternalConnectivity, + _task_write: &impl TaskWriter, + ) -> Result<(), anyhow::Error> { + let mut locked_self = self.lock().expect("mock task service mutex poisoned"); + locked_self + .update_task_result + .save_arguments((task_id, update.clone())); + + locked_self.update_task_result.return_value_anyhow() + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/domain/user.rs b/practices/development/examples/rust-microservice-template/src/domain/user.rs new file mode 100644 index 0000000..5363c97 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/domain/user.rs @@ -0,0 +1,589 @@ +use crate::domain::user::driving_ports::CreateUserError; +use crate::domain::Error; +use crate::external_connections::ExternalConnectivity; +use anyhow::Context; + +#[derive(PartialEq, Eq, Debug, Default)] +#[cfg_attr(test, derive(Clone))] +/// A user who can own to-do items +pub struct TodoUser { + pub id: i32, + pub first_name: String, + pub last_name: String, +} + +/// The set of driven ports that can be invoked by the business logic +pub mod driven_ports { + use super::*; + use crate::external_connections::ExternalConnectivity; + + /// An external system which can read user data + pub trait UserReader: Sync { + /// Retrieve all users in the system + async fn all( + &self, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error>; + /// Retrieve a specific user in the system + async fn by_id( + &self, + id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error>; + } + + /// An external system which can accept new user data + pub trait UserWriter: Sync { + /// Create a new user + async fn create_user( + &self, + user: &CreateUser, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result; + } + + /// Contains a description of a user's unique personal information + pub struct UserDescription<'names> { + pub first_name: &'names str, + pub last_name: &'names str, + } + + /// An external system which can report the presence of a user with specific criteria + pub trait DetectUser: Sync { + /// Returns true if a user with the given ID already exists + async fn user_exists( + &self, + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result; + + /// Returns true if a user with the given description already exists + async fn user_with_name_exists<'strings>( + &self, + description: UserDescription<'strings>, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result; + } +} + +#[cfg_attr(test, derive(Clone))] +/// Contains information necessary to create a new user +pub struct CreateUser { + pub first_name: String, + pub last_name: String, +} + +/// Contains the set of driving ports for invoking business logic involving users +pub mod driving_ports { + use super::*; + use crate::external_connections::ExternalConnectivity; + + #[derive(Debug, Error)] + /// Defines the set of reasons why a user would fail to be created + pub enum CreateUserError { + #[error("The provided user already exists.")] + UserAlreadyExists, + #[error(transparent)] + PortError(#[from] anyhow::Error), + } + + /// The driving port which exposes business logic involving users to driving adapters + pub trait UserPort { + /// Retrieve the set of users in the system + async fn get_users( + &self, + ext_cxn: &mut impl ExternalConnectivity, + u_reader: &impl driven_ports::UserReader, + ) -> Result, anyhow::Error>; + + /// Create a new user who can be responsible for to-do items + async fn create_user( + &self, + new_user: &CreateUser, + ext_cxn: &mut impl ExternalConnectivity, + u_writer: &impl driven_ports::UserWriter, + u_detect: &impl driven_ports::DetectUser, + ) -> Result; + } + + #[cfg(test)] + mod cue_clone { + use crate::domain::user::driving_ports::CreateUserError; + use anyhow::anyhow; + + // Implements clone for CreateUserInfo in tests so the error type can be used with mocks + impl Clone for CreateUserError { + fn clone(&self) -> Self { + match self { + CreateUserError::UserAlreadyExists => CreateUserError::UserAlreadyExists, + CreateUserError::PortError(anyhow_err) => { + CreateUserError::PortError(anyhow!(format!("{}", anyhow_err))) + } + } + } + } + } +} + +/// Implementation of the driving port which allows driving adapters to access user business logic +pub struct UserService; + +#[derive(Debug, Error)] +/// Error which expresses problems that may occur when asserting a user exists +pub(super) enum UserExistsErr { + #[error("user with ID {0} does not exist")] + UserDoesNotExist(i32), + + #[error(transparent)] + PortError(#[from] anyhow::Error), +} + +/// Asserts that a user already exists in the system, returning an error if not +pub(super) async fn verify_user_exists( + id: i32, + external_cxn: &mut impl ExternalConnectivity, + user_detect: &impl driven_ports::DetectUser, +) -> Result<(), UserExistsErr> { + let does_user_exist = user_detect.user_exists(id, external_cxn).await?; + + if does_user_exist { + Ok(()) + } else { + Err(UserExistsErr::UserDoesNotExist(id)) + } +} + +impl driving_ports::UserPort for UserService { + async fn get_users( + &self, + ext_cxn: &mut impl ExternalConnectivity, + u_reader: &impl driven_ports::UserReader, + ) -> Result, anyhow::Error> { + let all_users_result = u_reader.all(ext_cxn).await; + if let Err(ref port_err) = all_users_result { + log::error!("User fetch failure: {port_err}"); + } + + all_users_result.context("Failed fetching users") + } + + async fn create_user( + &self, + new_user: &CreateUser, + ext_cxn: &mut impl ExternalConnectivity, + u_writer: &impl driven_ports::UserWriter, + u_detect: &impl driven_ports::DetectUser, + ) -> Result { + let description = driven_ports::UserDescription { + first_name: &new_user.first_name, + last_name: &new_user.last_name, + }; + + let user_exists = u_detect + .user_with_name_exists(description, ext_cxn) + .await + .context("Looking up user during creation")?; + if user_exists { + return Err(CreateUserError::UserAlreadyExists); + } + + Ok(u_writer + .create_user(new_user, ext_cxn) + .await + .context("Trying to create user at service level")?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::test_util::Connectivity; + use crate::domain::user::driven_ports::UserWriter; + use crate::domain::user::driving_ports::UserPort; + use crate::external_connections; + use speculoos::prelude::*; + use std::sync::RwLock; + + mod verify_user_exists { + use super::*; + + #[tokio::test] + async fn detects_user() { + let user_stuff = test_util::InMemoryUserPersistence::new_locked(); + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + // This is guaranteed to succeed because it's connected by default + let create_result = user_stuff + .create_user(&test_util::user_create_default(), &mut db_cxn) + .await; + let new_user_id = match create_result { + Ok(info) => info, + Err(_) => unreachable!(), + }; + + let exists_result = verify_user_exists(new_user_id, &mut db_cxn, &user_stuff).await; + assert_that!(exists_result).is_ok(); + } + + #[tokio::test] + async fn errors_when_user_doesnt_exist() { + let user_stuff = test_util::InMemoryUserPersistence::new_locked(); + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let exists_result = verify_user_exists(5, &mut db_cxn, &user_stuff).await; + assert_that!(exists_result) + .is_err() + .matches(|inner_err| matches!(inner_err, UserExistsErr::UserDoesNotExist(5))); + } + + #[tokio::test] + async fn propagates_port_error() { + let mut user_persistence = test_util::InMemoryUserPersistence::new(); + user_persistence.connectivity = Connectivity::Disconnected; + + let user_stuff = RwLock::new(user_persistence); + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + + let exists_result = verify_user_exists(5, &mut db_cxn, &user_stuff).await; + assert_that!(exists_result) + .is_err() + .matches(|inner_err| matches!(inner_err, UserExistsErr::PortError(_))); + } + } + + mod user_service { + use super::*; + + mod get_users { + use super::*; + + #[tokio::test] + async fn happy_path() { + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_data = test_util::InMemoryUserPersistence::new_with_users(&[ + CreateUser { + first_name: "John".to_owned(), + last_name: "Doe".to_owned(), + }, + CreateUser { + first_name: "Jeff".to_owned(), + last_name: "Doe".to_owned(), + }, + CreateUser { + first_name: "Jane".to_owned(), + last_name: "Doe".to_owned(), + }, + ]); + let locked_user_data = RwLock::new(user_data); + let user_service = UserService {}; + + let users_result = user_service.get_users(&mut db_cxn, &locked_user_data).await; + let fetched_users = match users_result { + Ok(users) => users, + Err(error) => panic!("Should have fetched users but failed: {}", error), + }; + + assert_that!(fetched_users).matches(|users| { + matches!(users.as_slice(), [ + TodoUser { + id: 1, + first_name: fn1, + last_name: ln1, + }, + TodoUser { + id: 2, + first_name: fn2, + last_name: ln2, + }, + TodoUser { + id: 3, + first_name: fn3, + last_name: ln3 + } + ] if fn1 == "John" && + ln1 == "Doe" && + fn2 == "Jeff" && + ln2 == "Doe" && + fn3 == "Jane" && + ln3 == "Doe" + ) + }); + } + + #[tokio::test] + async fn propagates_error() { + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let mut user_data = test_util::InMemoryUserPersistence::new(); + user_data.connectivity = Connectivity::Disconnected; + let locked_user_data = RwLock::new(user_data); + let user_service = UserService {}; + + let get_result = user_service.get_users(&mut db_cxn, &locked_user_data).await; + assert_that!(get_result).is_err(); + } + } + + mod create_user { + use super::*; + + #[tokio::test] + async fn happy_path() { + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_data = test_util::InMemoryUserPersistence::new_locked(); + let user_service = UserService {}; + let new_user = test_util::user_create_default(); + + let create_result = user_service + .create_user(&new_user, &mut db_cxn, &user_data, &user_data) + .await; + assert_that!(create_result).is_ok(); + } + + #[tokio::test] + async fn fails_if_user_already_exists() { + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let user_persistence = + test_util::InMemoryUserPersistence::new_with_users(&[CreateUser { + first_name: "Evan".to_owned(), + last_name: "Rittenhouse".to_owned(), + }]); + let locked_user_data = RwLock::new(user_persistence); + let user_service = UserService {}; + let new_user = CreateUser { + first_name: "Evan".to_owned(), + last_name: "Rittenhouse".to_owned(), + }; + + let create_result = user_service + .create_user(&new_user, &mut db_cxn, &locked_user_data, &locked_user_data) + .await; + let returned_error = match create_result { + Err(error) => error, + Ok(num) => { + panic!( + "Creating user should not have succeeded, got this user ID back: {num}" + ) + } + }; + + assert_that!(returned_error) + .matches(|err| matches!(err, CreateUserError::UserAlreadyExists)); + } + + #[tokio::test] + async fn propagates_port_error() { + let mut db_cxn = external_connections::test_util::FakeExternalConnectivity::new(); + let mut user_data = test_util::InMemoryUserPersistence::new(); + user_data.connectivity = Connectivity::Disconnected; + let locked_user_data = RwLock::new(user_data); + let user_service = UserService {}; + let new_user = test_util::user_create_default(); + + let create_result = user_service + .create_user(&new_user, &mut db_cxn, &locked_user_data, &locked_user_data) + .await; + assert_that!(create_result) + .is_err() + .matches(|err| matches!(err, CreateUserError::PortError(_))); + } + } + } +} + +#[cfg(test)] +pub mod test_util { + use super::*; + use crate::domain::test_util::{Connectivity, FakeImplementation}; + use crate::domain::user::driven_ports::{DetectUser, UserDescription, UserReader, UserWriter}; + use anyhow::Error; + + use crate::domain::user::driving_ports::UserPort; + use std::sync::{Mutex, RwLock}; + + /// A fake of driven ports for user data + pub struct InMemoryUserPersistence { + highest_user_id: i32, + pub created_users: Vec, + pub connectivity: Connectivity, + } + + impl InMemoryUserPersistence { + /// Constructor for InMemoryUserPersistence + pub fn new() -> InMemoryUserPersistence { + InMemoryUserPersistence { + highest_user_id: 0, + created_users: Vec::new(), + connectivity: Connectivity::Connected, + } + } + + /// Constructor for InMemoryUserPersistence that adds a set of already-existing users + /// to the fake + pub fn new_with_users(users: &[CreateUser]) -> InMemoryUserPersistence { + InMemoryUserPersistence { + highest_user_id: users.len() as i32, + created_users: users + .iter() + .enumerate() + .map(|(index, user_info)| TodoUser { + id: (index + 1) as i32, + first_name: user_info.first_name.clone(), + last_name: user_info.last_name.clone(), + }) + .collect(), + connectivity: Connectivity::Connected, + } + } + + /// Constructor for InMemoryUserPersistence which wraps it in an RwLock so it + /// can be immediately used as driven ports in domain logic tests + pub fn new_locked() -> RwLock { + RwLock::new(InMemoryUserPersistence::new()) + } + } + + impl driven_ports::UserWriter for RwLock { + async fn create_user( + &self, + user: &CreateUser, + _: &mut impl ExternalConnectivity, + ) -> Result { + let mut persister = self.write().expect("user create mutex poisoned"); + persister.connectivity.blow_up_if_disconnected()?; + + persister.highest_user_id += 1; + let id = persister.highest_user_id; + persister.created_users.push(TodoUser { + id, + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + }); + + Ok(persister.highest_user_id) + } + } + + impl driven_ports::UserReader for RwLock { + async fn all( + &self, + _: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error> { + let persister = self.read().expect("user read rwlock poisoned"); + persister.connectivity.blow_up_if_disconnected()?; + + Ok(persister + .created_users + .iter() + .map(|user| TodoUser { + id: user.id, + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + }) + .collect()) + } + + async fn by_id( + &self, + id: i32, + _: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error> { + let persister = self.read().expect("user read rwlock poisoned"); + persister.connectivity.blow_up_if_disconnected()?; + + let user = persister.created_users.iter().find(|user| user.id == id); + match user { + Some(user) => Ok(Some(TodoUser { + id: user.id, + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + })), + None => Ok(None), + } + } + } + + /// Creates a new CreateUser payload + pub fn user_create_default() -> CreateUser { + CreateUser { + first_name: "First".into(), + last_name: "Last".into(), + } + } + + impl DetectUser for RwLock { + async fn user_exists( + &self, + user_id: i32, + _: &mut impl ExternalConnectivity, + ) -> Result { + let detector = self.read().expect("user detect rwlock poisoned"); + detector.connectivity.blow_up_if_disconnected()?; + + Ok(detector.created_users.iter().any(|user| user.id == user_id)) + } + + async fn user_with_name_exists<'strings>( + &self, + description: UserDescription<'strings>, + _: &mut impl ExternalConnectivity, + ) -> Result { + let detector = self.read().expect("user detect rwlock poisoned"); + detector.connectivity.blow_up_if_disconnected()?; + + Ok(detector.created_users.iter().any(|user| { + user.first_name == description.first_name && user.last_name == description.last_name + })) + } + } + + /// A mock of UserService for use in API tests + pub struct MockUserService { + pub get_users_response: FakeImplementation<(), Result, Error>>, + pub create_user_response: FakeImplementation>, + } + + impl MockUserService { + /// Constructor for MockUserService + pub fn new() -> MockUserService { + MockUserService { + get_users_response: FakeImplementation::new(), + create_user_response: FakeImplementation::new(), + } + } + + /// Constructs a new MockUserService, allowing for configuration of mocks + /// in the builder function before the mock is wrapped in a Mutex for use + /// in API tests + pub fn build_locked(builder: impl FnOnce(&mut Self)) -> Mutex { + let mut new_svc = Self::new(); + builder(&mut new_svc); + + Mutex::new(new_svc) + } + } + + impl UserPort for Mutex { + async fn get_users( + &self, + _: &mut impl ExternalConnectivity, + _: &impl UserReader, + ) -> Result, Error> { + let locked_self = self.lock().expect("Lock is poisoned!"); + locked_self.get_users_response.return_value_anyhow() + } + + async fn create_user( + &self, + new_user: &CreateUser, + _: &mut impl ExternalConnectivity, + _: &impl UserWriter, + _: &impl DetectUser, + ) -> Result { + let mut locked_self = self.lock().expect("Lock is poisoned!"); + locked_self + .create_user_response + .save_arguments(new_user.clone()); + locked_self.create_user_response.return_value_result() + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/dto.rs b/practices/development/examples/rust-microservice-template/src/dto.rs new file mode 100644 index 0000000..3fbb8b9 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/dto.rs @@ -0,0 +1,238 @@ +use crate::domain; +use derive_more::Display; +use serde::{Deserialize, Serialize}; +use utoipa::openapi::{RefOr, Schema}; +use utoipa::{openapi, OpenApi, ToSchema}; +use validator::{Validate, ValidationErrors}; + +#[derive(OpenApi)] +#[openapi(components( + schemas( + TodoUser, + NewUser, + InsertedUser, + NewTask, + TodoTask, + UpdateTask, + InsertedTask, + BasicError, + ExtraInfo, + ValidationErrorSchema, + ), + responses( + err_resps::BasicError400Validation, + err_resps::BasicError404, + err_resps::BasicError500, + ), +))] +/// Captures OpenAPI schemas and canned responses defined in the DTO module +pub struct OpenApiSchemas; + +/// DTO for a constructed user +#[derive(Serialize, ToSchema)] +#[cfg_attr(test, derive(Deserialize, PartialEq, Eq, Debug))] +pub struct TodoUser { + #[schema(example = 4)] + pub id: i32, + #[schema(example = "John")] + pub first_name: String, + #[schema(example = "Doe")] + pub last_name: String, +} + +impl From for TodoUser { + fn from(value: domain::user::TodoUser) -> Self { + TodoUser { + id: value.id, + first_name: value.first_name, + last_name: value.last_name, + } + } +} + +/// DTO for creating a new user via the API +#[derive(Deserialize, Display, Validate, ToSchema)] +#[display(fmt = "{} {}", "first_name", "last_name")] +#[cfg_attr(test, derive(Serialize))] +pub struct NewUser { + #[validate(length(max = 30))] + pub first_name: String, + #[validate(length(max = 50))] + pub last_name: String, +} + +/// DTO containing the ID of a user that was created via the API. +#[derive(Serialize, ToSchema)] +#[cfg_attr(test, derive(Deserialize, Debug))] +pub struct InsertedUser { + #[schema(example = 10)] + pub id: i32, +} + +/// DTO for creating a new task via the API +#[derive(Deserialize, Validate, ToSchema)] +#[cfg_attr(test, derive(Serialize))] +pub struct NewTask { + #[validate(length(min = 1))] + pub item_desc: String, +} + +impl From for domain::todo::NewTask { + fn from(value: NewTask) -> Self { + domain::todo::NewTask { + description: value.item_desc, + } + } +} + +/// DTO for a returned task on the API +#[derive(Serialize, ToSchema)] +pub struct TodoTask { + #[schema(example = 10)] + pub id: i32, + #[schema(example = "Something to do")] + pub description: String, +} + +impl From for TodoTask { + fn from(value: domain::todo::TodoTask) -> Self { + TodoTask { + id: value.id, + description: value.item_desc, + } + } +} + +/// DTO for updating a task's content via the API +#[derive(Deserialize, Validate, ToSchema)] +#[cfg_attr(test, derive(Serialize))] +pub struct UpdateTask { + #[validate(length(min = 1))] + pub description: String, +} + +impl From for domain::todo::UpdateTask { + fn from(value: UpdateTask) -> Self { + domain::todo::UpdateTask { + description: value.description, + } + } +} + +/// DTO for a newly created task +#[derive(Serialize, ToSchema)] +pub struct InsertedTask { + #[schema(example = 5)] + pub id: i32, +} + +/// Contains diagnostic information about an API failure +#[derive(Serialize, Debug, ToSchema)] +#[cfg_attr(test, derive(Deserialize))] +pub struct BasicError { + /// A sentinel value that can be used to differentiate between different causes of a non-2XX + /// HTTP response code + pub error_code: String, + /// A human-readable error message suitable for showing to users + pub error_description: String, + + /// Additional contextual information, such as what validations failed on a request DTO + #[serde(skip_deserializing)] + pub extra_info: Option, +} + +/// Contains a set of generic OpenAPI error responses based on [BasicError] that can +/// be easily reused in other requests +pub mod err_resps { + use crate::dto::BasicError; + use utoipa::ToResponse; + + #[derive(ToResponse)] + #[response( + description = "Invalid request body was passed", + example = json!({ + "error_code": "invalid_input", + "error_description": "Submitted data was invalid.", + "extra_info": { + "first_name": [ + { + "code": "length", + "message": null, + "params": { + "value": "Nameeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "max": 30 + } + } + ] + } + }) + )] + pub struct BasicError400Validation(BasicError); + + #[derive(ToResponse)] + #[response( + description = "Entity could not be found", + example = json!({ + "error_code": "not_found", + "error_description": "The requested entity could not be found.", + "extra_info": null + }) + )] + pub struct BasicError404(BasicError); + + #[derive(ToResponse)] + #[response( + description = "Something unexpected went wrong inside the server", + example = json!({ + "error_code": "internal_error", + "error_description": "Could not access data to complete your request", + "extra_info": null + }) + )] + pub struct BasicError500(BasicError); +} + +/// Extra contextual information which explains why an API error occurred +#[derive(Serialize, Debug, ToSchema)] +#[serde(untagged)] +pub enum ExtraInfo { + ValidationIssues(ValidationErrorSchema), + Message(String), +} + +/// Stand-in OpenAPI schema for [ValidationErrors] which just provides an empty object +#[derive(Serialize, Debug)] +#[serde(transparent)] +pub struct ValidationErrorSchema(pub ValidationErrors); + +impl<'schem> ToSchema<'schem> for ValidationErrorSchema { + fn schema() -> (&'schem str, RefOr) { + ( + "ValidationErrorSchema", + openapi::ObjectBuilder::new().into(), + ) + } +} + +#[cfg(test)] +mod dto_tests { + use super::*; + + mod new_user { + use super::*; + + #[test] + fn bad_user_data_gets_rejected() { + let bad_user = NewUser { + first_name: (0..35).map(|_| "A").collect(), + last_name: (0..55).map(|_| "B").collect(), + }; + let validation_result = bad_user.validate(); + assert!(validation_result.is_err()); + let validation_errors = validation_result.unwrap_err(); + let field_validations = validation_errors.field_errors(); + assert!(field_validations.contains_key("first_name")); + assert!(field_validations.contains_key("last_name")); + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/external_connections.rs b/practices/development/examples/rust-microservice-template/src/external_connections.rs new file mode 100644 index 0000000..a2b9b53 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/external_connections.rs @@ -0,0 +1,257 @@ +use sqlx::PgConnection; + +use std::fmt::{Debug, Display}; +use std::future::Future; +use thiserror::Error; + +/// TransactableExternalConnectivity represents an [ExternalConnectivity] that can initiate +/// a database transaction +pub trait TransactableExternalConnectivity: ExternalConnectivity + Transactable + Sync {} + +impl TransactableExternalConnectivity for T {} + +/// ExternalConnectivity owns clients that are able to communicate with the outside world, +/// such as database clients, HTTP clients, and more. +pub trait ExternalConnectivity: Sync { + type Handle<'handle>: ConnectionHandle + 'handle + where + Self: 'handle; + type Error: Debug + Display; + + /// Acquire a handle which allows borrowing a connection from the database pool + async fn database_cxn(&mut self) -> Result, Self::Error>; +} + +/// ConnectionHandle is a handle borrowed from [ExternalConnectivity] which can be +/// used to acquire a connection to the database +pub trait ConnectionHandle { + /// Borrow a connection from the database pool to perform a query + fn borrow_connection(&mut self) -> &mut PgConnection; +} + +/// Anything that can initiate a database transaction +pub trait Transactable: Sync { + type Handle<'handle>: TransactionHandle + 'handle + where + Self: 'handle; + type Error: Debug + Display; + + /// Retrieve a handle which contains a database connection in an active transaction + async fn start_transaction(&self) -> Result, Self::Error>; +} + +/// TransactionHandle is a handle borrowed from [Transactable] which represents +/// an in-flight database transaction that can later be committed. It is expected +/// that dropping the handle without invoking `TransactionHandle::commit` will +/// roll back the transaction +pub trait TransactionHandle: Sync { + type Error: Debug + Display; + + /// Commit the changes to the database + async fn commit(self) -> Result<(), Self::Error>; +} + +#[allow(dead_code)] +#[derive(Debug, Error)] +/// This error reports issues that occur during database transactions, allowing the +/// original result of a [with_transaction]'s lambda to be retrieved even if the transaction +/// commit fails. +pub enum TxOrSourceError +where + SourceErr: Debug + Display, + TxBeginErr: Debug + Display, + TxCommitErr: Debug + Display, +{ + #[error(transparent)] + /// Represents that the lambda failed, returning the error from the lambda + Source(SourceErr), + + #[error("Failed to start the transaction: {0}")] + /// Represents that the database failed to start the transaction, and the lambda did not execute. + TxBegin(TxBeginErr), + + #[error("Got a successful result, but the database transaction failed: {transaction_err}")] + /// Represents that the lambda executed successfully, but the database transaction failed to commit. + /// The original result of the lambda is provided in this error. + TxCommit { + /// The success value returned from the lambda + successful_result: SourceValue, + /// The database error that occurred when the commit failed + transaction_err: TxCommitErr, + }, +} + +// TxAble = "The thing that can begin a transaction" +// ErrBegin = "The error returned if we fail to start a transaction" +// Handle = "The thing that can give you a database connection" +// ErrCommit = "The error returned if we fail to commit the transaction" +// Fn = "The function which contains code executed in a database transaction" +// Fut = "The future returned from the function passed via transaction_context which may be awaited for the return value" +// Ret = "The type Fut resolves to if the transaction was a success" +// ErrSource = "The error Fut resolves to if the user returns an error from Fn" +/// Accepts [tx_origin] which can start a database transaction. It then starts a transaction and +/// invokes [transaction_context] with the started transaction. When [transaction_context] completes, +/// the transaction handle passed to it is committed as long as [transaction_context] does not return +/// a [Result::Err]. +#[allow(dead_code)] +pub async fn with_transaction<'tx, TxAble, ErrBegin, Handle, ErrCommit, Fn, Fut, Ret, ErrSource>( + tx_origin: &'tx TxAble, + transaction_context: Fn, +) -> Result> +where + TxAble: Transactable = Handle, Error = ErrBegin>, + ErrBegin: Debug + Display, + Handle: TransactionHandle, + ErrCommit: Debug + Display, + Fn: FnOnce(&mut Handle) -> Fut, + Fut: Future>, + ErrSource: Debug + Display, +{ + let mut tx_handle = tx_origin + .start_transaction() + .await + .map_err(|err| TxOrSourceError::TxBegin(err))?; + let ret_val = transaction_context(&mut tx_handle).await; + if ret_val.is_ok() { + let commit_result = tx_handle.commit().await; + if let Err(commit_err) = commit_result { + return Err(TxOrSourceError::TxCommit { + successful_result: ret_val.unwrap(), + transaction_err: commit_err, + }); + } + } + + match ret_val { + Ok(value) => Ok(value), + Err(error) => Err(TxOrSourceError::Source(error)), + } +} + +#[cfg(test)] +mod with_transaction_test { + use super::*; + use speculoos::prelude::*; + use thiserror::Error; + + // I need this to help provide a size for the error in the async block used in the following test + #[derive(Debug, Error)] + #[error("Abcde")] + struct SampleErr; + + #[tokio::test] + async fn commits_on_success() { + let ext_cxn = test_util::FakeExternalConnectivity::new(); + let tx_result = with_transaction(&ext_cxn, |_tx_cxn| async { + println!("Woohoo!"); + Ok::<(), SampleErr>(()) + }) + .await; + + assert_that!(tx_result).is_ok(); + assert_that!(ext_cxn.did_transaction_commit()).is_true(); + } + + #[tokio::test] + async fn does_not_commit_on_failure() { + let ext_cxn = test_util::FakeExternalConnectivity::new(); + let tx_result = with_transaction(&ext_cxn, |_tx_cxn| async { + println!("Whoopsie!"); + Err::<(), SampleErr>(SampleErr) + }) + .await; + + assert_that!(tx_result) + .is_err() + .matches(|inner_err| matches!(inner_err, TxOrSourceError::Source(SampleErr))); + assert_that!(ext_cxn.did_transaction_commit()).is_false(); + } +} + +#[cfg(test)] +pub mod test_util { + use crate::external_connections::{ + ConnectionHandle, ExternalConnectivity, Transactable, TransactionHandle, + }; + + use sqlx::PgConnection; + use std::convert::Infallible; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + /// A fake for ExternalConnectivity so unit tests don't actually have to connect to external systems. + /// Also allows inspection in tests to verify a database transaction was committed + pub struct FakeExternalConnectivity { + is_transacting: bool, + downstream_transaction_committed: Arc, + } + + impl FakeExternalConnectivity { + /// Constructor for FakeExternalConnectivity + pub fn new() -> Self { + Self { + is_transacting: false, + downstream_transaction_committed: Arc::new(AtomicBool::new(false)), + } + } + + /// Returns true if a database transaction is active + #[allow(dead_code)] + pub fn is_transacting(&self) -> bool { + self.is_transacting + } + + /// Returns true if there was a database transaction which successfully committed + pub fn did_transaction_commit(&self) -> bool { + self.downstream_transaction_committed.load(Ordering::SeqCst) + } + } + + /// A fake database connection handle which panics if code tries to acquire + /// a real database connection + pub struct MockHandle {} + + impl ConnectionHandle for MockHandle { + fn borrow_connection(&mut self) -> &mut PgConnection { + panic!("You cannot acquire a real database connection in a test.") + } + } + + impl ExternalConnectivity for FakeExternalConnectivity { + type Handle<'cxn> = MockHandle; + type Error = Infallible; + + #[allow(clippy::diverging_sub_expression)] + async fn database_cxn(&mut self) -> Result, Self::Error> { + Ok(MockHandle {}) + } + } + + impl TransactionHandle for FakeExternalConnectivity { + type Error = Infallible; + + async fn commit(self) -> Result<(), Self::Error> { + if !self.is_transacting { + panic!("Tried to commit when we weren't in a transaction!") + } + + self.downstream_transaction_committed + .store(true, Ordering::SeqCst); + Ok(()) + } + } + + impl Transactable for FakeExternalConnectivity { + type Handle<'handle> = FakeExternalConnectivity; + type Error = Infallible; + + async fn start_transaction(&self) -> Result { + Ok(FakeExternalConnectivity { + is_transacting: true, + downstream_transaction_committed: Arc::clone( + &self.downstream_transaction_committed, + ), + }) + } + } +} diff --git a/practices/development/examples/rust-microservice-template/src/integration_test/mod.rs b/practices/development/examples/rust-microservice-template/src/integration_test/mod.rs new file mode 100644 index 0000000..d68c93f --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/integration_test/mod.rs @@ -0,0 +1,2 @@ +mod test_util; +mod user_api; diff --git a/practices/development/examples/rust-microservice-template/src/integration_test/test_util.rs b/practices/development/examples/rust-microservice-template/src/integration_test/test_util.rs new file mode 100644 index 0000000..23b25c7 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/integration_test/test_util.rs @@ -0,0 +1,135 @@ +use crate::persistence::ExternalConnectivity; +use crate::{app_env, configure_logger, db, SharedData}; +use axum::Router; +use dotenv::dotenv; +use lazy_static::lazy_static; +use rand::{thread_rng, Rng}; +use sqlx::{Connection, PgConnection, Row}; +use std::env; +use std::sync::Arc; +use tokio::sync::Mutex; + +lazy_static! { + static ref LOGGER_INITIALIZED: Mutex = Mutex::from(false); + static ref DB_CLEANED: Mutex = Mutex::from(false); + static ref DB_TEMPLATIZED: Mutex = Mutex::from(false); +} + +/// Cleans old test databases from the DB from previous runs to keep them in check. +async fn clear_old_dbs(db_base_url: &str) { + let mut conn = PgConnection::connect(db_base_url) + .await + .expect("Test failure - could not create initial connection to provision database."); + let test_dbs = + sqlx::query("SELECT datname FROM pg_catalog.pg_database WHERE datname LIKE 'test_db%'") + .fetch_all(&mut conn) + .await; + let test_dbs = match test_dbs { + Ok(results) => results.into_iter().map(|row| row.get::(0)), + Err(error) => { + println!("Warning: failed to drop old test databases. You may );need to delete them manually. Error: {error}"); + return; + } + }; + + for db in test_dbs { + let result = sqlx::query(format!("DROP DATABASE {}", db).as_str()) + .execute(&mut conn) + .await; + if result.is_err() { + println!( + "Warning: failed to drop old test database {}, you may need to do it manually.", + db + ); + } + } +} + +/// Creates a new test schema for a single test, using the "postgres" schema as a template which is unique to the test. Test schemas will always +/// have the naming convention "test_db_#####", where "#####" is a random sequence of 5 numbers. +async fn create_test_db( + db_base_url: &str, + db_access_lock: &Mutex, +) -> Result { + let mut is_db_templatized = db_access_lock.lock().await; + + let mut conn = PgConnection::connect(db_base_url) + .await + .expect("Test failure - could not create initial connection to provision database."); + let mut rng = thread_rng(); + let schema_id: i32 = rng.gen_range(10_000..99_999); + let template_db_name = format!("test_db_{}", schema_id); + + if !*is_db_templatized { + sqlx::query("ALTER DATABASE postgres WITH is_template TRUE") + .execute(&mut conn) + .await?; + + *is_db_templatized = true; + } + + sqlx::query(format!("CREATE DATABASE {} TEMPLATE postgres", template_db_name).as_str()) + .execute(&mut conn) + .await?; + + Ok(template_db_name) +} + +/// Creates a temp schema for a test by using the "postgres" default table's content as a template +/// when creating a new schema. +async fn prepare_db(pg_connection_base_url: &str) -> sqlx::PgPool { + // I need to create individual connections here because I need exclusive database access in order to convert a schema to a template schema + let test_db = { + { + let mut db_cleaned_state = DB_CLEANED.lock().await; + if !*db_cleaned_state { + clear_old_dbs(pg_connection_base_url).await; + + *db_cleaned_state = true; + } + } + + let test_db = create_test_db(pg_connection_base_url, &DB_TEMPLATIZED).await; + + match test_db { + Ok(tdb) => tdb, + Err(db_err) => panic!("Failed to start test database: {}", db_err), + } + }; + + db::connect_sqlx(format!("{}/{}", pg_connection_base_url, test_db).as_str()).await +} + +/// Prepares a database-connected application for integration tests, attaching routes via the provided +/// Axum router. This function returns both the database pool and a prepared application instance +/// which can handle requests based on the registered routes passed to the function. +/// +/// Expects that the [TEST_DB_URL](app_env::test::TEST_DB_URL) environment variable is populated. +pub async fn prepare_application(routes: Router>) -> (Router, sqlx::PgPool) { + // As soon as we're done configuring the logger we can release the mutex + { + let mut mutex_handle = LOGGER_INITIALIZED.lock().await; + if !*mutex_handle { + if dotenv().is_err() { + println!("Test is running without .env file."); + } + configure_logger(); + + *mutex_handle = true; + } + } + + let pg_connection_base_url = env::var(app_env::test::TEST_DB_URL).unwrap_or_else(|_| { + panic!( + "You must provide the {} environment variable as the base postgres connection string", + app_env::test::TEST_DB_URL + ) + }); + + let db = prepare_db(pg_connection_base_url.as_str()).await; + let app = routes.with_state(Arc::new(SharedData { + ext_cxn: ExternalConnectivity::new(db.clone()), + })); + + (app, db) +} diff --git a/practices/development/examples/rust-microservice-template/src/integration_test/user_api.rs b/practices/development/examples/rust-microservice-template/src/integration_test/user_api.rs new file mode 100644 index 0000000..e08ec94 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/integration_test/user_api.rs @@ -0,0 +1,73 @@ +use axum::body::Body; +use axum::http::{header, Method, Request, StatusCode}; +use axum::Router; +use tower::Service; // THIS IS REQUIRED FOR Router.call() + +use crate::api::test_util::{deserialize_body, dto_to_body}; +use crate::{api, dto}; + +use super::test_util; + +fn create_user_request() -> Request { + Request::builder() + .method(Method::POST) + .uri("/users") + .header(header::CONTENT_TYPE, "application/json") + .body(dto_to_body(&dto::NewUser { + first_name: String::from("John"), + last_name: String::from("Doe"), + })) + .unwrap() +} + +#[tokio::test] +#[cfg_attr(not(feature = "integration_test"), ignore)] +async fn can_create_user() { + let router = Router::new().nest("/users", api::user::user_routes()); + let (mut app, _) = test_util::prepare_application(router).await; + let test_req = create_user_request(); + + let response = app.call(test_req).await.unwrap(); + + let (res_parts, res_body) = response.into_parts(); + assert_eq!(StatusCode::CREATED, res_parts.status); + + let new_user_dto: dto::InsertedUser = deserialize_body(res_body).await; + assert!(new_user_dto.id > 0); +} + +#[tokio::test] +#[cfg_attr(not(feature = "integration_test"), ignore)] +async fn can_retrieve_user() { + let router = Router::new().nest("/users", api::user::user_routes()); + let (mut app, _) = test_util::prepare_application(router).await; + let create_user_req = create_user_request(); + + let create_response = app.call(create_user_req).await.unwrap(); + let (create_parts, body) = create_response.into_parts(); + assert_eq!(StatusCode::CREATED, create_parts.status); + + let user_id: dto::InsertedUser = deserialize_body(body).await; + + let list_users_req = Request::builder() + .method(Method::GET) + .uri("/users") + .body(Body::empty()) + .expect("List users request failed to construct"); + let list_users_resp = app + .call(list_users_req) + .await + .expect("User lookup request failed"); + let (list_users_parts, lu_body) = list_users_resp.into_parts(); + + assert_eq!(StatusCode::OK, list_users_parts.status); + + let received_user: Vec = deserialize_body(lu_body).await; + let expected_user = dto::TodoUser { + id: user_id.id, + first_name: String::from("John"), + last_name: String::from("Doe"), + }; + + assert_eq!(expected_user, received_user[0]); +} diff --git a/practices/development/examples/rust-microservice-template/src/main.rs b/practices/development/examples/rust-microservice-template/src/main.rs new file mode 100644 index 0000000..93fc8c4 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/main.rs @@ -0,0 +1,68 @@ +use std::env; +use std::sync::Arc; + +use axum::extract::State; + +use axum::Router; +use dotenv::dotenv; +use log::*; +use tokio::net::TcpListener; + +mod api; +mod app_env; +mod db; +mod domain; +mod dto; +// mod entity; +mod persistence; +// mod routes; +mod routing_utils; + +mod external_connections; +#[cfg(test)] +mod integration_test; + +/// Configures the logging system for the application. Pulls configuration from the [LOG_LEVEL](app_env::LOG_LEVEL) +/// environment variable. Sets log level to "INFO" for all modules and sqlx to "WARN" by default. +pub fn configure_logger() { + env_logger::builder() + .filter_level(LevelFilter::Info) + .filter_module("sqlx", LevelFilter::Warn) + .parse_env(app_env::LOG_LEVEL) + .init(); +} + +/// Global data store which is shared among HTTP routes +pub struct SharedData { + pub ext_cxn: persistence::ExternalConnectivity, +} + +/// Type alias for the extractor used to get access to the global app state +type AppState = State>; + +#[tokio::main] +async fn main() { + if dotenv().is_err() { + println!("Starting server without .env file."); + } + configure_logger(); + let db_url = env::var(app_env::DB_URL).expect("Could not get database URL from environment"); + + let sqlx_db_connection = db::connect_sqlx(&db_url).await; + let ext_cxn = persistence::ExternalConnectivity::new(sqlx_db_connection); + + let router = Router::new() + .nest("/users", api::user::user_routes()) + .nest("/tasks", api::todo::task_routes()) + .merge(api::swagger_main::build_documentation()) + .with_state(Arc::new(SharedData { ext_cxn })); + + info!("Starting server."); + let network_listener = match TcpListener::bind(&"0.0.0.0:8080").await { + Ok(listener) => listener, + Err(bind_err) => panic!("Could not listen on requested port! {}", bind_err), + }; + axum::serve(network_listener, router.into_make_service()) + .await + .unwrap(); +} diff --git a/practices/development/examples/rust-microservice-template/src/persistence/db_todo_driven_ports.rs b/practices/development/examples/rust-microservice-template/src/persistence/db_todo_driven_ports.rs new file mode 100644 index 0000000..4686da0 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/persistence/db_todo_driven_ports.rs @@ -0,0 +1,132 @@ +use crate::domain; +use crate::domain::todo::{NewTask, TodoTask, UpdateTask}; +use crate::external_connections::{ConnectionHandle, ExternalConnectivity}; +use anyhow::{Context, Error}; +use sqlx::{query, query_as}; + +/// A database-based driven adapter for reading tasks +pub struct DbTaskReader; + +/// DTO containing information about a to-do item from the database +struct TodoItemRow { + id: i32, + user_id: i32, + item_desc: String, +} + +impl From for domain::todo::TodoTask { + fn from(value: TodoItemRow) -> Self { + TodoTask { + id: value.id, + owner_user_id: value.user_id, + item_desc: value.item_desc, + } + } +} + +impl domain::todo::driven_ports::TaskReader for DbTaskReader { + async fn tasks_for_user( + &self, + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, Error> { + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let todo_items: Vec = query_as!( + TodoItemRow, + "SELECT ti.* FROM todo_item ti WHERE ti.user_id = $1", + user_id + ) + .fetch_all(cxn.borrow_connection()) + .await + .context("trying to fetch todo items for a user")? + .into_iter() + .map(domain::todo::TodoTask::from) + .collect(); + + Ok(todo_items) + } + + async fn user_task_by_id( + &self, + user_id: i32, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, Error> { + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let todo_item: Option = query_as!( + TodoItemRow, + "SELECT ti.* FROM todo_item ti WHERE ti.user_id = $1 AND ti.id = $2", + user_id, + task_id + ) + .fetch_optional(cxn.borrow_connection()) + .await + .context("trying to fetch a todo item by ID")? + .map(domain::todo::TodoTask::from); + + Ok(todo_item) + } +} + +/// A database-based driven adapter for writing new tasks +pub struct DbTaskWriter; + +impl domain::todo::driven_ports::TaskWriter for DbTaskWriter { + async fn create_task_for_user( + &self, + user_id: i32, + new_task: &NewTask, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result { + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let new_id = query_as!( + super::NewId, + "INSERT INTO todo_item(user_id, item_desc) VALUES ($1, $2) RETURNING todo_item.id", + user_id, + new_task.description + ) + .fetch_one(cxn.borrow_connection()) + .await + .context("trying to insert a new task into the database")?; + + Ok(new_id.id) + } + + async fn delete_task( + &self, + task_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result<(), Error> { + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + query!("DELETE FROM todo_item WHERE id = $1", task_id) + .execute(cxn.borrow_connection()) + .await + .context("trying to remove a task from the database")?; + + Ok(()) + } + + async fn update_task( + &self, + task_id: i32, + update: &UpdateTask, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result<(), Error> { + let mut cxn = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + query!( + "UPDATE todo_item SET item_desc = $1 WHERE id = $2", + update.description, + task_id + ) + .execute(cxn.borrow_connection()) + .await + .context("trying to update a task in the database")?; + + Ok(()) + } +} diff --git a/practices/development/examples/rust-microservice-template/src/persistence/db_user_driven_ports.rs b/practices/development/examples/rust-microservice-template/src/persistence/db_user_driven_ports.rs new file mode 100644 index 0000000..406f841 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/persistence/db_user_driven_ports.rs @@ -0,0 +1,134 @@ +use super::Count; +use crate::domain; +use crate::domain::user::driven_ports::UserDescription; +use crate::domain::user::{CreateUser, TodoUser}; +use crate::external_connections::{ConnectionHandle, ExternalConnectivity}; +use anyhow::Context; +use sqlx::query_as; + +/// A database-based driven adapter for detecting the presence of existing users +pub struct DbDetectUser; + +impl domain::user::driven_ports::DetectUser for DbDetectUser { + async fn user_exists( + &self, + user_id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result { + let mut connection = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let user_with_id_count = query_as!( + Count, + "SELECT count(*) FROM todo_user tu WHERE tu.id = $1", + user_id + ) + .fetch_one(connection.borrow_connection()) + .await + .context("Detecting user with ID")?; + + Ok(user_with_id_count.count() > 0) + } + + async fn user_with_name_exists<'strings>( + &self, + description: UserDescription<'strings>, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result { + let mut connection = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let user_with_name_count = query_as!( + Count, + "SELECT count(*) from todo_user tu WHERE tu.first_name = $1 AND tu.last_name = $2", + description.first_name, + description.last_name + ) + .fetch_one(connection.borrow_connection()) + .await + .context("Detecting user via name")?; + + Ok(user_with_name_count.count() > 0) + } +} + +/// A database-based driven adapter for reading existing user data +pub struct DbReadUsers; + +/// A database DTO containing user data +struct TodoUserRow { + id: i32, + first_name: String, + last_name: String, +} + +impl From for TodoUser { + fn from(value: TodoUserRow) -> Self { + TodoUser { + id: value.id, + first_name: value.first_name, + last_name: value.last_name, + } + } +} + +impl domain::user::driven_ports::UserReader for DbReadUsers { + async fn all( + &self, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error> { + let mut connection = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let users: Vec = query_as!(TodoUserRow, "SELECT * FROM todo_user") + .fetch_all(connection.borrow_connection()) + .await + .context("Fetching all users")? + .into_iter() + .map(domain::user::TodoUser::from) + .collect(); + + Ok(users) + } + + async fn by_id( + &self, + id: i32, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result, anyhow::Error> { + let mut cxn_handle = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let user = query_as!( + TodoUserRow, + "SELECT * FROM todo_user tu WHERE tu.id = $1", + id + ) + .fetch_optional(cxn_handle.borrow_connection()) + .await + .context("Fetching a user by id")?; + + Ok(user.map(TodoUser::from)) + } +} + +/// A database-based driven adapter for writing new users into the database +pub struct DbWriteUsers; + +impl domain::user::driven_ports::UserWriter for DbWriteUsers { + async fn create_user( + &self, + user: &CreateUser, + ext_cxn: &mut impl ExternalConnectivity, + ) -> Result { + let mut cxn_handle = ext_cxn.database_cxn().await.map_err(super::anyhowify)?; + + let user = query_as!( + super::NewId, + "INSERT INTO todo_user(first_name, last_name) VALUES ($1, $2) RETURNING todo_user.id", + user.first_name, + user.last_name, + ) + .fetch_one(cxn_handle.borrow_connection()) + .await + .context("Inserting new user")?; + + Ok(user.id) + } +} diff --git a/practices/development/examples/rust-microservice-template/src/persistence/mod.rs b/practices/development/examples/rust-microservice-template/src/persistence/mod.rs new file mode 100644 index 0000000..ba2a0c3 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/persistence/mod.rs @@ -0,0 +1,135 @@ +pub mod db_todo_driven_ports; +pub mod db_user_driven_ports; + +use crate::external_connections; +use crate::external_connections::ConnectionHandle; +use anyhow::{anyhow, Context}; +use std::fmt::{Debug, Display}; + +use sqlx::pool::PoolConnection; +use sqlx::{Acquire, PgConnection, PgPool, Postgres, Transaction}; + +/// Data structure which owns clients for connecting to external systems. +/// Allows business logic to be agnostic of the external systems it communicates with +/// so driven adapters can easily be swapped out for other implementations +#[derive(Clone)] +pub struct ExternalConnectivity { + db: PgPool, +} + +impl ExternalConnectivity { + /// Accepts the set of clients used to connect to external systems and constructs + /// an instance of ExternalConnectivity owning those clients + pub fn new(db: PgPool) -> Self { + ExternalConnectivity { db } + } +} + +/// A handle from ExternalConnectivity which can connect to a database +pub struct PoolConnectionHandle { + active_connection: PoolConnection, +} + +impl ConnectionHandle for PoolConnectionHandle { + fn borrow_connection(&mut self) -> &mut PgConnection { + &mut self.active_connection + } +} + +impl external_connections::ExternalConnectivity for ExternalConnectivity { + type Handle<'cxn_borrow> = PoolConnectionHandle; + type Error = anyhow::Error; + + async fn database_cxn(&mut self) -> Result, Self::Error> { + let handle = PoolConnectionHandle { + active_connection: self.db.acquire().await?, + }; + + Ok(handle) + } +} + +impl external_connections::Transactable for ExternalConnectivity { + type Handle<'handle> = ExternalConnectionsInTransaction<'handle>; + type Error = anyhow::Error; + + async fn start_transaction(&self) -> Result, Self::Error> { + let transaction = self + .db + .begin() + .await + .context("Starting transaction from db pool")?; + + Ok(ExternalConnectionsInTransaction { txn: transaction }) + } +} + +/// A variant of ExternalConnectivity where the database client has an active database transaction +/// which can later be committed +pub struct ExternalConnectionsInTransaction<'tx> { + txn: Transaction<'tx, Postgres>, +} + +/// A handle from ExternalConnectionsInTransaction which can connect to a database +pub struct TransactionHandle<'tx> { + active_transaction: &'tx mut PgConnection, +} + +impl<'tx> external_connections::ExternalConnectivity for ExternalConnectionsInTransaction<'tx> { + type Handle<'tx_borrow> = TransactionHandle<'tx_borrow> where Self: 'tx_borrow; + type Error = anyhow::Error; + + async fn database_cxn(&mut self) -> Result, Self::Error> { + let handle = self + .txn + .acquire() + .await + .context("acquiring connection from database transaction")?; + + Ok(TransactionHandle { + active_transaction: handle, + }) + } +} + +impl<'tx> ConnectionHandle for TransactionHandle<'tx> { + fn borrow_connection(&mut self) -> &mut PgConnection { + &mut *self.active_transaction + } +} + +impl<'tx> external_connections::TransactionHandle for ExternalConnectionsInTransaction<'tx> { + type Error = anyhow::Error; + + async fn commit(self) -> Result<(), Self::Error> { + self.txn + .commit() + .await + .context("Committing database transaction")?; + + Ok(()) + } +} + +/// Utility DTO for consuming the output of the PostgreSQL `count()` function +struct Count { + count: Option, +} + +impl Count { + /// Retrieve the count value, as it's typechecked to be optional but should always be present + fn count(&self) -> i64 { + self.count + .expect("count() should always produce at least one row") + } +} + +/// Utility DTO for retrieving the ID of a newly inserted record to PostgreSQL +struct NewId { + id: i32, +} + +/// Converts anything implementing Debug and Display into an [anyhow::Error] +fn anyhowify(errorish: T) -> anyhow::Error { + anyhow!(format!("{}", errorish)) +} diff --git a/practices/development/examples/rust-microservice-template/src/routing_utils.rs b/practices/development/examples/rust-microservice-template/src/routing_utils.rs new file mode 100644 index 0000000..71aafe5 --- /dev/null +++ b/practices/development/examples/rust-microservice-template/src/routing_utils.rs @@ -0,0 +1,90 @@ +use axum::extract::rejection::JsonRejection; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum_macros::FromRequest; + +use serde::Serialize; + +use crate::dto::{BasicError, ExtraInfo, ValidationErrorSchema}; +use validator::ValidationErrors; + +/// Represents a generic 500 internal server error which turns into a [BasicError] +pub struct GenericErrorResponse(pub anyhow::Error); + +impl IntoResponse for GenericErrorResponse { + fn into_response(self) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BasicError { + error_code: "internal_error".to_owned(), + error_description: format!("An unexpected error occurred: {}", self.0), + extra_info: None, + }), + ) + .into_response() + } +} + +/// Response type that wraps validation errors and turns them into [BasicError]s +pub struct ValidationErrorResponse(ValidationErrors); + +impl IntoResponse for ValidationErrorResponse { + fn into_response(self) -> Response { + ( + StatusCode::BAD_REQUEST, + Json(BasicError { + error_code: "invalid_input".into(), + error_description: "Submitted data was invalid.".to_owned(), + extra_info: Some(ExtraInfo::ValidationIssues(ValidationErrorSchema(self.0))), + }), + ) + .into_response() + } +} + +impl From for ValidationErrorResponse { + fn from(value: ValidationErrors) -> Self { + Self(value) + } +} + +/// Wrapper for [axum::Json] which customizes the error response to use the template's +/// data structure for API errors +#[derive(FromRequest)] +#[from_request(via(axum::Json), rejection(JsonErrorResponse))] +#[cfg_attr(test, derive(Debug))] +pub struct Json(pub T); + +impl IntoResponse for Json { + fn into_response(self) -> Response { + axum::Json(self.0).into_response() + } +} + +/// Response type representing JSON parse errors +pub struct JsonErrorResponse { + parse_problem: String, +} + +impl From for JsonErrorResponse { + fn from(value: JsonRejection) -> Self { + JsonErrorResponse { + parse_problem: value.body_text(), + } + } +} + +impl IntoResponse for JsonErrorResponse { + fn into_response(self) -> Response { + ( + StatusCode::BAD_REQUEST, + axum::Json(BasicError { + error_code: "invalid_json".into(), + error_description: + "The passed request body contained malformed or unreadable JSON.".into(), + extra_info: Some(ExtraInfo::Message(self.parse_problem)), + }), + ) + .into_response() + } +}