├── .cargo └── config.toml ├── .dockerignore ├── .env ├── .github └── workflows │ ├── audit.yml │ └── general.yml ├── .gitignore ├── .sqlx ├── query-0029b925e31429d25d23538804511943e2ea1fddc5a2db9a4e219c9b5be53fce.json ├── query-06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a.json ├── query-0b93f6f4f1bc59e7ee597ef6df52bbee1233d98e0a4cf53e29c153ccdae0537b.json ├── query-1bb5d1c15161a276262535134c306bc392dda0fa1d7bb7deddcd544583a19fc8.json ├── query-21f0f4c2ae0e88b99684823b83ce6126c218cec3badc8126492aab8fc7042109.json ├── query-2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b.json ├── query-33b11051e779866db9aeb86d28a59db07a94323ffdc59a5a2c1da694ebe9a65f.json ├── query-38d1a12165ad4f50d8fbd4fc92376d9cc243dcc344c67b37f7fef13c6589e1eb.json ├── query-51c9c995452d3359e3da7e2f2ff8a6e68690f740a36d2a32ec7c40b08931ebdb.json ├── query-753c8ecfac0ea7d052e60cb582e3b3ebac5e50eb133152712ca18ab5d5e202f3.json ├── query-9ab6536d2bf619381573b3bf13507d53b2e9cf50051e51c803e916f25b51abd2.json ├── query-9f103f7d6dfa569bafce4546e6e610f3d31b95fe81f96ea72575b27ddfea796e.json ├── query-a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b.json ├── query-aa682ff5c6485c4faa8168322413294a282ddcc0ef4e38ca3980e6fc7c00c87c.json ├── query-aa6ec2d18c8536eb8340bdf02a833440ff7954c503133ed99ebd6190822edf04.json ├── query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json ├── query-ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f.json └── query-c00b32b331e0444b4bb0cd823b71a8c7ed3a3c8f2b8db3b12c6fbc434aa4d34b.json ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── configuration ├── base.yaml ├── local.yaml └── production.yaml ├── migrations ├── 20200823135036_create_subscriptions_table.sql ├── 20210307181858_add_status_to_subscriptions.sql ├── 20210307184428_make_status_not_null_in_subscriptions.sql ├── 20210307185410_create_subscription_tokens_table.sql ├── 20210815112026_create_users_table.sql ├── 20210822143736_rename_password_column.sql ├── 20210829175741_add_salt_to_users.sql ├── 20210829200701_remove_salt_from_users.sql ├── 20220312175058_seed_user.sql ├── 20220313182312_create_idempotency_table.sql ├── 20220313184809_relax_null_checks_on_idempotency.sql ├── 20220313190920_create_newsletter_issues_table.sql └── 20220313191254_create_issue_delivery_queue_table.sql ├── scripts ├── init_db.sh └── init_redis.sh ├── spec.yaml ├── src ├── authentication │ ├── middleware.rs │ ├── mod.rs │ └── password.rs ├── configuration.rs ├── domain │ ├── mod.rs │ ├── new_subscriber.rs │ ├── subscriber_email.rs │ └── subscriber_name.rs ├── email_client.rs ├── idempotency │ ├── key.rs │ ├── mod.rs │ └── persistence.rs ├── issue_delivery_worker.rs ├── lib.rs ├── main.rs ├── routes │ ├── admin │ │ ├── dashboard.rs │ │ ├── logout.rs │ │ ├── mod.rs │ │ ├── newsletter │ │ │ ├── get.rs │ │ │ ├── mod.rs │ │ │ └── post.rs │ │ └── password │ │ │ ├── get.rs │ │ │ ├── mod.rs │ │ │ └── post.rs │ ├── health_check.rs │ ├── home │ │ ├── home.html │ │ └── mod.rs │ ├── login │ │ ├── get.rs │ │ ├── login.html │ │ ├── mod.rs │ │ └── post.rs │ ├── mod.rs │ ├── subscriptions.rs │ └── subscriptions_confirm.rs ├── session_state.rs ├── startup.rs ├── telemetry.rs └── utils.rs └── tests └── api ├── admin_dashboard.rs ├── change_password.rs ├── health_check.rs ├── helpers.rs ├── login.rs ├── main.rs ├── newsletter.rs ├── subscriptions.rs ├── subscriptions_confirm.rs └── test_user.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-unknown-linux-gnu] 2 | rustflags = ["-C", "linker=clang", "-C", "link-arg=-fuse-ld=lld"] 3 | 4 | [target.x86_64-pc-windows-msvc] 5 | rustflags = ["-C", "link-arg=-fuse-ld=lld"] 6 | 7 | [target.x86_64-pc-windows-gnu] 8 | rustflags = ["-C", "link-arg=-fuse-ld=lld"] 9 | 10 | [target.x86_64-apple-darwin] 11 | rustflags = ["-C", "link-arg=-fuse-ld=lld"] 12 | 13 | [target.aarch64-apple-darwin] 14 | rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld"] -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .dockerignore 3 | spec.yaml 4 | target/ 5 | deploy/ 6 | tests/ 7 | Dockerfile 8 | scripts/ 9 | migrations/ -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgres://postgres:password@localhost:5432/newsletter" 2 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | push: 6 | paths: 7 | - '**/Cargo.toml' 8 | - '**/Cargo.lock' 9 | jobs: 10 | security_audit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: taiki-e/install-action@cargo-deny 15 | - name: Scan for vulnerabilities 16 | run: cargo deny check advisories -------------------------------------------------------------------------------- /.github/workflows/general.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | # NB: this differs from the book's project! 5 | # These settings allow us to run this specific CI pipeline for PRs against 6 | # this specific branch (a.k.a. book chapter). 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | types: [opened, synchronize, reopened] 12 | branches: 13 | - main 14 | 15 | env: 16 | CARGO_TERM_COLOR: always 17 | SQLX_VERSION: 0.8.0 18 | SQLX_FEATURES: "rustls,postgres" 19 | APP_USER: app 20 | APP_USER_PWD: secret 21 | APP_DB_NAME: newsletter 22 | 23 | jobs: 24 | test: 25 | name: Test 26 | runs-on: ubuntu-latest 27 | # Service containers to run alongside the `test` container job 28 | services: 29 | postgres: 30 | # Docker Hub image 31 | image: postgres:14 32 | env: 33 | POSTGRES_USER: postgres 34 | POSTGRES_PASSWORD: password 35 | POSTGRES_DB: postgres 36 | ports: 37 | - 5432:5432 38 | redis: 39 | image: redis:7 40 | ports: 41 | - 6379:6379 42 | steps: 43 | # Downloads a copy of the code in your repository before running CI tests 44 | - name: Check out repository code 45 | # The uses keyword specifies that this step will run v4 of the actions/checkout action. 46 | # This is an action that checks out your repository onto the runner, allowing you to run scripts or other actions against your code (such as build and test tools). 47 | # You should use the checkout action any time your workflow will run against the repository's code. 48 | uses: actions/checkout@v4 49 | 50 | # This GitHub Action installs a Rust toolchain using rustup. It is designed for one-line concise usage and good defaults. 51 | # It also takes care of caching intermediate build artifacts. 52 | - name: Install the Rust toolchain 53 | uses: actions-rust-lang/setup-rust-toolchain@v1 54 | 55 | - name: Install sqlx-cli 56 | run: 57 | cargo install sqlx-cli 58 | --version=${{ env.SQLX_VERSION }} 59 | --features ${{ env.SQLX_FEATURES }} 60 | --no-default-features 61 | --locked 62 | # The --locked flag can be used to force Cargo to use the packaged Cargo.lock file if it is available. 63 | # This may be useful for ensuring reproducible builds, to use the exact same set of dependencies that were available when the package was published. 64 | # It may also be useful if a newer version of a dependency is published that no longer builds on your system, or has other problems 65 | 66 | - name: Create app user in Postgres 67 | run: | 68 | sudo apt-get install postgresql-client 69 | 70 | # Create the application user 71 | CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';" 72 | PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${CREATE_QUERY}" 73 | 74 | # Grant create db privileges to the app user 75 | GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;" 76 | PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${GRANT_QUERY}" 77 | 78 | - name: Migrate database 79 | run: | 80 | SKIP_DOCKER=true ./scripts/init_db.sh 81 | 82 | - name: Run tests 83 | run: cargo test 84 | 85 | - name: Check that queries are fresh 86 | run: cargo sqlx prepare --workspace --check -- --all-targets 87 | 88 | # `fmt` container job 89 | fmt: 90 | name: Rustfmt 91 | runs-on: ubuntu-latest 92 | steps: 93 | - uses: actions/checkout@v4 94 | - name: Install the Rust toolchain 95 | uses: actions-rust-lang/setup-rust-toolchain@v1 96 | with: 97 | components: rustfmt 98 | - name: Enforce formatting 99 | run: cargo fmt --check 100 | 101 | clippy: 102 | name: Clippy 103 | runs-on: ubuntu-latest 104 | env: 105 | SQLX_OFFLINE: true 106 | steps: 107 | - uses: actions/checkout@v4 108 | - name: Install the Rust toolchain 109 | uses: actions-rust-lang/setup-rust-toolchain@v1 110 | with: 111 | components: clippy 112 | - name: Linting 113 | run: cargo clippy -- -D warnings 114 | 115 | coverage: 116 | name: Code coverage 117 | runs-on: ubuntu-latest 118 | services: 119 | postgres: 120 | image: postgres:14 121 | env: 122 | POSTGRES_USER: postgres 123 | POSTGRES_PASSWORD: password 124 | POSTGRES_DB: postgres 125 | ports: 126 | - 5432:5432 127 | redis: 128 | image: redis:7 129 | ports: 130 | - 6379:6379 131 | steps: 132 | - uses: actions/checkout@v4 133 | - name: Install the Rust toolchain 134 | uses: actions-rust-lang/setup-rust-toolchain@v1 135 | with: 136 | components: llvm-tools-preview 137 | - name: Install sqlx-cli 138 | run: cargo install sqlx-cli 139 | --version=${{ env.SQLX_VERSION }} 140 | --features ${{ env.SQLX_FEATURES }} 141 | --no-default-features 142 | --locked 143 | - name: Create app user in Postgres 144 | run: | 145 | sudo apt-get install postgresql-client 146 | 147 | # Create the application user 148 | CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';" 149 | PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${CREATE_QUERY}" 150 | 151 | # Grant create db privileges to the app user 152 | GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;" 153 | PGPASSWORD="password" psql -U "postgres" -h "localhost" -c "${GRANT_QUERY}" 154 | - name: Migrate database 155 | run: SKIP_DOCKER=true ./scripts/init_db.sh 156 | - name: Install cargo-llvm-cov 157 | uses: taiki-e/install-action@cargo-llvm-cov 158 | - name: Generate code coverage 159 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 160 | - name: Generate report 161 | run: cargo llvm-cov report --html --output-dir coverage 162 | - uses: actions/upload-artifact@v4 163 | with: 164 | name: "Coverage report" 165 | path: coverage/ 166 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | tags 4 | -------------------------------------------------------------------------------- /.sqlx/query-0029b925e31429d25d23538804511943e2ea1fddc5a2db9a4e219c9b5be53fce.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO users (user_id, username, password_hash)\n VALUES ($1, $2, $3)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Text", 10 | "Text" 11 | ] 12 | }, 13 | "nullable": [] 14 | }, 15 | "hash": "0029b925e31429d25d23538804511943e2ea1fddc5a2db9a4e219c9b5be53fce" 16 | } 17 | -------------------------------------------------------------------------------- /.sqlx/query-06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT newsletter_issue_id, subscriber_email\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "newsletter_issue_id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "subscriber_email", 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Left": [] 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a" 26 | } 27 | -------------------------------------------------------------------------------- /.sqlx/query-0b93f6f4f1bc59e7ee597ef6df52bbee1233d98e0a4cf53e29c153ccdae0537b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n INSERT INTO newsletter_issues (\n newsletter_issue_id, \n title, \n text_content, \n html_content,\n published_at\n )\n VALUES ($1, $2, $3, $4, now())\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Text", 10 | "Text", 11 | "Text" 12 | ] 13 | }, 14 | "nullable": [] 15 | }, 16 | "hash": "0b93f6f4f1bc59e7ee597ef6df52bbee1233d98e0a4cf53e29c153ccdae0537b" 17 | } 18 | -------------------------------------------------------------------------------- /.sqlx/query-1bb5d1c15161a276262535134c306bc392dda0fa1d7bb7deddcd544583a19fc8.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n INSERT INTO idempotency (\n user_id, \n idempotency_key,\n created_at\n ) \n VALUES ($1, $2, now()) \n ON CONFLICT DO NOTHING\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "1bb5d1c15161a276262535134c306bc392dda0fa1d7bb7deddcd544583a19fc8" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-21f0f4c2ae0e88b99684823b83ce6126c218cec3badc8126492aab8fc7042109.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n UPDATE idempotency\n SET \n response_status_code = $3, \n response_headers = $4,\n response_body = $5\n WHERE\n user_id = $1 AND\n idempotency_key = $2\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Text", 10 | "Int2", 11 | { 12 | "Custom": { 13 | "name": "header_pair[]", 14 | "kind": { 15 | "Array": { 16 | "Custom": { 17 | "name": "header_pair", 18 | "kind": { 19 | "Composite": [ 20 | [ 21 | "name", 22 | "Text" 23 | ], 24 | [ 25 | "value", 26 | "Bytea" 27 | ] 28 | ] 29 | } 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | "Bytea" 36 | ] 37 | }, 38 | "nullable": [] 39 | }, 40 | "hash": "21f0f4c2ae0e88b99684823b83ce6126c218cec3badc8126492aab8fc7042109" 41 | } 42 | -------------------------------------------------------------------------------- /.sqlx/query-2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n UPDATE users\n SET password_hash = $1\n WHERE user_id = $2\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Uuid" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-33b11051e779866db9aeb86d28a59db07a94323ffdc59a5a2c1da694ebe9a65f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT username\n FROM users\n WHERE user_id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "username", 9 | "type_info": "Text" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Uuid" 15 | ] 16 | }, 17 | "nullable": [ 18 | false 19 | ] 20 | }, 21 | "hash": "33b11051e779866db9aeb86d28a59db07a94323ffdc59a5a2c1da694ebe9a65f" 22 | } 23 | -------------------------------------------------------------------------------- /.sqlx/query-38d1a12165ad4f50d8fbd4fc92376d9cc243dcc344c67b37f7fef13c6589e1eb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT title, text_content, html_content\n FROM newsletter_issues\n WHERE\n newsletter_issue_id = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "title", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "text_content", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "html_content", 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Uuid" 25 | ] 26 | }, 27 | "nullable": [ 28 | false, 29 | false, 30 | false 31 | ] 32 | }, 33 | "hash": "38d1a12165ad4f50d8fbd4fc92376d9cc243dcc344c67b37f7fef13c6589e1eb" 34 | } 35 | -------------------------------------------------------------------------------- /.sqlx/query-51c9c995452d3359e3da7e2f2ff8a6e68690f740a36d2a32ec7c40b08931ebdb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at, status)\n VALUES ($1, $2, $3, $4, 'pending_confirmation')\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Text", 10 | "Text", 11 | "Timestamptz" 12 | ] 13 | }, 14 | "nullable": [] 15 | }, 16 | "hash": "51c9c995452d3359e3da7e2f2ff8a6e68690f740a36d2a32ec7c40b08931ebdb" 17 | } 18 | -------------------------------------------------------------------------------- /.sqlx/query-753c8ecfac0ea7d052e60cb582e3b3ebac5e50eb133152712ca18ab5d5e202f3.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n INSERT INTO subscription_tokens (subscription_token, subscriber_id)\n VALUES ($1, $2)\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Uuid" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "753c8ecfac0ea7d052e60cb582e3b3ebac5e50eb133152712ca18ab5d5e202f3" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-9ab6536d2bf619381573b3bf13507d53b2e9cf50051e51c803e916f25b51abd2.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT email, name, status FROM subscriptions", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "email", 9 | "type_info": "Text" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "name", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "status", 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [] 24 | }, 25 | "nullable": [ 26 | false, 27 | false, 28 | false 29 | ] 30 | }, 31 | "hash": "9ab6536d2bf619381573b3bf13507d53b2e9cf50051e51c803e916f25b51abd2" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-9f103f7d6dfa569bafce4546e6e610f3d31b95fe81f96ea72575b27ddfea796e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT \n response_status_code as \"response_status_code!\", \n response_headers as \"response_headers!: Vec\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE \n user_id = $1 AND\n idempotency_key = $2\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "response_status_code!", 9 | "type_info": "Int2" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "response_headers!: Vec", 14 | "type_info": { 15 | "Custom": { 16 | "name": "header_pair[]", 17 | "kind": { 18 | "Array": { 19 | "Custom": { 20 | "name": "header_pair", 21 | "kind": { 22 | "Composite": [ 23 | [ 24 | "name", 25 | "Text" 26 | ], 27 | [ 28 | "value", 29 | "Bytea" 30 | ] 31 | ] 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | }, 39 | { 40 | "ordinal": 2, 41 | "name": "response_body!", 42 | "type_info": "Bytea" 43 | } 44 | ], 45 | "parameters": { 46 | "Left": [ 47 | "Uuid", 48 | "Text" 49 | ] 50 | }, 51 | "nullable": [ 52 | true, 53 | true, 54 | true 55 | ] 56 | }, 57 | "hash": "9f103f7d6dfa569bafce4546e6e610f3d31b95fe81f96ea72575b27ddfea796e" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-aa682ff5c6485c4faa8168322413294a282ddcc0ef4e38ca3980e6fc7c00c87c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id, \n subscriber_email\n )\n SELECT $1, email\n FROM subscriptions\n WHERE status = 'confirmed'\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "aa682ff5c6485c4faa8168322413294a282ddcc0ef4e38ca3980e6fc7c00c87c" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-aa6ec2d18c8536eb8340bdf02a833440ff7954c503133ed99ebd6190822edf04.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "ALTER TABLE subscriptions DROP COLUMN email;", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [] 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "aa6ec2d18c8536eb8340bdf02a833440ff7954c503133ed99ebd6190822edf04" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "user_id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "password_hash", 14 | "type_info": "Text" 15 | } 16 | ], 17 | "parameters": { 18 | "Left": [ 19 | "Text" 20 | ] 21 | }, 22 | "nullable": [ 23 | false, 24 | false 25 | ] 26 | }, 27 | "hash": "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58" 28 | } 29 | -------------------------------------------------------------------------------- /.sqlx/query-ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "subscriber_id", 9 | "type_info": "Uuid" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text" 15 | ] 16 | }, 17 | "nullable": [ 18 | false 19 | ] 20 | }, 21 | "hash": "ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f" 22 | } 23 | -------------------------------------------------------------------------------- /.sqlx/query-c00b32b331e0444b4bb0cd823b71a8c7ed3a3c8f2b8db3b12c6fbc434aa4d34b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "\n DELETE FROM issue_delivery_queue\n WHERE \n newsletter_issue_id = $1 AND\n subscriber_email = $2 \n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "c00b32b331e0444b4bb0cd823b71a8c7ed3a3c8f2b8db3b12c6fbc434aa4d34b" 15 | } 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zero2prod" 3 | version = "0.1.0" 4 | authors = ["LukeMathWalker "] 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | [lib] 9 | path = "src/lib.rs" 10 | 11 | [[bin]] 12 | path = "src/main.rs" 13 | name = "zero2prod" 14 | 15 | [dependencies] 16 | actix-web = "4" 17 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 18 | serde = "1.0.115" 19 | config = { version = "0.14", default-features = false, features = ["yaml"] } 20 | sqlx = { version = "0.8", default-features = false, features = [ 21 | "runtime-tokio-rustls", 22 | "macros", 23 | "postgres", 24 | "uuid", 25 | "chrono", 26 | "migrate", 27 | ] } 28 | uuid = { version = "1", features = ["v4", "serde"] } 29 | chrono = { version = "0.4.22", default-features = false, features = ["clock"] } 30 | reqwest = { version = "0.12", default-features = false, features = [ 31 | "json", 32 | "rustls-tls", 33 | "cookies", 34 | ] } 35 | log = "0.4" 36 | tracing = "0.1.19" 37 | tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } 38 | tracing-bunyan-formatter = "0.3.1" 39 | thiserror = "1.0.24" 40 | serde-aux = "4" 41 | unicode-segmentation = "1.7.1" 42 | rand = { version = "0.8", features = ["std_rng"] } 43 | anyhow = "1.0.40" 44 | base64 = "0.22.0" 45 | argon2 = { version = "0.5", features = ["std"] } 46 | validator = "0.18" 47 | tracing-log = "0.2.0" 48 | tracing-actix-web = "0.7" 49 | secrecy = { version = "0.8", features = ["serde"] } 50 | actix-web-flash-messages = { version = "0.5", features = ["cookies"] } 51 | actix-session = { version = "0.10", features = ["redis-session-rustls"] } 52 | serde_json = "1" 53 | 54 | [dev-dependencies] 55 | quickcheck = "1.0.3" 56 | quickcheck_macros = "1" 57 | fake = "2.9" 58 | wiremock = "0.6" 59 | serde_json = "1.0.61" 60 | serde_urlencoded = "0.7.1" 61 | linkify = "0.10" 62 | claims = "0.7" 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lukemathwalker/cargo-chef:latest-rust-1.80.1 as chef 2 | WORKDIR /app 3 | RUN apt update && apt install lld clang -y 4 | 5 | FROM chef as planner 6 | COPY . . 7 | # Compute a lock-like file for our project 8 | RUN cargo chef prepare --recipe-path recipe.json 9 | 10 | FROM chef as builder 11 | COPY --from=planner /app/recipe.json recipe.json 12 | # Build our project dependencies, not our application! 13 | RUN cargo chef cook --release --recipe-path recipe.json 14 | COPY . . 15 | ENV SQLX_OFFLINE true 16 | # Build our project 17 | RUN cargo build --release --bin zero2prod 18 | 19 | FROM debian:bookworm-slim AS runtime 20 | WORKDIR /app 21 | RUN apt-get update -y \ 22 | && apt-get install -y --no-install-recommends openssl ca-certificates \ 23 | # Clean up 24 | && apt-get autoremove -y \ 25 | && apt-get clean -y \ 26 | && rm -rf /var/lib/apt/lists/* 27 | COPY --from=builder /app/target/release/zero2prod zero2prod 28 | COPY configuration configuration 29 | ENV APP_ENVIRONMENT production 30 | ENTRYPOINT ["./zero2prod"] 31 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Luca Palmieri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zero To Production In Rust 2 | 3 |
4 | 5 | [Zero To Production In Rust](https://zero2prod.com) is an opinionated introduction to backend development using Rust. 6 | 7 | This repository serves as supplementary material for [the book](https://zero2prod.com/): it hosts several snapshots of the codebase for our email newsletter project as it evolves throughout the book. 8 | 9 | ## Chapter snapshots 10 | 11 | The [`main`](https://github.com/LukeMathWalker/zero-to-production) branch shows the project at the end of the book. 12 | 13 | You can browse the project at the end of previous chapters by switching to their dedicated branches: 14 | 15 | - [Chapter 3, Part 0](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-03-part0) 16 | - [Chapter 3, Part 1](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-03-part1) 17 | - [Chapter 4](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-04) 18 | - [Chapter 5](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-05) 19 | - [Chapter 6, Part 0](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-06-part0) 20 | - [Chapter 6, Part 1](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-06-part1) 21 | - [Chapter 7, Part 0](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-07-part0) 22 | - [Chapter 7, Part 1](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-07-part1) 23 | - [Chapter 7, Part 2](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-07-part2) 24 | - [Chapter 8](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-08) 25 | - [Chapter 9](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-09) 26 | - [Chapter 10, Part 0](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-10-part0) 27 | - [Chapter 10, Part 1](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-10-part1) 28 | - [Chapter 10, Part 2](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-10-part2) 29 | - [Chapter 10, Part 3](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-10-part3) 30 | - [Chapter 11](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-11) 31 | 32 | ## Pre-requisites 33 | 34 | You'll need to install: 35 | 36 | - [Rust](https://www.rust-lang.org/tools/install) 37 | - [Docker](https://docs.docker.com/get-docker/) 38 | 39 | There are also some OS-specific requirements. 40 | 41 | ### Windows 42 | 43 | ```bash 44 | cargo install -f cargo-binutils 45 | rustup component add llvm-tools-preview 46 | ``` 47 | 48 | ``` 49 | cargo install --version="~0.7" sqlx-cli --no-default-features --features rustls,postgres 50 | ``` 51 | 52 | ### Linux 53 | 54 | ```bash 55 | # Ubuntu 56 | sudo apt-get install lld clang libssl-dev postgresql-client 57 | # Arch 58 | sudo pacman -S lld clang postgresql 59 | ``` 60 | 61 | ``` 62 | cargo install --version="~0.7" sqlx-cli --no-default-features --features rustls,postgres 63 | ``` 64 | 65 | ### MacOS 66 | 67 | ```bash 68 | brew install michaeleisel/zld/zld 69 | ``` 70 | 71 | ``` 72 | cargo install --version="~0.7" sqlx-cli --no-default-features --features rustls,postgres 73 | ``` 74 | 75 | ## How to build 76 | 77 | Launch a (migrated) Postgres database via Docker: 78 | 79 | ```bash 80 | ./scripts/init_db.sh 81 | ``` 82 | 83 | Launch a Redis instance via Docker: 84 | 85 | ```bash 86 | ./scripts/init_redis.sh 87 | ``` 88 | 89 | Launch `cargo`: 90 | 91 | ```bash 92 | cargo build 93 | ``` 94 | 95 | You can now try with opening a browser on http://127.0.0.1:8000/login after 96 | having launch the web server with `cargo run`. 97 | 98 | There is a default `admin` account with password 99 | `everythinghastostartsomewhere`. The available entrypoints are listed in 100 | [src/startup.rs](https://github.com/LukeMathWalker/zero-to-production/blob/6bd30650cb8670a146819a342ccefd3d73ed5085/src/startup.rs#L92) 101 | 102 | ## How to test 103 | 104 | Launch a (migrated) Postgres database via Docker: 105 | 106 | ```bash 107 | ./scripts/init_db.sh 108 | ``` 109 | 110 | Launch a Redis instance via Docker: 111 | 112 | ```bash 113 | ./scripts/init_redis.sh 114 | ``` 115 | 116 | Launch `cargo`: 117 | 118 | ```bash 119 | cargo test 120 | ``` 121 | -------------------------------------------------------------------------------- /configuration/base.yaml: -------------------------------------------------------------------------------- 1 | application: 2 | port: 8000 3 | host: 0.0.0.0 4 | hmac_secret: "super-long-and-secret-random-key-needed-to-verify-message-integrity" 5 | database: 6 | host: "127.0.0.1" 7 | port: 5432 8 | username: "postgres" 9 | password: "password" 10 | database_name: "newsletter" 11 | require_ssl: false 12 | email_client: 13 | base_url: "localhost" 14 | sender_email: "test@gmail.com" 15 | authorization_token: "my-secret-token" 16 | timeout_milliseconds: 10000 17 | redis_uri: "redis://127.0.0.1:6379" -------------------------------------------------------------------------------- /configuration/local.yaml: -------------------------------------------------------------------------------- 1 | application: 2 | host: 127.0.0.1 3 | base_url: "http://127.0.0.1" 4 | database: 5 | require_ssl: false 6 | -------------------------------------------------------------------------------- /configuration/production.yaml: -------------------------------------------------------------------------------- 1 | application: 2 | host: 0.0.0.0 3 | database: 4 | require_ssl: true 5 | email_client: 6 | base_url: "https://api.postmarkapp.com" 7 | -------------------------------------------------------------------------------- /migrations/20200823135036_create_subscriptions_table.sql: -------------------------------------------------------------------------------- 1 | -- Create Subscriptions Table 2 | CREATE TABLE subscriptions( 3 | id uuid NOT NULL, 4 | PRIMARY KEY (id), 5 | email TEXT NOT NULL UNIQUE, 6 | name TEXT NOT NULL, 7 | subscribed_at timestamptz NOT NULL 8 | ); -------------------------------------------------------------------------------- /migrations/20210307181858_add_status_to_subscriptions.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE subscriptions ADD COLUMN status TEXT NULL; -------------------------------------------------------------------------------- /migrations/20210307184428_make_status_not_null_in_subscriptions.sql: -------------------------------------------------------------------------------- 1 | -- We wrap the whole migration in a transaction to make sure 2 | -- it succeeds or fails atomically. 3 | -- `sqlx` does not do it automatically for us. 4 | BEGIN; 5 | -- Backfill `status` for historical entries 6 | UPDATE subscriptions 7 | SET status = 'confirmed' 8 | WHERE status IS NULL; 9 | -- Make `status` mandatory 10 | ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL; 11 | COMMIT; 12 | -------------------------------------------------------------------------------- /migrations/20210307185410_create_subscription_tokens_table.sql: -------------------------------------------------------------------------------- 1 | -- Create Subscription Tokens Table 2 | CREATE TABLE subscription_tokens( 3 | subscription_token TEXT NOT NULL, 4 | subscriber_id uuid NOT NULL REFERENCES subscriptions (id), 5 | PRIMARY KEY (subscription_token) 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/20210815112026_create_users_table.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | CREATE TABLE users( 3 | user_id uuid PRIMARY KEY, 4 | username TEXT NOT NULL UNIQUE, 5 | password TEXT NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/20210822143736_rename_password_column.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users RENAME password TO password_hash; 2 | -------------------------------------------------------------------------------- /migrations/20210829175741_add_salt_to_users.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD COLUMN salt TEXT NOT NULL; 2 | -------------------------------------------------------------------------------- /migrations/20210829200701_remove_salt_from_users.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users DROP COLUMN salt; 2 | -------------------------------------------------------------------------------- /migrations/20220312175058_seed_user.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO users (user_id, username, password_hash) 2 | VALUES ( 3 | 'ddf8994f-d522-4659-8d02-c1d479057be6', 4 | 'admin', 5 | '$argon2id$v=19$m=15000,t=2,p=1$OEx/rcq+3ts//WUDzGNl2g$Am8UFBA4w5NJEmAtquGvBmAlu92q/VQcaoL5AyJPfc8' 6 | ); -------------------------------------------------------------------------------- /migrations/20220313182312_create_idempotency_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE header_pair AS ( 2 | name TEXT, 3 | value BYTEA 4 | ); 5 | 6 | CREATE TABLE idempotency ( 7 | user_id uuid NOT NULL REFERENCES users(user_id), 8 | idempotency_key TEXT NOT NULL, 9 | response_status_code SMALLINT NOT NULL, 10 | response_headers header_pair[] NOT NULL, 11 | response_body BYTEA NOT NULL, 12 | created_at timestamptz NOT NULL, 13 | PRIMARY KEY(user_id, idempotency_key) 14 | ); -------------------------------------------------------------------------------- /migrations/20220313184809_relax_null_checks_on_idempotency.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE idempotency ALTER COLUMN response_status_code DROP NOT NULL; 2 | ALTER TABLE idempotency ALTER COLUMN response_body DROP NOT NULL; 3 | ALTER TABLE idempotency ALTER COLUMN response_headers DROP NOT NULL; -------------------------------------------------------------------------------- /migrations/20220313190920_create_newsletter_issues_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE newsletter_issues ( 2 | newsletter_issue_id uuid NOT NULL, 3 | title TEXT NOT NULL, 4 | text_content TEXT NOT NULL, 5 | html_content TEXT NOT NULL, 6 | published_at TEXT NOT NULL, 7 | PRIMARY KEY(newsletter_issue_id) 8 | ); 9 | -------------------------------------------------------------------------------- /migrations/20220313191254_create_issue_delivery_queue_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE issue_delivery_queue ( 2 | newsletter_issue_id uuid NOT NULL REFERENCES newsletter_issues (newsletter_issue_id), 3 | subscriber_email TEXT NOT NULL, 4 | PRIMARY KEY(newsletter_issue_id, subscriber_email) 5 | ); -------------------------------------------------------------------------------- /scripts/init_db.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -eo pipefail 4 | 5 | if ! [ -x "$(command -v sqlx)" ]; then 6 | echo >&2 "Error: sqlx is not installed." 7 | echo >&2 "Use:" 8 | echo >&2 " cargo install --version='~0.8' sqlx-cli --no-default-features --features rustls,postgres" 9 | echo >&2 "to install it." 10 | exit 1 11 | fi 12 | 13 | # Check if a custom parameter has been set, otherwise use default values 14 | DB_PORT="${DB_PORT:=5432}" 15 | SUPERUSER="${SUPERUSER:=postgres}" 16 | SUPERUSER_PWD="${SUPERUSER_PWD:=password}" 17 | APP_USER="${APP_USER:=app}" 18 | APP_USER_PWD="${APP_USER_PWD:=secret}" 19 | APP_DB_NAME="${APP_DB_NAME:=newsletter}" 20 | 21 | # Allow to skip Docker if a dockerized Postgres database is already running 22 | if [[ -z "${SKIP_DOCKER}" ]] 23 | then 24 | # if a postgres container is running, print instructions to kill it and exit 25 | RUNNING_POSTGRES_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.ID}}') 26 | if [[ -n $RUNNING_POSTGRES_CONTAINER ]]; then 27 | echo >&2 "there is a postgres container already running, kill it with" 28 | echo >&2 " docker kill ${RUNNING_POSTGRES_CONTAINER}" 29 | exit 1 30 | fi 31 | CONTAINER_NAME="postgres_$(date '+%s')" 32 | # Launch postgres using Docker 33 | docker run \ 34 | --env POSTGRES_USER=${SUPERUSER} \ 35 | --env POSTGRES_PASSWORD=${SUPERUSER_PWD} \ 36 | --health-cmd="pg_isready -U ${SUPERUSER} || exit 1" \ 37 | --health-interval=1s \ 38 | --health-timeout=5s \ 39 | --health-retries=5 \ 40 | --publish "${DB_PORT}":5432 \ 41 | --detach \ 42 | --name "${CONTAINER_NAME}" \ 43 | postgres -N 1000 44 | # ^ Increased maximum number of connections for testing purposes 45 | 46 | until [ \ 47 | "$(docker inspect -f "{{.State.Health.Status}}" ${CONTAINER_NAME})" == \ 48 | "healthy" \ 49 | ]; do 50 | >&2 echo "Postgres is still unavailable - sleeping" 51 | sleep 1 52 | done 53 | 54 | # Create the application user 55 | CREATE_QUERY="CREATE USER ${APP_USER} WITH PASSWORD '${APP_USER_PWD}';" 56 | docker exec -it "${CONTAINER_NAME}" psql -U "${SUPERUSER}" -c "${CREATE_QUERY}" 57 | 58 | # Grant create db privileges to the app user 59 | GRANT_QUERY="ALTER USER ${APP_USER} CREATEDB;" 60 | docker exec -it "${CONTAINER_NAME}" psql -U "${SUPERUSER}" -c "${GRANT_QUERY}" 61 | fi 62 | 63 | >&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations now!" 64 | 65 | # Create the application database 66 | DATABASE_URL=postgres://${APP_USER}:${APP_USER_PWD}@localhost:${DB_PORT}/${APP_DB_NAME} 67 | export DATABASE_URL 68 | sqlx database create 69 | sqlx migrate run 70 | 71 | >&2 echo "Postgres has been migrated, ready to go!" 72 | -------------------------------------------------------------------------------- /scripts/init_redis.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -eo pipefail 4 | 5 | # if a redis container is running, print instructions to kill it and exit 6 | RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}') 7 | if [[ -n $RUNNING_CONTAINER ]]; then 8 | echo >&2 "there is a redis container already running, kill it with" 9 | echo >&2 " docker kill ${RUNNING_CONTAINER}" 10 | exit 1 11 | fi 12 | 13 | # Launch Redis using Docker 14 | docker run \ 15 | -p "6379:6379" \ 16 | -d \ 17 | --name "redis_$(date '+%s')" \ 18 | redis:7 19 | 20 | >&2 echo "Redis is ready to go!" -------------------------------------------------------------------------------- /spec.yaml: -------------------------------------------------------------------------------- 1 | name: zero2prod 2 | # See https://www.digitalocean.com/docs/app-platform/#regional-availability for the available options 3 | # You can get region slugs from https://www.digitalocean.com/docs/platform/availability-matrix/ 4 | # `fra` stands for Frankfurt (Germany - EU) 5 | region: fra 6 | services: 7 | - name: zero2prod 8 | # Relative to the repository root 9 | dockerfile_path: Dockerfile 10 | source_dir: . 11 | github: 12 | branch: main 13 | deploy_on_push: true 14 | repo: LukeMathWalker/zero-to-production 15 | # Active probe used by DigitalOcean's to ensure our application is healthy 16 | health_check: 17 | # The path to our health check endpoint! It turned out to be useful in the end! 18 | http_path: /health_check 19 | # The port the application will be listening on for incoming requests 20 | # It should match what we specify in our configuration.yaml file! 21 | http_port: 8000 22 | # For production workloads we'd go for at least two! 23 | instance_count: 1 24 | # Let's keep the bill lean for now... 25 | instance_size_slug: basic-xxs 26 | # All incoming requests should be routed to our app 27 | routes: 28 | - path: / 29 | envs: 30 | - key: APP_APPLICATION__BASE_URL 31 | scope: RUN_TIME 32 | value: ${APP_URL} 33 | - key: APP_DATABASE__USERNAME 34 | scope: RUN_TIME 35 | value: ${newsletter.USERNAME} 36 | - key: APP_DATABASE__PASSWORD 37 | scope: RUN_TIME 38 | value: ${newsletter.PASSWORD} 39 | - key: APP_DATABASE__HOST 40 | scope: RUN_TIME 41 | value: ${newsletter.HOSTNAME} 42 | - key: APP_DATABASE__PORT 43 | scope: RUN_TIME 44 | value: ${newsletter.PORT} 45 | - key: APP_DATABASE__DATABASE_NAME 46 | scope: RUN_TIME 47 | value: ${newsletter.DATABASE} 48 | databases: 49 | # PG = Postgres 50 | - engine: PG 51 | # Database name 52 | name: newsletter 53 | # Again, let's keep the bill lean 54 | num_nodes: 1 55 | size: db-s-dev-database 56 | # Postgres version - using the latest here 57 | version: "14" -------------------------------------------------------------------------------- /src/authentication/middleware.rs: -------------------------------------------------------------------------------- 1 | use crate::session_state::TypedSession; 2 | use crate::utils::{e500, see_other}; 3 | use actix_web::body::MessageBody; 4 | use actix_web::dev::{ServiceRequest, ServiceResponse}; 5 | use actix_web::error::InternalError; 6 | use actix_web::middleware::Next; 7 | use actix_web::{FromRequest, HttpMessage}; 8 | use std::ops::Deref; 9 | use uuid::Uuid; 10 | 11 | #[derive(Copy, Clone, Debug)] 12 | pub struct UserId(Uuid); 13 | 14 | impl std::fmt::Display for UserId { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | self.0.fmt(f) 17 | } 18 | } 19 | 20 | impl Deref for UserId { 21 | type Target = Uuid; 22 | 23 | fn deref(&self) -> &Self::Target { 24 | &self.0 25 | } 26 | } 27 | 28 | pub async fn reject_anonymous_users( 29 | mut req: ServiceRequest, 30 | next: Next, 31 | ) -> Result, actix_web::Error> { 32 | let session = { 33 | let (http_request, payload) = req.parts_mut(); 34 | TypedSession::from_request(http_request, payload).await 35 | }?; 36 | 37 | match session.get_user_id().map_err(e500)? { 38 | Some(user_id) => { 39 | req.extensions_mut().insert(UserId(user_id)); 40 | next.call(req).await 41 | } 42 | None => { 43 | let response = see_other("/login"); 44 | let e = anyhow::anyhow!("The user has not logged in"); 45 | Err(InternalError::from_response(e, response).into()) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/authentication/mod.rs: -------------------------------------------------------------------------------- 1 | mod middleware; 2 | mod password; 3 | pub use middleware::reject_anonymous_users; 4 | pub use middleware::UserId; 5 | pub use password::{change_password, validate_credentials, AuthError, Credentials}; 6 | -------------------------------------------------------------------------------- /src/authentication/password.rs: -------------------------------------------------------------------------------- 1 | use crate::telemetry::spawn_blocking_with_tracing; 2 | use anyhow::Context; 3 | use argon2::password_hash::SaltString; 4 | use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version}; 5 | use secrecy::{ExposeSecret, Secret}; 6 | use sqlx::PgPool; 7 | 8 | #[derive(thiserror::Error, Debug)] 9 | pub enum AuthError { 10 | #[error("Invalid credentials.")] 11 | InvalidCredentials(#[source] anyhow::Error), 12 | #[error(transparent)] 13 | UnexpectedError(#[from] anyhow::Error), 14 | } 15 | 16 | pub struct Credentials { 17 | pub username: String, 18 | pub password: Secret, 19 | } 20 | 21 | #[tracing::instrument(name = "Get stored credentials", skip(username, pool))] 22 | async fn get_stored_credentials( 23 | username: &str, 24 | pool: &PgPool, 25 | ) -> Result)>, anyhow::Error> { 26 | let row = sqlx::query!( 27 | r#" 28 | SELECT user_id, password_hash 29 | FROM users 30 | WHERE username = $1 31 | "#, 32 | username, 33 | ) 34 | .fetch_optional(pool) 35 | .await 36 | .context("Failed to performed a query to retrieve stored credentials.")? 37 | .map(|row| (row.user_id, Secret::new(row.password_hash))); 38 | Ok(row) 39 | } 40 | 41 | #[tracing::instrument(name = "Validate credentials", skip(credentials, pool))] 42 | pub async fn validate_credentials( 43 | credentials: Credentials, 44 | pool: &PgPool, 45 | ) -> Result { 46 | let mut user_id = None; 47 | let mut expected_password_hash = Secret::new( 48 | "$argon2id$v=19$m=15000,t=2,p=1$\ 49 | gZiV/M1gPc22ElAH/Jh1Hw$\ 50 | CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno" 51 | .to_string(), 52 | ); 53 | 54 | if let Some((stored_user_id, stored_password_hash)) = 55 | get_stored_credentials(&credentials.username, pool).await? 56 | { 57 | user_id = Some(stored_user_id); 58 | expected_password_hash = stored_password_hash; 59 | } 60 | 61 | spawn_blocking_with_tracing(move || { 62 | verify_password_hash(expected_password_hash, credentials.password) 63 | }) 64 | .await 65 | .context("Failed to spawn blocking task.")??; 66 | 67 | user_id 68 | .ok_or_else(|| anyhow::anyhow!("Unknown username.")) 69 | .map_err(AuthError::InvalidCredentials) 70 | } 71 | 72 | #[tracing::instrument( 73 | name = "Validate credentials", 74 | skip(expected_password_hash, password_candidate) 75 | )] 76 | fn verify_password_hash( 77 | expected_password_hash: Secret, 78 | password_candidate: Secret, 79 | ) -> Result<(), AuthError> { 80 | let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret()) 81 | .context("Failed to parse hash in PHC string format.")?; 82 | 83 | Argon2::default() 84 | .verify_password( 85 | password_candidate.expose_secret().as_bytes(), 86 | &expected_password_hash, 87 | ) 88 | .context("Invalid password.") 89 | .map_err(AuthError::InvalidCredentials) 90 | } 91 | 92 | #[tracing::instrument(name = "Change password", skip(password, pool))] 93 | pub async fn change_password( 94 | user_id: uuid::Uuid, 95 | password: Secret, 96 | pool: &PgPool, 97 | ) -> Result<(), anyhow::Error> { 98 | let password_hash = spawn_blocking_with_tracing(move || compute_password_hash(password)) 99 | .await? 100 | .context("Failed to hash password")?; 101 | sqlx::query!( 102 | r#" 103 | UPDATE users 104 | SET password_hash = $1 105 | WHERE user_id = $2 106 | "#, 107 | password_hash.expose_secret(), 108 | user_id 109 | ) 110 | .execute(pool) 111 | .await 112 | .context("Failed to change user's password in the database.")?; 113 | Ok(()) 114 | } 115 | 116 | fn compute_password_hash(password: Secret) -> Result, anyhow::Error> { 117 | let salt = SaltString::generate(&mut rand::thread_rng()); 118 | let password_hash = Argon2::new( 119 | Algorithm::Argon2id, 120 | Version::V0x13, 121 | Params::new(15000, 2, 1, None).unwrap(), 122 | ) 123 | .hash_password(password.expose_secret().as_bytes(), &salt)? 124 | .to_string(); 125 | Ok(Secret::new(password_hash)) 126 | } 127 | -------------------------------------------------------------------------------- /src/configuration.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::SubscriberEmail; 2 | use crate::email_client::EmailClient; 3 | use secrecy::{ExposeSecret, Secret}; 4 | use serde_aux::field_attributes::deserialize_number_from_string; 5 | use sqlx::postgres::{PgConnectOptions, PgSslMode}; 6 | use std::convert::{TryFrom, TryInto}; 7 | 8 | #[derive(serde::Deserialize, Clone)] 9 | pub struct Settings { 10 | pub database: DatabaseSettings, 11 | pub application: ApplicationSettings, 12 | pub email_client: EmailClientSettings, 13 | pub redis_uri: Secret, 14 | } 15 | 16 | #[derive(serde::Deserialize, Clone)] 17 | pub struct ApplicationSettings { 18 | #[serde(deserialize_with = "deserialize_number_from_string")] 19 | pub port: u16, 20 | pub host: String, 21 | pub base_url: String, 22 | pub hmac_secret: Secret, 23 | } 24 | 25 | #[derive(serde::Deserialize, Clone)] 26 | pub struct DatabaseSettings { 27 | pub username: String, 28 | pub password: Secret, 29 | #[serde(deserialize_with = "deserialize_number_from_string")] 30 | pub port: u16, 31 | pub host: String, 32 | pub database_name: String, 33 | pub require_ssl: bool, 34 | } 35 | 36 | impl DatabaseSettings { 37 | pub fn connect_options(&self) -> PgConnectOptions { 38 | let ssl_mode = if self.require_ssl { 39 | PgSslMode::Require 40 | } else { 41 | PgSslMode::Prefer 42 | }; 43 | PgConnectOptions::new() 44 | .host(&self.host) 45 | .username(&self.username) 46 | .password(self.password.expose_secret()) 47 | .port(self.port) 48 | .ssl_mode(ssl_mode) 49 | .database(&self.database_name) 50 | } 51 | } 52 | 53 | #[derive(serde::Deserialize, Clone)] 54 | pub struct EmailClientSettings { 55 | pub base_url: String, 56 | pub sender_email: String, 57 | pub authorization_token: Secret, 58 | #[serde(deserialize_with = "deserialize_number_from_string")] 59 | pub timeout_milliseconds: u64, 60 | } 61 | 62 | impl EmailClientSettings { 63 | pub fn client(self) -> EmailClient { 64 | let sender_email = self.sender().expect("Invalid sender email address."); 65 | let timeout = self.timeout(); 66 | EmailClient::new( 67 | self.base_url, 68 | sender_email, 69 | self.authorization_token, 70 | timeout, 71 | ) 72 | } 73 | 74 | pub fn sender(&self) -> Result { 75 | SubscriberEmail::parse(self.sender_email.clone()) 76 | } 77 | 78 | pub fn timeout(&self) -> std::time::Duration { 79 | std::time::Duration::from_millis(self.timeout_milliseconds) 80 | } 81 | } 82 | 83 | pub fn get_configuration() -> Result { 84 | let base_path = std::env::current_dir().expect("Failed to determine the current directory"); 85 | let configuration_directory = base_path.join("configuration"); 86 | 87 | // Detect the running environment. 88 | // Default to `local` if unspecified. 89 | let environment: Environment = std::env::var("APP_ENVIRONMENT") 90 | .unwrap_or_else(|_| "local".into()) 91 | .try_into() 92 | .expect("Failed to parse APP_ENVIRONMENT."); 93 | let environment_filename = format!("{}.yaml", environment.as_str()); 94 | let settings = config::Config::builder() 95 | .add_source(config::File::from( 96 | configuration_directory.join("base.yaml"), 97 | )) 98 | .add_source(config::File::from( 99 | configuration_directory.join(environment_filename), 100 | )) 101 | // Add in settings from environment variables (with a prefix of APP and '__' as separator) 102 | // E.g. `APP_APPLICATION__PORT=5001 would set `Settings.application.port` 103 | .add_source( 104 | config::Environment::with_prefix("APP") 105 | .prefix_separator("_") 106 | .separator("__"), 107 | ) 108 | .build()?; 109 | 110 | settings.try_deserialize::() 111 | } 112 | 113 | /// The possible runtime environment for our application. 114 | pub enum Environment { 115 | Local, 116 | Production, 117 | } 118 | 119 | impl Environment { 120 | pub fn as_str(&self) -> &'static str { 121 | match self { 122 | Environment::Local => "local", 123 | Environment::Production => "production", 124 | } 125 | } 126 | } 127 | 128 | impl TryFrom for Environment { 129 | type Error = String; 130 | 131 | fn try_from(s: String) -> Result { 132 | match s.to_lowercase().as_str() { 133 | "local" => Ok(Self::Local), 134 | "production" => Ok(Self::Production), 135 | other => Err(format!( 136 | "{} is not a supported environment. Use either `local` or `production`.", 137 | other 138 | )), 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | mod new_subscriber; 2 | mod subscriber_email; 3 | mod subscriber_name; 4 | 5 | pub use new_subscriber::NewSubscriber; 6 | pub use subscriber_email::SubscriberEmail; 7 | pub use subscriber_name::SubscriberName; 8 | -------------------------------------------------------------------------------- /src/domain/new_subscriber.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::SubscriberEmail; 2 | use crate::domain::SubscriberName; 3 | 4 | pub struct NewSubscriber { 5 | // We are not using `String` anymore! 6 | pub email: SubscriberEmail, 7 | pub name: SubscriberName, 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/subscriber_email.rs: -------------------------------------------------------------------------------- 1 | use validator::ValidateEmail; 2 | 3 | #[derive(Debug)] 4 | pub struct SubscriberEmail(String); 5 | 6 | impl SubscriberEmail { 7 | pub fn parse(s: String) -> Result { 8 | if s.validate_email() { 9 | Ok(Self(s)) 10 | } else { 11 | Err(format!("{} is not a valid subscriber email.", s)) 12 | } 13 | } 14 | } 15 | 16 | impl AsRef for SubscriberEmail { 17 | fn as_ref(&self) -> &str { 18 | &self.0 19 | } 20 | } 21 | 22 | impl std::fmt::Display for SubscriberEmail { 23 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 | self.0.fmt(f) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use super::SubscriberEmail; 31 | use claims::assert_err; 32 | use fake::faker::internet::en::SafeEmail; 33 | use fake::Fake; 34 | use rand::rngs::StdRng; 35 | use rand::SeedableRng; 36 | 37 | #[test] 38 | fn empty_string_is_rejected() { 39 | let email = "".to_string(); 40 | assert_err!(SubscriberEmail::parse(email)); 41 | } 42 | 43 | #[test] 44 | fn email_missing_at_symbol_is_rejected() { 45 | let email = "ursuladomain.com".to_string(); 46 | assert_err!(SubscriberEmail::parse(email)); 47 | } 48 | 49 | #[test] 50 | fn email_missing_subject_is_rejected() { 51 | let email = "@domain.com".to_string(); 52 | assert_err!(SubscriberEmail::parse(email)); 53 | } 54 | 55 | #[derive(Debug, Clone)] 56 | struct ValidEmailFixture(pub String); 57 | 58 | impl quickcheck::Arbitrary for ValidEmailFixture { 59 | fn arbitrary(g: &mut quickcheck::Gen) -> Self { 60 | let mut rng = StdRng::seed_from_u64(u64::arbitrary(g)); 61 | let email = SafeEmail().fake_with_rng(&mut rng); 62 | 63 | Self(email) 64 | } 65 | } 66 | 67 | #[quickcheck_macros::quickcheck] 68 | fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool { 69 | SubscriberEmail::parse(valid_email.0).is_ok() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/domain/subscriber_name.rs: -------------------------------------------------------------------------------- 1 | use unicode_segmentation::UnicodeSegmentation; 2 | 3 | #[derive(Debug)] 4 | pub struct SubscriberName(String); 5 | 6 | impl SubscriberName { 7 | /// Returns an instance of `SubscriberName` if the input satisfies all 8 | /// our validation constraints on subscriber names. 9 | /// It panics otherwise. 10 | pub fn parse(s: String) -> Result { 11 | // `.trim()` returns a view over the input `s` without trailing 12 | // whitespace-like characters. 13 | // `.is_empty` checks if the view contains any character. 14 | let is_empty_or_whitespace = s.trim().is_empty(); 15 | 16 | // A grapheme is defined by the Unicode standard as a "user-perceived" 17 | // character: `å` is a single grapheme, but it is composed of two characters 18 | // (`a` and `̊`). 19 | // 20 | // `graphemes` returns an iterator over the graphemes in the input `s`. 21 | // `true` specifies that we want to use the extended grapheme definition set, 22 | // the recommended one. 23 | let is_too_long = s.graphemes(true).count() > 256; 24 | 25 | // Iterate over all characters in the input `s` to check if any of them matches 26 | // one of the characters in the forbidden array. 27 | let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; 28 | let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g)); 29 | 30 | if is_empty_or_whitespace || is_too_long || contains_forbidden_characters { 31 | Err(format!("{} is not a valid subscriber name.", s)) 32 | } else { 33 | Ok(Self(s)) 34 | } 35 | } 36 | } 37 | 38 | impl AsRef for SubscriberName { 39 | fn as_ref(&self) -> &str { 40 | &self.0 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use crate::domain::SubscriberName; 47 | use claims::{assert_err, assert_ok}; 48 | 49 | #[test] 50 | fn a_256_grapheme_long_name_is_valid() { 51 | let name = "a̐".repeat(256); 52 | assert_ok!(SubscriberName::parse(name)); 53 | } 54 | 55 | #[test] 56 | fn a_name_longer_than_256_graphemes_is_rejected() { 57 | let name = "a".repeat(257); 58 | assert_err!(SubscriberName::parse(name)); 59 | } 60 | 61 | #[test] 62 | fn whitespace_only_names_are_rejected() { 63 | let name = " ".to_string(); 64 | assert_err!(SubscriberName::parse(name)); 65 | } 66 | 67 | #[test] 68 | fn empty_string_is_rejected() { 69 | let name = "".to_string(); 70 | assert_err!(SubscriberName::parse(name)); 71 | } 72 | 73 | #[test] 74 | fn names_containing_an_invalid_character_are_rejected() { 75 | for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] { 76 | let name = name.to_string(); 77 | assert_err!(SubscriberName::parse(name)); 78 | } 79 | } 80 | 81 | #[test] 82 | fn a_valid_name_is_parsed_successfully() { 83 | let name = "Ursula Le Guin".to_string(); 84 | assert_ok!(SubscriberName::parse(name)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/email_client.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::SubscriberEmail; 2 | use reqwest::Client; 3 | use secrecy::{ExposeSecret, Secret}; 4 | 5 | pub struct EmailClient { 6 | http_client: Client, 7 | base_url: String, 8 | sender: SubscriberEmail, 9 | authorization_token: Secret, 10 | } 11 | 12 | impl EmailClient { 13 | pub fn new( 14 | base_url: String, 15 | sender: SubscriberEmail, 16 | authorization_token: Secret, 17 | timeout: std::time::Duration, 18 | ) -> Self { 19 | let http_client = Client::builder().timeout(timeout).build().unwrap(); 20 | Self { 21 | http_client, 22 | base_url, 23 | sender, 24 | authorization_token, 25 | } 26 | } 27 | 28 | pub async fn send_email( 29 | &self, 30 | recipient: &SubscriberEmail, 31 | subject: &str, 32 | html_content: &str, 33 | text_content: &str, 34 | ) -> Result<(), reqwest::Error> { 35 | let url = format!("{}/email", self.base_url); 36 | let request_body = SendEmailRequest { 37 | from: self.sender.as_ref(), 38 | to: recipient.as_ref(), 39 | subject, 40 | html_body: html_content, 41 | text_body: text_content, 42 | }; 43 | self.http_client 44 | .post(&url) 45 | .header( 46 | "X-Postmark-Server-Token", 47 | self.authorization_token.expose_secret(), 48 | ) 49 | .json(&request_body) 50 | .send() 51 | .await? 52 | .error_for_status()?; 53 | Ok(()) 54 | } 55 | } 56 | 57 | #[derive(serde::Serialize)] 58 | #[serde(rename_all = "PascalCase")] 59 | struct SendEmailRequest<'a> { 60 | from: &'a str, 61 | to: &'a str, 62 | subject: &'a str, 63 | html_body: &'a str, 64 | text_body: &'a str, 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use crate::domain::SubscriberEmail; 70 | use crate::email_client::EmailClient; 71 | use claims::{assert_err, assert_ok}; 72 | use fake::faker::internet::en::SafeEmail; 73 | use fake::faker::lorem::en::{Paragraph, Sentence}; 74 | use fake::{Fake, Faker}; 75 | use secrecy::Secret; 76 | use wiremock::matchers::{any, header, header_exists, method, path}; 77 | use wiremock::{Mock, MockServer, Request, ResponseTemplate}; 78 | 79 | struct SendEmailBodyMatcher; 80 | 81 | impl wiremock::Match for SendEmailBodyMatcher { 82 | fn matches(&self, request: &Request) -> bool { 83 | let result: Result = serde_json::from_slice(&request.body); 84 | if let Ok(body) = result { 85 | body.get("From").is_some() 86 | && body.get("To").is_some() 87 | && body.get("Subject").is_some() 88 | && body.get("HtmlBody").is_some() 89 | && body.get("TextBody").is_some() 90 | } else { 91 | false 92 | } 93 | } 94 | } 95 | 96 | /// Generate a random email subject 97 | fn subject() -> String { 98 | Sentence(1..2).fake() 99 | } 100 | 101 | /// Generate a random email content 102 | fn content() -> String { 103 | Paragraph(1..10).fake() 104 | } 105 | 106 | /// Generate a random subscriber email 107 | fn email() -> SubscriberEmail { 108 | SubscriberEmail::parse(SafeEmail().fake()).unwrap() 109 | } 110 | 111 | /// Get a test instance of `EmailClient`. 112 | fn email_client(base_url: String) -> EmailClient { 113 | EmailClient::new( 114 | base_url, 115 | email(), 116 | Secret::new(Faker.fake()), 117 | std::time::Duration::from_millis(200), 118 | ) 119 | } 120 | 121 | #[tokio::test] 122 | async fn send_email_sends_the_expected_request() { 123 | // Arrange 124 | let mock_server = MockServer::start().await; 125 | let email_client = email_client(mock_server.uri()); 126 | 127 | Mock::given(header_exists("X-Postmark-Server-Token")) 128 | .and(header("Content-Type", "application/json")) 129 | .and(path("/email")) 130 | .and(method("POST")) 131 | .and(SendEmailBodyMatcher) 132 | .respond_with(ResponseTemplate::new(200)) 133 | .expect(1) 134 | .mount(&mock_server) 135 | .await; 136 | 137 | // Act 138 | let _ = email_client 139 | .send_email(&email(), &subject(), &content(), &content()) 140 | .await; 141 | 142 | // Assert 143 | } 144 | 145 | #[tokio::test] 146 | async fn send_email_succeeds_if_the_server_returns_200() { 147 | // Arrange 148 | let mock_server = MockServer::start().await; 149 | let email_client = email_client(mock_server.uri()); 150 | 151 | Mock::given(any()) 152 | .respond_with(ResponseTemplate::new(200)) 153 | .expect(1) 154 | .mount(&mock_server) 155 | .await; 156 | 157 | // Act 158 | let outcome = email_client 159 | .send_email(&email(), &subject(), &content(), &content()) 160 | .await; 161 | 162 | // Assert 163 | assert_ok!(outcome); 164 | } 165 | 166 | #[tokio::test] 167 | async fn send_email_fails_if_the_server_returns_500() { 168 | // Arrange 169 | let mock_server = MockServer::start().await; 170 | let email_client = email_client(mock_server.uri()); 171 | 172 | Mock::given(any()) 173 | // Not a 200 anymore! 174 | .respond_with(ResponseTemplate::new(500)) 175 | .expect(1) 176 | .mount(&mock_server) 177 | .await; 178 | 179 | // Act 180 | let outcome = email_client 181 | .send_email(&email(), &subject(), &content(), &content()) 182 | .await; 183 | 184 | // Assert 185 | assert_err!(outcome); 186 | } 187 | 188 | #[tokio::test] 189 | async fn send_email_times_out_if_the_server_takes_too_long() { 190 | // Arrange 191 | let mock_server = MockServer::start().await; 192 | let email_client = email_client(mock_server.uri()); 193 | 194 | let response = ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(180)); 195 | Mock::given(any()) 196 | .respond_with(response) 197 | .expect(1) 198 | .mount(&mock_server) 199 | .await; 200 | 201 | // Act 202 | let outcome = email_client 203 | .send_email(&email(), &subject(), &content(), &content()) 204 | .await; 205 | 206 | // Assert 207 | assert_err!(outcome); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/idempotency/key.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub struct IdempotencyKey(String); 3 | 4 | impl TryFrom for IdempotencyKey { 5 | type Error = anyhow::Error; 6 | 7 | fn try_from(s: String) -> Result { 8 | if s.is_empty() { 9 | anyhow::bail!("The idempotency key cannot be empty"); 10 | } 11 | let max_length = 50; 12 | if s.len() >= max_length { 13 | anyhow::bail!( 14 | "The idempotency key must be shorter 15 | than {max_length} characters" 16 | ); 17 | } 18 | Ok(Self(s)) 19 | } 20 | } 21 | 22 | impl From for String { 23 | fn from(k: IdempotencyKey) -> Self { 24 | k.0 25 | } 26 | } 27 | 28 | impl AsRef for IdempotencyKey { 29 | fn as_ref(&self) -> &str { 30 | &self.0 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/idempotency/mod.rs: -------------------------------------------------------------------------------- 1 | mod key; 2 | mod persistence; 3 | pub use key::IdempotencyKey; 4 | pub use persistence::get_saved_response; 5 | pub use persistence::save_response; 6 | pub use persistence::{try_processing, NextAction}; 7 | -------------------------------------------------------------------------------- /src/idempotency/persistence.rs: -------------------------------------------------------------------------------- 1 | use super::IdempotencyKey; 2 | use actix_web::body::to_bytes; 3 | use actix_web::http::StatusCode; 4 | use actix_web::HttpResponse; 5 | use sqlx::{Executor, PgPool}; 6 | use sqlx::{Postgres, Transaction}; 7 | use uuid::Uuid; 8 | 9 | #[derive(Debug, sqlx::Type)] 10 | #[sqlx(type_name = "header_pair")] 11 | struct HeaderPairRecord { 12 | name: String, 13 | value: Vec, 14 | } 15 | 16 | pub async fn get_saved_response( 17 | pool: &PgPool, 18 | idempotency_key: &IdempotencyKey, 19 | user_id: Uuid, 20 | ) -> Result, anyhow::Error> { 21 | let saved_response = sqlx::query!( 22 | r#" 23 | SELECT 24 | response_status_code as "response_status_code!", 25 | response_headers as "response_headers!: Vec", 26 | response_body as "response_body!" 27 | FROM idempotency 28 | WHERE 29 | user_id = $1 AND 30 | idempotency_key = $2 31 | "#, 32 | user_id, 33 | idempotency_key.as_ref() 34 | ) 35 | .fetch_optional(pool) 36 | .await?; 37 | if let Some(r) = saved_response { 38 | let status_code = StatusCode::from_u16(r.response_status_code.try_into()?)?; 39 | let mut response = HttpResponse::build(status_code); 40 | for HeaderPairRecord { name, value } in r.response_headers { 41 | response.append_header((name, value)); 42 | } 43 | Ok(Some(response.body(r.response_body))) 44 | } else { 45 | Ok(None) 46 | } 47 | } 48 | 49 | pub async fn save_response( 50 | mut transaction: Transaction<'static, Postgres>, 51 | idempotency_key: &IdempotencyKey, 52 | user_id: Uuid, 53 | http_response: HttpResponse, 54 | ) -> Result { 55 | let (response_head, body) = http_response.into_parts(); 56 | let body = to_bytes(body).await.map_err(|e| anyhow::anyhow!("{}", e))?; 57 | let status_code = response_head.status().as_u16() as i16; 58 | let headers = { 59 | let mut h = Vec::with_capacity(response_head.headers().len()); 60 | for (name, value) in response_head.headers().iter() { 61 | let name = name.as_str().to_owned(); 62 | let value = value.as_bytes().to_owned(); 63 | h.push(HeaderPairRecord { name, value }); 64 | } 65 | h 66 | }; 67 | transaction 68 | .execute(sqlx::query_unchecked!( 69 | r#" 70 | UPDATE idempotency 71 | SET 72 | response_status_code = $3, 73 | response_headers = $4, 74 | response_body = $5 75 | WHERE 76 | user_id = $1 AND 77 | idempotency_key = $2 78 | "#, 79 | user_id, 80 | idempotency_key.as_ref(), 81 | status_code, 82 | headers, 83 | body.as_ref() 84 | )) 85 | .await?; 86 | transaction.commit().await?; 87 | 88 | let http_response = response_head.set_body(body).map_into_boxed_body(); 89 | Ok(http_response) 90 | } 91 | 92 | #[allow(clippy::large_enum_variant)] 93 | pub enum NextAction { 94 | // Return transaction for later usage 95 | StartProcessing(Transaction<'static, Postgres>), 96 | ReturnSavedResponse(HttpResponse), 97 | } 98 | 99 | pub async fn try_processing( 100 | pool: &PgPool, 101 | idempotency_key: &IdempotencyKey, 102 | user_id: Uuid, 103 | ) -> Result { 104 | let mut transaction = pool.begin().await?; 105 | let query = sqlx::query!( 106 | r#" 107 | INSERT INTO idempotency ( 108 | user_id, 109 | idempotency_key, 110 | created_at 111 | ) 112 | VALUES ($1, $2, now()) 113 | ON CONFLICT DO NOTHING 114 | "#, 115 | user_id, 116 | idempotency_key.as_ref() 117 | ); 118 | let n_inserted_rows = transaction.execute(query).await?.rows_affected(); 119 | if n_inserted_rows > 0 { 120 | Ok(NextAction::StartProcessing(transaction)) 121 | } else { 122 | let saved_response = get_saved_response(pool, idempotency_key, user_id) 123 | .await? 124 | .ok_or_else(|| anyhow::anyhow!("We expected a saved response, we didn't find it"))?; 125 | Ok(NextAction::ReturnSavedResponse(saved_response)) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/issue_delivery_worker.rs: -------------------------------------------------------------------------------- 1 | use crate::{configuration::Settings, startup::get_connection_pool}; 2 | use crate::{domain::SubscriberEmail, email_client::EmailClient}; 3 | use sqlx::{PgPool, Postgres, Transaction}; 4 | use std::time::Duration; 5 | use tracing::{field::display, Span}; 6 | use uuid::Uuid; 7 | 8 | pub async fn run_worker_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> { 9 | let connection_pool = get_connection_pool(&configuration.database); 10 | let email_client = configuration.email_client.client(); 11 | worker_loop(connection_pool, email_client).await 12 | } 13 | 14 | async fn worker_loop(pool: PgPool, email_client: EmailClient) -> Result<(), anyhow::Error> { 15 | loop { 16 | match try_execute_task(&pool, &email_client).await { 17 | Ok(ExecutionOutcome::EmptyQueue) => { 18 | tokio::time::sleep(Duration::from_secs(10)).await; 19 | } 20 | Err(_) => { 21 | tokio::time::sleep(Duration::from_secs(1)).await; 22 | } 23 | Ok(ExecutionOutcome::TaskCompleted) => {} 24 | } 25 | } 26 | } 27 | 28 | pub enum ExecutionOutcome { 29 | TaskCompleted, 30 | EmptyQueue, 31 | } 32 | 33 | #[tracing::instrument( 34 | skip_all, 35 | fields( 36 | newsletter_issue_id=tracing::field::Empty, 37 | subscriber_email=tracing::field::Empty 38 | ), 39 | err 40 | )] 41 | pub async fn try_execute_task( 42 | pool: &PgPool, 43 | email_client: &EmailClient, 44 | ) -> Result { 45 | let task = dequeue_task(pool).await?; 46 | if task.is_none() { 47 | return Ok(ExecutionOutcome::EmptyQueue); 48 | } 49 | let (transaction, issue_id, email) = task.unwrap(); 50 | Span::current() 51 | .record("newsletter_issue_id", display(issue_id)) 52 | .record("subscriber_email", display(&email)); 53 | match SubscriberEmail::parse(email.clone()) { 54 | Ok(email) => { 55 | let issue = get_issue(pool, issue_id).await?; 56 | if let Err(e) = email_client 57 | .send_email( 58 | &email, 59 | &issue.title, 60 | &issue.html_content, 61 | &issue.text_content, 62 | ) 63 | .await 64 | { 65 | tracing::error!( 66 | error.cause_chain = ?e, 67 | error.message = %e, 68 | "Failed to deliver issue to a confirmed subscriber. \ 69 | Skipping.", 70 | ); 71 | } 72 | } 73 | Err(e) => { 74 | tracing::error!( 75 | error.cause_chain = ?e, 76 | error.message = %e, 77 | "Skipping a confirmed subscriber. \ 78 | Their stored contact details are invalid", 79 | ); 80 | } 81 | } 82 | delete_task(transaction, issue_id, &email).await?; 83 | Ok(ExecutionOutcome::TaskCompleted) 84 | } 85 | 86 | type PgTransaction = Transaction<'static, Postgres>; 87 | 88 | #[tracing::instrument(skip_all)] 89 | async fn dequeue_task( 90 | pool: &PgPool, 91 | ) -> Result, anyhow::Error> { 92 | let mut transaction = pool.begin().await?; 93 | let r = sqlx::query!( 94 | r#" 95 | SELECT newsletter_issue_id, subscriber_email 96 | FROM issue_delivery_queue 97 | FOR UPDATE 98 | SKIP LOCKED 99 | LIMIT 1 100 | "#, 101 | ) 102 | .fetch_optional(&mut *transaction) 103 | .await?; 104 | if let Some(r) = r { 105 | Ok(Some(( 106 | transaction, 107 | r.newsletter_issue_id, 108 | r.subscriber_email, 109 | ))) 110 | } else { 111 | Ok(None) 112 | } 113 | } 114 | 115 | #[tracing::instrument(skip_all)] 116 | async fn delete_task( 117 | mut transaction: PgTransaction, 118 | issue_id: Uuid, 119 | email: &str, 120 | ) -> Result<(), anyhow::Error> { 121 | sqlx::query!( 122 | r#" 123 | DELETE FROM issue_delivery_queue 124 | WHERE 125 | newsletter_issue_id = $1 AND 126 | subscriber_email = $2 127 | "#, 128 | issue_id, 129 | email 130 | ) 131 | .execute(&mut *transaction) 132 | .await?; 133 | transaction.commit().await?; 134 | Ok(()) 135 | } 136 | 137 | struct NewsletterIssue { 138 | title: String, 139 | text_content: String, 140 | html_content: String, 141 | } 142 | 143 | #[tracing::instrument(skip_all)] 144 | async fn get_issue(pool: &PgPool, issue_id: Uuid) -> Result { 145 | let issue = sqlx::query_as!( 146 | NewsletterIssue, 147 | r#" 148 | SELECT title, text_content, html_content 149 | FROM newsletter_issues 150 | WHERE 151 | newsletter_issue_id = $1 152 | "#, 153 | issue_id 154 | ) 155 | .fetch_one(pool) 156 | .await?; 157 | Ok(issue) 158 | } 159 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod authentication; 2 | pub mod configuration; 3 | pub mod domain; 4 | pub mod email_client; 5 | pub mod idempotency; 6 | pub mod issue_delivery_worker; 7 | pub mod routes; 8 | pub mod session_state; 9 | pub mod startup; 10 | pub mod telemetry; 11 | pub mod utils; 12 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Debug, Display}; 2 | use tokio::task::JoinError; 3 | use zero2prod::configuration::get_configuration; 4 | use zero2prod::issue_delivery_worker::run_worker_until_stopped; 5 | use zero2prod::startup::Application; 6 | use zero2prod::telemetry::{get_subscriber, init_subscriber}; 7 | 8 | #[tokio::main] 9 | async fn main() -> anyhow::Result<()> { 10 | let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); 11 | init_subscriber(subscriber); 12 | 13 | let configuration = get_configuration().expect("Failed to read configuration."); 14 | let application = Application::build(configuration.clone()).await?; 15 | let application_task = tokio::spawn(application.run_until_stopped()); 16 | let worker_task = tokio::spawn(run_worker_until_stopped(configuration)); 17 | 18 | tokio::select! { 19 | o = application_task => report_exit("API", o), 20 | o = worker_task => report_exit("Background worker", o), 21 | }; 22 | 23 | Ok(()) 24 | } 25 | 26 | fn report_exit(task_name: &str, outcome: Result, JoinError>) { 27 | match outcome { 28 | Ok(Ok(())) => { 29 | tracing::info!("{} has exited", task_name) 30 | } 31 | Ok(Err(e)) => { 32 | tracing::error!( 33 | error.cause_chain = ?e, 34 | error.message = %e, 35 | "{} failed", 36 | task_name 37 | ) 38 | } 39 | Err(e) => { 40 | tracing::error!( 41 | error.cause_chain = ?e, 42 | error.message = %e, 43 | "{}' task failed to complete", 44 | task_name 45 | ) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/routes/admin/dashboard.rs: -------------------------------------------------------------------------------- 1 | use crate::session_state::TypedSession; 2 | use crate::utils::e500; 3 | use actix_web::http::header::LOCATION; 4 | use actix_web::{http::header::ContentType, web, HttpResponse}; 5 | use anyhow::Context; 6 | use sqlx::PgPool; 7 | use uuid::Uuid; 8 | 9 | pub async fn admin_dashboard( 10 | session: TypedSession, 11 | pool: web::Data, 12 | ) -> Result { 13 | let username = if let Some(user_id) = session.get_user_id().map_err(e500)? { 14 | get_username(user_id, &pool).await.map_err(e500)? 15 | } else { 16 | return Ok(HttpResponse::SeeOther() 17 | .insert_header((LOCATION, "/login")) 18 | .finish()); 19 | }; 20 | Ok(HttpResponse::Ok() 21 | .content_type(ContentType::html()) 22 | .body(format!( 23 | r#" 24 | 25 | 26 | 27 | Admin dashboard 28 | 29 | 30 |

Welcome {username}!

31 |

Available actions:

32 |
    33 |
  1. Change password
  2. 34 |
  3. 35 |
    36 | 37 |
    38 |
  4. 39 |
40 | 41 | "#, 42 | ))) 43 | } 44 | 45 | #[tracing::instrument(name = "Get username", skip(pool))] 46 | pub async fn get_username(user_id: Uuid, pool: &PgPool) -> Result { 47 | let row = sqlx::query!( 48 | r#" 49 | SELECT username 50 | FROM users 51 | WHERE user_id = $1 52 | "#, 53 | user_id, 54 | ) 55 | .fetch_one(pool) 56 | .await 57 | .context("Failed to perform a query to retrieve a username.")?; 58 | Ok(row.username) 59 | } 60 | -------------------------------------------------------------------------------- /src/routes/admin/logout.rs: -------------------------------------------------------------------------------- 1 | use crate::session_state::TypedSession; 2 | use crate::utils::{e500, see_other}; 3 | use actix_web::HttpResponse; 4 | use actix_web_flash_messages::FlashMessage; 5 | 6 | pub async fn log_out(session: TypedSession) -> Result { 7 | if session.get_user_id().map_err(e500)?.is_none() { 8 | Ok(see_other("/login")) 9 | } else { 10 | session.log_out(); 11 | FlashMessage::info("You have successfully logged out.").send(); 12 | Ok(see_other("/login")) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/routes/admin/mod.rs: -------------------------------------------------------------------------------- 1 | mod dashboard; 2 | mod logout; 3 | mod newsletter; 4 | mod password; 5 | 6 | pub use dashboard::admin_dashboard; 7 | pub use logout::log_out; 8 | pub use newsletter::*; 9 | pub use password::*; 10 | -------------------------------------------------------------------------------- /src/routes/admin/newsletter/get.rs: -------------------------------------------------------------------------------- 1 | use actix_web::http::header::ContentType; 2 | use actix_web::HttpResponse; 3 | use actix_web_flash_messages::IncomingFlashMessages; 4 | use std::fmt::Write; 5 | 6 | pub async fn publish_newsletter_form( 7 | flash_messages: IncomingFlashMessages, 8 | ) -> Result { 9 | let mut msg_html = String::new(); 10 | for m in flash_messages.iter() { 11 | writeln!(msg_html, "

{}

", m.content()).unwrap(); 12 | } 13 | let idempotency_key = uuid::Uuid::new_v4(); 14 | Ok(HttpResponse::Ok() 15 | .content_type(ContentType::html()) 16 | .body(format!( 17 | r#" 18 | 19 | 20 | 21 | Publish Newsletter Issue 22 | 23 | 24 | {msg_html} 25 |
26 | 33 |
34 | 42 |
43 | 51 |
52 | 53 | 54 |
55 |

<- Back

56 | 57 | "#, 58 | ))) 59 | } 60 | -------------------------------------------------------------------------------- /src/routes/admin/newsletter/mod.rs: -------------------------------------------------------------------------------- 1 | mod get; 2 | mod post; 3 | 4 | pub use get::publish_newsletter_form; 5 | pub use post::publish_newsletter; 6 | -------------------------------------------------------------------------------- /src/routes/admin/newsletter/post.rs: -------------------------------------------------------------------------------- 1 | use crate::authentication::UserId; 2 | use crate::idempotency::{save_response, try_processing, IdempotencyKey, NextAction}; 3 | use crate::utils::e400; 4 | use crate::utils::{e500, see_other}; 5 | use actix_web::{web, HttpResponse}; 6 | use actix_web_flash_messages::FlashMessage; 7 | use anyhow::Context; 8 | use sqlx::{Executor, PgPool, Postgres, Transaction}; 9 | use uuid::Uuid; 10 | 11 | #[derive(serde::Deserialize)] 12 | pub struct FormData { 13 | title: String, 14 | text_content: String, 15 | html_content: String, 16 | idempotency_key: String, 17 | } 18 | 19 | fn success_message() -> FlashMessage { 20 | FlashMessage::info( 21 | "The newsletter issue has been accepted - \ 22 | emails will go out shortly.", 23 | ) 24 | } 25 | 26 | #[tracing::instrument( 27 | name = "Publish a newsletter issue", 28 | skip_all, 29 | fields(user_id=%&*user_id) 30 | )] 31 | pub async fn publish_newsletter( 32 | form: web::Form, 33 | pool: web::Data, 34 | user_id: web::ReqData, 35 | ) -> Result { 36 | let user_id = user_id.into_inner(); 37 | let FormData { 38 | title, 39 | text_content, 40 | html_content, 41 | idempotency_key, 42 | } = form.0; 43 | let idempotency_key: IdempotencyKey = idempotency_key.try_into().map_err(e400)?; 44 | let mut transaction = match try_processing(&pool, &idempotency_key, *user_id) 45 | .await 46 | .map_err(e500)? 47 | { 48 | NextAction::StartProcessing(t) => t, 49 | NextAction::ReturnSavedResponse(saved_response) => { 50 | success_message().send(); 51 | return Ok(saved_response); 52 | } 53 | }; 54 | let issue_id = insert_newsletter_issue(&mut transaction, &title, &text_content, &html_content) 55 | .await 56 | .context("Failed to store newsletter issue details") 57 | .map_err(e500)?; 58 | enqueue_delivery_tasks(&mut transaction, issue_id) 59 | .await 60 | .context("Failed to enqueue delivery tasks") 61 | .map_err(e500)?; 62 | let response = see_other("/admin/newsletters"); 63 | let response = save_response(transaction, &idempotency_key, *user_id, response) 64 | .await 65 | .map_err(e500)?; 66 | success_message().send(); 67 | Ok(response) 68 | } 69 | 70 | #[tracing::instrument(skip_all)] 71 | async fn insert_newsletter_issue( 72 | transaction: &mut Transaction<'_, Postgres>, 73 | title: &str, 74 | text_content: &str, 75 | html_content: &str, 76 | ) -> Result { 77 | let newsletter_issue_id = Uuid::new_v4(); 78 | let query = sqlx::query!( 79 | r#" 80 | INSERT INTO newsletter_issues ( 81 | newsletter_issue_id, 82 | title, 83 | text_content, 84 | html_content, 85 | published_at 86 | ) 87 | VALUES ($1, $2, $3, $4, now()) 88 | "#, 89 | newsletter_issue_id, 90 | title, 91 | text_content, 92 | html_content 93 | ); 94 | transaction.execute(query).await?; 95 | Ok(newsletter_issue_id) 96 | } 97 | 98 | #[tracing::instrument(skip_all)] 99 | async fn enqueue_delivery_tasks( 100 | transaction: &mut Transaction<'_, Postgres>, 101 | newsletter_issue_id: Uuid, 102 | ) -> Result<(), sqlx::Error> { 103 | let query = sqlx::query!( 104 | r#" 105 | INSERT INTO issue_delivery_queue ( 106 | newsletter_issue_id, 107 | subscriber_email 108 | ) 109 | SELECT $1, email 110 | FROM subscriptions 111 | WHERE status = 'confirmed' 112 | "#, 113 | newsletter_issue_id, 114 | ); 115 | transaction.execute(query).await?; 116 | Ok(()) 117 | } 118 | -------------------------------------------------------------------------------- /src/routes/admin/password/get.rs: -------------------------------------------------------------------------------- 1 | use crate::session_state::TypedSession; 2 | use crate::utils::{e500, see_other}; 3 | use actix_web::http::header::ContentType; 4 | use actix_web::HttpResponse; 5 | use actix_web_flash_messages::IncomingFlashMessages; 6 | use std::fmt::Write; 7 | 8 | pub async fn change_password_form( 9 | session: TypedSession, 10 | flash_messages: IncomingFlashMessages, 11 | ) -> Result { 12 | if session.get_user_id().map_err(e500)?.is_none() { 13 | return Ok(see_other("/login")); 14 | }; 15 | let mut msg_html = String::new(); 16 | for m in flash_messages.iter() { 17 | writeln!(msg_html, "

{}

", m.content()).unwrap(); 18 | } 19 | Ok(HttpResponse::Ok() 20 | .content_type(ContentType::html()) 21 | .body(format!( 22 | r#" 23 | 24 | 25 | 26 | Change Password 27 | 28 | 29 | {msg_html} 30 |
31 | 38 |
39 | 46 |
47 | 54 |
55 | 56 |
57 |

<- Back

58 | 59 | "#, 60 | ))) 61 | } 62 | -------------------------------------------------------------------------------- /src/routes/admin/password/mod.rs: -------------------------------------------------------------------------------- 1 | mod get; 2 | pub use get::change_password_form; 3 | mod post; 4 | pub use post::change_password; 5 | -------------------------------------------------------------------------------- /src/routes/admin/password/post.rs: -------------------------------------------------------------------------------- 1 | use crate::authentication::{validate_credentials, AuthError, Credentials, UserId}; 2 | use crate::routes::admin::dashboard::get_username; 3 | use crate::utils::{e500, see_other}; 4 | use actix_web::{web, HttpResponse}; 5 | use actix_web_flash_messages::FlashMessage; 6 | use secrecy::{ExposeSecret, Secret}; 7 | use sqlx::PgPool; 8 | 9 | #[derive(serde::Deserialize)] 10 | pub struct FormData { 11 | current_password: Secret, 12 | new_password: Secret, 13 | new_password_check: Secret, 14 | } 15 | 16 | pub async fn change_password( 17 | form: web::Form, 18 | pool: web::Data, 19 | user_id: web::ReqData, 20 | ) -> Result { 21 | let user_id = user_id.into_inner(); 22 | if form.new_password.expose_secret() != form.new_password_check.expose_secret() { 23 | FlashMessage::error( 24 | "You entered two different new passwords - the field values must match.", 25 | ) 26 | .send(); 27 | return Ok(see_other("/admin/password")); 28 | } 29 | let username = get_username(*user_id, &pool).await.map_err(e500)?; 30 | let credentials = Credentials { 31 | username, 32 | password: form.0.current_password, 33 | }; 34 | if let Err(e) = validate_credentials(credentials, &pool).await { 35 | return match e { 36 | AuthError::InvalidCredentials(_) => { 37 | FlashMessage::error("The current password is incorrect.").send(); 38 | Ok(see_other("/admin/password")) 39 | } 40 | AuthError::UnexpectedError(_) => Err(e500(e)), 41 | }; 42 | } 43 | crate::authentication::change_password(*user_id, form.0.new_password, &pool) 44 | .await 45 | .map_err(e500)?; 46 | FlashMessage::error("Your password has been changed.").send(); 47 | Ok(see_other("/admin/password")) 48 | } 49 | -------------------------------------------------------------------------------- /src/routes/health_check.rs: -------------------------------------------------------------------------------- 1 | use actix_web::HttpResponse; 2 | 3 | pub async fn health_check() -> HttpResponse { 4 | HttpResponse::Ok().finish() 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/home/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Home 6 | 7 | 8 |

Welcome to our newsletter!

9 | 10 | -------------------------------------------------------------------------------- /src/routes/home/mod.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{http::header::ContentType, HttpResponse}; 2 | 3 | pub async fn home() -> HttpResponse { 4 | HttpResponse::Ok() 5 | .content_type(ContentType::html()) 6 | .body(include_str!("home.html")) 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/login/get.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{http::header::ContentType, HttpResponse}; 2 | use actix_web_flash_messages::IncomingFlashMessages; 3 | use std::fmt::Write; 4 | 5 | pub async fn login_form(flash_messages: IncomingFlashMessages) -> HttpResponse { 6 | let mut error_html = String::new(); 7 | for m in flash_messages.iter() { 8 | writeln!(error_html, "

{}

", m.content()).unwrap(); 9 | } 10 | HttpResponse::Ok() 11 | .content_type(ContentType::html()) 12 | .body(format!( 13 | r#" 14 | 15 | 16 | 17 | Login 18 | 19 | 20 | {error_html} 21 |
22 | 29 | 36 | 37 |
38 | 39 | "#, 40 | )) 41 | } 42 | -------------------------------------------------------------------------------- /src/routes/login/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login 6 | 7 | 8 |
9 | 16 | 17 | 24 | 25 | 26 |
27 | 28 | -------------------------------------------------------------------------------- /src/routes/login/mod.rs: -------------------------------------------------------------------------------- 1 | mod get; 2 | mod post; 3 | 4 | pub use get::login_form; 5 | pub use post::login; 6 | -------------------------------------------------------------------------------- /src/routes/login/post.rs: -------------------------------------------------------------------------------- 1 | use crate::authentication::AuthError; 2 | use crate::authentication::{validate_credentials, Credentials}; 3 | use crate::routes::error_chain_fmt; 4 | use crate::session_state::TypedSession; 5 | use actix_web::error::InternalError; 6 | use actix_web::http::header::LOCATION; 7 | use actix_web::web; 8 | use actix_web::HttpResponse; 9 | use actix_web_flash_messages::FlashMessage; 10 | use secrecy::Secret; 11 | use sqlx::PgPool; 12 | 13 | #[derive(serde::Deserialize)] 14 | pub struct FormData { 15 | username: String, 16 | password: Secret, 17 | } 18 | 19 | #[tracing::instrument( 20 | skip(form, pool, session), 21 | fields(username=tracing::field::Empty, user_id=tracing::field::Empty) 22 | )] 23 | // We are now injecting `PgPool` to retrieve stored credentials from the database 24 | pub async fn login( 25 | form: web::Form, 26 | pool: web::Data, 27 | session: TypedSession, 28 | ) -> Result> { 29 | let credentials = Credentials { 30 | username: form.0.username, 31 | password: form.0.password, 32 | }; 33 | tracing::Span::current().record("username", tracing::field::display(&credentials.username)); 34 | match validate_credentials(credentials, &pool).await { 35 | Ok(user_id) => { 36 | tracing::Span::current().record("user_id", tracing::field::display(&user_id)); 37 | session.renew(); 38 | session 39 | .insert_user_id(user_id) 40 | .map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?; 41 | Ok(HttpResponse::SeeOther() 42 | .insert_header((LOCATION, "/admin/dashboard")) 43 | .finish()) 44 | } 45 | Err(e) => { 46 | let e = match e { 47 | AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()), 48 | AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()), 49 | }; 50 | Err(login_redirect(e)) 51 | } 52 | } 53 | } 54 | 55 | fn login_redirect(e: LoginError) -> InternalError { 56 | FlashMessage::error(e.to_string()).send(); 57 | let response = HttpResponse::SeeOther() 58 | .insert_header((LOCATION, "/login")) 59 | .finish(); 60 | InternalError::from_response(e, response) 61 | } 62 | 63 | #[derive(thiserror::Error)] 64 | pub enum LoginError { 65 | #[error("Authentication failed")] 66 | AuthError(#[source] anyhow::Error), 67 | #[error("Something went wrong")] 68 | UnexpectedError(#[from] anyhow::Error), 69 | } 70 | 71 | impl std::fmt::Debug for LoginError { 72 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 73 | error_chain_fmt(self, f) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | mod admin; 2 | mod health_check; 3 | mod home; 4 | mod login; 5 | mod subscriptions; 6 | mod subscriptions_confirm; 7 | 8 | pub use admin::*; 9 | pub use health_check::*; 10 | pub use home::*; 11 | pub use login::*; 12 | pub use subscriptions::*; 13 | pub use subscriptions_confirm::*; 14 | -------------------------------------------------------------------------------- /src/routes/subscriptions.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; 2 | use crate::email_client::EmailClient; 3 | use crate::startup::ApplicationBaseUrl; 4 | use actix_web::http::StatusCode; 5 | use actix_web::{web, HttpResponse, ResponseError}; 6 | use anyhow::Context; 7 | use chrono::Utc; 8 | use rand::distributions::Alphanumeric; 9 | use rand::{thread_rng, Rng}; 10 | use sqlx::{Executor, PgPool, Postgres, Transaction}; 11 | use std::convert::{TryFrom, TryInto}; 12 | use uuid::Uuid; 13 | 14 | #[derive(serde::Deserialize)] 15 | pub struct FormData { 16 | email: String, 17 | name: String, 18 | } 19 | 20 | impl TryFrom for NewSubscriber { 21 | type Error = String; 22 | 23 | fn try_from(value: FormData) -> Result { 24 | let name = SubscriberName::parse(value.name)?; 25 | let email = SubscriberEmail::parse(value.email)?; 26 | Ok(Self { email, name }) 27 | } 28 | } 29 | 30 | #[derive(thiserror::Error)] 31 | pub enum SubscribeError { 32 | #[error("{0}")] 33 | ValidationError(String), 34 | #[error(transparent)] 35 | UnexpectedError(#[from] anyhow::Error), 36 | } 37 | 38 | impl std::fmt::Debug for SubscribeError { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | error_chain_fmt(self, f) 41 | } 42 | } 43 | 44 | impl ResponseError for SubscribeError { 45 | fn status_code(&self) -> StatusCode { 46 | match self { 47 | SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST, 48 | SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, 49 | } 50 | } 51 | } 52 | 53 | #[tracing::instrument( 54 | name = "Adding a new subscriber", 55 | skip(form, pool, email_client, base_url), 56 | fields( 57 | subscriber_email = %form.email, 58 | subscriber_name = %form.name 59 | ) 60 | )] 61 | pub async fn subscribe( 62 | form: web::Form, 63 | pool: web::Data, 64 | email_client: web::Data, 65 | base_url: web::Data, 66 | ) -> Result { 67 | let new_subscriber = form.0.try_into().map_err(SubscribeError::ValidationError)?; 68 | let mut transaction = pool 69 | .begin() 70 | .await 71 | .context("Failed to acquire a Postgres connection from the pool")?; 72 | let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber) 73 | .await 74 | .context("Failed to insert new subscriber in the database.")?; 75 | let subscription_token = generate_subscription_token(); 76 | store_token(&mut transaction, subscriber_id, &subscription_token) 77 | .await 78 | .context("Failed to store the confirmation token for a new subscriber.")?; 79 | transaction 80 | .commit() 81 | .await 82 | .context("Failed to commit SQL transaction to store a new subscriber.")?; 83 | send_confirmation_email( 84 | &email_client, 85 | new_subscriber, 86 | &base_url.0, 87 | &subscription_token, 88 | ) 89 | .await 90 | .context("Failed to send a confirmation email.")?; 91 | Ok(HttpResponse::Ok().finish()) 92 | } 93 | 94 | fn generate_subscription_token() -> String { 95 | let mut rng = thread_rng(); 96 | std::iter::repeat_with(|| rng.sample(Alphanumeric)) 97 | .map(char::from) 98 | .take(25) 99 | .collect() 100 | } 101 | 102 | #[tracing::instrument( 103 | name = "Send a confirmation email to a new subscriber", 104 | skip(email_client, new_subscriber, base_url, subscription_token) 105 | )] 106 | pub async fn send_confirmation_email( 107 | email_client: &EmailClient, 108 | new_subscriber: NewSubscriber, 109 | base_url: &str, 110 | subscription_token: &str, 111 | ) -> Result<(), reqwest::Error> { 112 | let confirmation_link = format!( 113 | "{}/subscriptions/confirm?subscription_token={}", 114 | base_url, subscription_token 115 | ); 116 | let plain_body = format!( 117 | "Welcome to our newsletter!\nVisit {} to confirm your subscription.", 118 | confirmation_link 119 | ); 120 | let html_body = format!( 121 | "Welcome to our newsletter!
Click here to confirm your subscription.", 122 | confirmation_link 123 | ); 124 | email_client 125 | .send_email(&new_subscriber.email, "Welcome!", &html_body, &plain_body) 126 | .await 127 | } 128 | 129 | #[tracing::instrument( 130 | name = "Saving new subscriber details in the database", 131 | skip(new_subscriber, transaction) 132 | )] 133 | pub async fn insert_subscriber( 134 | transaction: &mut Transaction<'_, Postgres>, 135 | new_subscriber: &NewSubscriber, 136 | ) -> Result { 137 | let subscriber_id = Uuid::new_v4(); 138 | let query = sqlx::query!( 139 | r#" 140 | INSERT INTO subscriptions (id, email, name, subscribed_at, status) 141 | VALUES ($1, $2, $3, $4, 'pending_confirmation') 142 | "#, 143 | subscriber_id, 144 | new_subscriber.email.as_ref(), 145 | new_subscriber.name.as_ref(), 146 | Utc::now() 147 | ); 148 | transaction.execute(query).await?; 149 | Ok(subscriber_id) 150 | } 151 | 152 | #[tracing::instrument( 153 | name = "Store subscription token in the database", 154 | skip(subscription_token, transaction) 155 | )] 156 | pub async fn store_token( 157 | transaction: &mut Transaction<'_, Postgres>, 158 | subscriber_id: Uuid, 159 | subscription_token: &str, 160 | ) -> Result<(), StoreTokenError> { 161 | let query = sqlx::query!( 162 | r#" 163 | INSERT INTO subscription_tokens (subscription_token, subscriber_id) 164 | VALUES ($1, $2) 165 | "#, 166 | subscription_token, 167 | subscriber_id 168 | ); 169 | transaction.execute(query).await.map_err(StoreTokenError)?; 170 | Ok(()) 171 | } 172 | 173 | pub struct StoreTokenError(sqlx::Error); 174 | 175 | impl std::error::Error for StoreTokenError { 176 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 177 | Some(&self.0) 178 | } 179 | } 180 | 181 | impl std::fmt::Debug for StoreTokenError { 182 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 183 | error_chain_fmt(self, f) 184 | } 185 | } 186 | 187 | impl std::fmt::Display for StoreTokenError { 188 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 189 | write!( 190 | f, 191 | "A database failure was encountered while trying to store a subscription token." 192 | ) 193 | } 194 | } 195 | 196 | pub fn error_chain_fmt( 197 | e: &impl std::error::Error, 198 | f: &mut std::fmt::Formatter<'_>, 199 | ) -> std::fmt::Result { 200 | writeln!(f, "{}\n", e)?; 201 | let mut current = e.source(); 202 | while let Some(cause) = current { 203 | writeln!(f, "Caused by:\n\t{}", cause)?; 204 | current = cause.source(); 205 | } 206 | Ok(()) 207 | } 208 | -------------------------------------------------------------------------------- /src/routes/subscriptions_confirm.rs: -------------------------------------------------------------------------------- 1 | use crate::routes::error_chain_fmt; 2 | use actix_web::http::StatusCode; 3 | use actix_web::{web, HttpResponse, ResponseError}; 4 | use anyhow::Context; 5 | use sqlx::PgPool; 6 | use uuid::Uuid; 7 | 8 | #[derive(serde::Deserialize)] 9 | pub struct Parameters { 10 | subscription_token: String, 11 | } 12 | 13 | #[derive(thiserror::Error)] 14 | pub enum ConfirmationError { 15 | #[error(transparent)] 16 | UnexpectedError(#[from] anyhow::Error), 17 | #[error("There is no subscriber associated with the provided token.")] 18 | UnknownToken, 19 | } 20 | 21 | impl std::fmt::Debug for ConfirmationError { 22 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 | error_chain_fmt(self, f) 24 | } 25 | } 26 | 27 | impl ResponseError for ConfirmationError { 28 | fn status_code(&self) -> StatusCode { 29 | match self { 30 | Self::UnknownToken => StatusCode::UNAUTHORIZED, 31 | Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, 32 | } 33 | } 34 | } 35 | 36 | #[tracing::instrument(name = "Confirm a pending subscriber", skip(parameters, pool))] 37 | pub async fn confirm( 38 | parameters: web::Query, 39 | pool: web::Data, 40 | ) -> Result { 41 | let subscriber_id = get_subscriber_id_from_token(&pool, ¶meters.subscription_token) 42 | .await 43 | .context("Failed to retrieve the subscriber id associated with the provided token.")? 44 | .ok_or(ConfirmationError::UnknownToken)?; 45 | confirm_subscriber(&pool, subscriber_id) 46 | .await 47 | .context("Failed to update the subscriber status to `confirmed`.")?; 48 | Ok(HttpResponse::Ok().finish()) 49 | } 50 | 51 | #[tracing::instrument(name = "Mark subscriber as confirmed", skip(subscriber_id, pool))] 52 | pub async fn confirm_subscriber(pool: &PgPool, subscriber_id: Uuid) -> Result<(), sqlx::Error> { 53 | sqlx::query!( 54 | r#"UPDATE subscriptions SET status = 'confirmed' WHERE id = $1"#, 55 | subscriber_id, 56 | ) 57 | .execute(pool) 58 | .await?; 59 | Ok(()) 60 | } 61 | 62 | #[tracing::instrument(name = "Get subscriber_id from token", skip(subscription_token, pool))] 63 | pub async fn get_subscriber_id_from_token( 64 | pool: &PgPool, 65 | subscription_token: &str, 66 | ) -> Result, sqlx::Error> { 67 | let result = sqlx::query!( 68 | r#"SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1"#, 69 | subscription_token, 70 | ) 71 | .fetch_optional(pool) 72 | .await?; 73 | Ok(result.map(|r| r.subscriber_id)) 74 | } 75 | -------------------------------------------------------------------------------- /src/session_state.rs: -------------------------------------------------------------------------------- 1 | use actix_session::SessionExt; 2 | use actix_session::{Session, SessionGetError, SessionInsertError}; 3 | use actix_web::dev::Payload; 4 | use actix_web::{FromRequest, HttpRequest}; 5 | use std::future::{ready, Ready}; 6 | use uuid::Uuid; 7 | 8 | pub struct TypedSession(Session); 9 | 10 | impl TypedSession { 11 | const USER_ID_KEY: &'static str = "user_id"; 12 | 13 | pub fn renew(&self) { 14 | self.0.renew(); 15 | } 16 | 17 | pub fn insert_user_id(&self, user_id: Uuid) -> Result<(), SessionInsertError> { 18 | self.0.insert(Self::USER_ID_KEY, user_id) 19 | } 20 | 21 | pub fn get_user_id(&self) -> Result, SessionGetError> { 22 | self.0.get(Self::USER_ID_KEY) 23 | } 24 | 25 | pub fn log_out(self) { 26 | self.0.purge() 27 | } 28 | } 29 | 30 | impl FromRequest for TypedSession { 31 | type Error = ::Error; 32 | type Future = Ready>; 33 | 34 | fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { 35 | ready(Ok(TypedSession(req.get_session()))) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/startup.rs: -------------------------------------------------------------------------------- 1 | use crate::authentication::reject_anonymous_users; 2 | use crate::configuration::{DatabaseSettings, Settings}; 3 | use crate::email_client::EmailClient; 4 | use crate::routes::{ 5 | admin_dashboard, change_password, change_password_form, confirm, health_check, home, log_out, 6 | login, login_form, publish_newsletter, publish_newsletter_form, subscribe, 7 | }; 8 | use actix_session::storage::RedisSessionStore; 9 | use actix_session::SessionMiddleware; 10 | use actix_web::cookie::Key; 11 | use actix_web::dev::Server; 12 | use actix_web::middleware::from_fn; 13 | use actix_web::web::Data; 14 | use actix_web::{web, App, HttpServer}; 15 | use actix_web_flash_messages::storage::CookieMessageStore; 16 | use actix_web_flash_messages::FlashMessagesFramework; 17 | use secrecy::{ExposeSecret, Secret}; 18 | use sqlx::postgres::PgPoolOptions; 19 | use sqlx::PgPool; 20 | use std::net::TcpListener; 21 | use tracing_actix_web::TracingLogger; 22 | 23 | pub struct Application { 24 | port: u16, 25 | server: Server, 26 | } 27 | 28 | impl Application { 29 | pub async fn build(configuration: Settings) -> Result { 30 | let connection_pool = get_connection_pool(&configuration.database); 31 | let email_client = configuration.email_client.client(); 32 | 33 | let address = format!( 34 | "{}:{}", 35 | configuration.application.host, configuration.application.port 36 | ); 37 | let listener = TcpListener::bind(address)?; 38 | let port = listener.local_addr().unwrap().port(); 39 | let server = run( 40 | listener, 41 | connection_pool, 42 | email_client, 43 | configuration.application.base_url, 44 | configuration.application.hmac_secret, 45 | configuration.redis_uri, 46 | ) 47 | .await?; 48 | 49 | Ok(Self { port, server }) 50 | } 51 | 52 | pub fn port(&self) -> u16 { 53 | self.port 54 | } 55 | 56 | pub async fn run_until_stopped(self) -> Result<(), std::io::Error> { 57 | self.server.await 58 | } 59 | } 60 | 61 | pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool { 62 | PgPoolOptions::new().connect_lazy_with(configuration.connect_options()) 63 | } 64 | 65 | pub struct ApplicationBaseUrl(pub String); 66 | 67 | async fn run( 68 | listener: TcpListener, 69 | db_pool: PgPool, 70 | email_client: EmailClient, 71 | base_url: String, 72 | hmac_secret: Secret, 73 | redis_uri: Secret, 74 | ) -> Result { 75 | let db_pool = Data::new(db_pool); 76 | let email_client = Data::new(email_client); 77 | let base_url = Data::new(ApplicationBaseUrl(base_url)); 78 | let secret_key = Key::from(hmac_secret.expose_secret().as_bytes()); 79 | let message_store = CookieMessageStore::builder(secret_key.clone()).build(); 80 | let message_framework = FlashMessagesFramework::builder(message_store).build(); 81 | let redis_store = RedisSessionStore::new(redis_uri.expose_secret()).await?; 82 | let server = HttpServer::new(move || { 83 | App::new() 84 | .wrap(message_framework.clone()) 85 | .wrap(SessionMiddleware::new( 86 | redis_store.clone(), 87 | secret_key.clone(), 88 | )) 89 | .wrap(TracingLogger::default()) 90 | .route("/", web::get().to(home)) 91 | .service( 92 | web::scope("/admin") 93 | .wrap(from_fn(reject_anonymous_users)) 94 | .route("/dashboard", web::get().to(admin_dashboard)) 95 | .route("/newsletters", web::get().to(publish_newsletter_form)) 96 | .route("/newsletters", web::post().to(publish_newsletter)) 97 | .route("/password", web::get().to(change_password_form)) 98 | .route("/password", web::post().to(change_password)) 99 | .route("/logout", web::post().to(log_out)), 100 | ) 101 | .route("/login", web::get().to(login_form)) 102 | .route("/login", web::post().to(login)) 103 | .route("/health_check", web::get().to(health_check)) 104 | .route("/subscriptions", web::post().to(subscribe)) 105 | .route("/subscriptions/confirm", web::get().to(confirm)) 106 | .route("/newsletters", web::post().to(publish_newsletter)) 107 | .app_data(db_pool.clone()) 108 | .app_data(email_client.clone()) 109 | .app_data(base_url.clone()) 110 | .app_data(Data::new(HmacSecret(hmac_secret.clone()))) 111 | }) 112 | .listen(listener)? 113 | .run(); 114 | Ok(server) 115 | } 116 | 117 | #[derive(Clone)] 118 | pub struct HmacSecret(pub Secret); 119 | -------------------------------------------------------------------------------- /src/telemetry.rs: -------------------------------------------------------------------------------- 1 | use actix_web::rt::task::JoinHandle; 2 | use tracing::subscriber::set_global_default; 3 | use tracing::Subscriber; 4 | use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; 5 | use tracing_log::LogTracer; 6 | use tracing_subscriber::fmt::MakeWriter; 7 | use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry}; 8 | 9 | /// Compose multiple layers into a `tracing`'s subscriber. 10 | /// 11 | /// # Implementation Notes 12 | /// 13 | /// We are using `impl Subscriber` as return type to avoid having to spell out the actual 14 | /// type of the returned subscriber, which is indeed quite complex. 15 | pub fn get_subscriber( 16 | name: String, 17 | env_filter: String, 18 | sink: Sink, 19 | ) -> impl Subscriber + Sync + Send 20 | where 21 | Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static, 22 | { 23 | let env_filter = 24 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter)); 25 | let formatting_layer = BunyanFormattingLayer::new(name, sink); 26 | Registry::default() 27 | .with(env_filter) 28 | .with(JsonStorageLayer) 29 | .with(formatting_layer) 30 | } 31 | 32 | /// Register a subscriber as global default to process span data. 33 | /// 34 | /// It should only be called once! 35 | pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) { 36 | LogTracer::init().expect("Failed to set logger"); 37 | set_global_default(subscriber).expect("Failed to set subscriber"); 38 | } 39 | 40 | pub fn spawn_blocking_with_tracing(f: F) -> JoinHandle 41 | where 42 | F: FnOnce() -> R + Send + 'static, 43 | R: Send + 'static, 44 | { 45 | let current_span = tracing::Span::current(); 46 | actix_web::rt::task::spawn_blocking(move || current_span.in_scope(f)) 47 | } 48 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use actix_web::http::header::LOCATION; 2 | use actix_web::HttpResponse; 3 | 4 | // Return an opaque 500 while preserving the error root's cause for logging. 5 | pub fn e500(e: T) -> actix_web::Error 6 | where 7 | T: std::fmt::Debug + std::fmt::Display + 'static, 8 | { 9 | actix_web::error::ErrorInternalServerError(e) 10 | } 11 | 12 | // Return a 400 with the user-representation of the validation error as body. 13 | // The error root cause is preserved for logging purposes. 14 | pub fn e400(e: T) -> actix_web::Error 15 | where 16 | T: std::fmt::Debug + std::fmt::Display + 'static, 17 | { 18 | actix_web::error::ErrorBadRequest(e) 19 | } 20 | 21 | pub fn see_other(location: &str) -> HttpResponse { 22 | HttpResponse::SeeOther() 23 | .insert_header((LOCATION, location)) 24 | .finish() 25 | } 26 | -------------------------------------------------------------------------------- /tests/api/admin_dashboard.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{assert_is_redirect_to, spawn_app}; 2 | 3 | #[tokio::test] 4 | async fn you_must_be_logged_in_to_access_the_admin_dashboard() { 5 | // Arrange 6 | let app = spawn_app().await; 7 | 8 | // Act 9 | let response = app.get_admin_dashboard().await; 10 | 11 | // Assert 12 | assert_is_redirect_to(&response, "/login"); 13 | } 14 | 15 | #[tokio::test] 16 | async fn logout_clears_session_state() { 17 | // Arrange 18 | let app = spawn_app().await; 19 | 20 | // Act - Part 1 - Login 21 | let login_body = serde_json::json!({ 22 | "username": &app.test_user.username, 23 | "password": &app.test_user.password 24 | }); 25 | let response = app.post_login(&login_body).await; 26 | assert_is_redirect_to(&response, "/admin/dashboard"); 27 | 28 | // Act - Part 2 - Follow the redirect 29 | let html_page = app.get_admin_dashboard_html().await; 30 | assert!(html_page.contains(&format!("Welcome {}", app.test_user.username))); 31 | 32 | // Act - Part 3 - Logout 33 | let response = app.post_logout().await; 34 | assert_is_redirect_to(&response, "/login"); 35 | 36 | // Act - Part 4 - Follow the redirect 37 | let html_page = app.get_login_html().await; 38 | assert!(html_page.contains(r#"

You have successfully logged out.

"#)); 39 | 40 | // Act - Part 5 - Attempt to load admin panel 41 | let response = app.get_admin_dashboard().await; 42 | assert_is_redirect_to(&response, "/login"); 43 | } 44 | -------------------------------------------------------------------------------- /tests/api/change_password.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{assert_is_redirect_to, spawn_app}; 2 | use uuid::Uuid; 3 | 4 | #[tokio::test] 5 | async fn you_must_be_logged_in_to_see_the_change_password_form() { 6 | // Arrange 7 | let app = spawn_app().await; 8 | 9 | // Act 10 | let response = app.get_change_password().await; 11 | 12 | // Assert 13 | assert_is_redirect_to(&response, "/login"); 14 | } 15 | 16 | #[tokio::test] 17 | async fn you_must_be_logged_in_to_change_your_password() { 18 | // Arrange 19 | let app = spawn_app().await; 20 | let new_password = Uuid::new_v4().to_string(); 21 | 22 | // Act 23 | let response = app 24 | .post_change_password(&serde_json::json!({ 25 | "current_password": Uuid::new_v4().to_string(), 26 | "new_password": &new_password, 27 | "new_password_check": &new_password, 28 | })) 29 | .await; 30 | 31 | // Assert 32 | assert_is_redirect_to(&response, "/login"); 33 | } 34 | 35 | #[tokio::test] 36 | async fn new_password_fields_must_match() { 37 | // Arrange 38 | let app = spawn_app().await; 39 | let new_password = Uuid::new_v4().to_string(); 40 | let another_new_password = Uuid::new_v4().to_string(); 41 | 42 | // Act - Part 1 - Login 43 | app.post_login(&serde_json::json!({ 44 | "username": &app.test_user.username, 45 | "password": &app.test_user.password 46 | })) 47 | .await; 48 | 49 | // Act - Part 2 - Try to change password 50 | let response = app 51 | .post_change_password(&serde_json::json!({ 52 | "current_password": &app.test_user.password, 53 | "new_password": &new_password, 54 | "new_password_check": &another_new_password, 55 | })) 56 | .await; 57 | assert_is_redirect_to(&response, "/admin/password"); 58 | 59 | // Act - Part 3 - Follow the redirect 60 | let html_page = app.get_change_password_html().await; 61 | assert!(html_page.contains( 62 | "

You entered two different new passwords - \ 63 | the field values must match.

" 64 | )); 65 | } 66 | 67 | #[tokio::test] 68 | async fn current_password_must_be_valid() { 69 | // Arrange 70 | let app = spawn_app().await; 71 | let new_password = Uuid::new_v4().to_string(); 72 | let wrong_password = Uuid::new_v4().to_string(); 73 | 74 | // Act - Part 1 - Login 75 | app.post_login(&serde_json::json!({ 76 | "username": &app.test_user.username, 77 | "password": &app.test_user.password 78 | })) 79 | .await; 80 | 81 | // Act - Part 2 - Try to change password 82 | let response = app 83 | .post_change_password(&serde_json::json!({ 84 | "current_password": &wrong_password, 85 | "new_password": &new_password, 86 | "new_password_check": &new_password, 87 | })) 88 | .await; 89 | 90 | // Assert 91 | assert_is_redirect_to(&response, "/admin/password"); 92 | 93 | // Act - Part 3 - Follow the redirect 94 | let html_page = app.get_change_password_html().await; 95 | assert!(html_page.contains("

The current password is incorrect.

")); 96 | } 97 | 98 | #[tokio::test] 99 | async fn changing_password_works() { 100 | // Arrange 101 | let app = spawn_app().await; 102 | let new_password = Uuid::new_v4().to_string(); 103 | 104 | // Act - Part 1 - Login 105 | let login_body = serde_json::json!({ 106 | "username": &app.test_user.username, 107 | "password": &app.test_user.password 108 | }); 109 | let response = app.post_login(&login_body).await; 110 | assert_is_redirect_to(&response, "/admin/dashboard"); 111 | 112 | // Act - Part 2 - Change password 113 | let response = app 114 | .post_change_password(&serde_json::json!({ 115 | "current_password": &app.test_user.password, 116 | "new_password": &new_password, 117 | "new_password_check": &new_password, 118 | })) 119 | .await; 120 | assert_is_redirect_to(&response, "/admin/password"); 121 | 122 | // Act - Part 3 - Follow the redirect 123 | let html_page = app.get_change_password_html().await; 124 | assert!(html_page.contains("

Your password has been changed.

")); 125 | 126 | // Act - Part 4 - Logout 127 | let response = app.post_logout().await; 128 | assert_is_redirect_to(&response, "/login"); 129 | 130 | // Act - Part 5 - Follow the redirect 131 | let html_page = app.get_login_html().await; 132 | assert!(html_page.contains("

You have successfully logged out.

")); 133 | 134 | // Act - Part 6 - Login using the new password 135 | let login_body = serde_json::json!({ 136 | "username": &app.test_user.username, 137 | "password": &new_password 138 | }); 139 | let response = app.post_login(&login_body).await; 140 | assert_is_redirect_to(&response, "/admin/dashboard"); 141 | } 142 | -------------------------------------------------------------------------------- /tests/api/health_check.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::spawn_app; 2 | 3 | #[tokio::test] 4 | async fn health_check_works() { 5 | // Arrange 6 | let app = spawn_app().await; 7 | let client = reqwest::Client::new(); 8 | 9 | // Act 10 | let response = client 11 | // Use the returned application address 12 | .get(&format!("{}/health_check", &app.address)) 13 | .send() 14 | .await 15 | .expect("Failed to execute request."); 16 | 17 | // Assert 18 | assert!(response.status().is_success()); 19 | assert_eq!(Some(0), response.content_length()); 20 | } 21 | -------------------------------------------------------------------------------- /tests/api/helpers.rs: -------------------------------------------------------------------------------- 1 | use argon2::password_hash::SaltString; 2 | use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version}; 3 | use secrecy::Secret; 4 | use sqlx::{Connection, Executor, PgConnection, PgPool}; 5 | use std::sync::LazyLock; 6 | use uuid::Uuid; 7 | use wiremock::MockServer; 8 | use zero2prod::configuration::{get_configuration, DatabaseSettings}; 9 | use zero2prod::email_client::EmailClient; 10 | use zero2prod::issue_delivery_worker::{try_execute_task, ExecutionOutcome}; 11 | use zero2prod::startup::{get_connection_pool, Application}; 12 | use zero2prod::telemetry::{get_subscriber, init_subscriber}; 13 | 14 | // Ensure that the `tracing` stack is only initialised once using `once_cell` 15 | static TRACING: LazyLock<()> = LazyLock::new(|| { 16 | let default_filter_level = "info".to_string(); 17 | let subscriber_name = "test".to_string(); 18 | if std::env::var("TEST_LOG").is_ok() { 19 | let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); 20 | init_subscriber(subscriber); 21 | } else { 22 | let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); 23 | init_subscriber(subscriber); 24 | }; 25 | }); 26 | 27 | pub struct TestApp { 28 | pub address: String, 29 | pub port: u16, 30 | pub db_pool: PgPool, 31 | pub email_server: MockServer, 32 | pub test_user: TestUser, 33 | pub api_client: reqwest::Client, 34 | pub email_client: EmailClient, 35 | } 36 | 37 | /// Confirmation links embedded in the request to the email API. 38 | pub struct ConfirmationLinks { 39 | pub html: reqwest::Url, 40 | pub plain_text: reqwest::Url, 41 | } 42 | 43 | impl TestApp { 44 | pub async fn dispatch_all_pending_emails(&self) { 45 | loop { 46 | if let ExecutionOutcome::EmptyQueue = 47 | try_execute_task(&self.db_pool, &self.email_client) 48 | .await 49 | .unwrap() 50 | { 51 | break; 52 | } 53 | } 54 | } 55 | 56 | pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { 57 | self.api_client 58 | .post(&format!("{}/subscriptions", &self.address)) 59 | .header("Content-Type", "application/x-www-form-urlencoded") 60 | .body(body) 61 | .send() 62 | .await 63 | .expect("Failed to execute request.") 64 | } 65 | 66 | pub async fn post_login(&self, body: &Body) -> reqwest::Response 67 | where 68 | Body: serde::Serialize, 69 | { 70 | self.api_client 71 | .post(&format!("{}/login", &self.address)) 72 | .form(body) 73 | .send() 74 | .await 75 | .expect("Failed to execute request.") 76 | } 77 | 78 | pub async fn get_login_html(&self) -> String { 79 | self.api_client 80 | .get(&format!("{}/login", &self.address)) 81 | .send() 82 | .await 83 | .expect("Failed to execute request.") 84 | .text() 85 | .await 86 | .unwrap() 87 | } 88 | 89 | pub async fn get_admin_dashboard(&self) -> reqwest::Response { 90 | self.api_client 91 | .get(&format!("{}/admin/dashboard", &self.address)) 92 | .send() 93 | .await 94 | .expect("Failed to execute request.") 95 | } 96 | 97 | pub async fn get_admin_dashboard_html(&self) -> String { 98 | self.get_admin_dashboard().await.text().await.unwrap() 99 | } 100 | 101 | pub async fn get_change_password(&self) -> reqwest::Response { 102 | self.api_client 103 | .get(&format!("{}/admin/password", &self.address)) 104 | .send() 105 | .await 106 | .expect("Failed to execute request.") 107 | } 108 | 109 | pub async fn get_change_password_html(&self) -> String { 110 | self.get_change_password().await.text().await.unwrap() 111 | } 112 | 113 | pub async fn post_logout(&self) -> reqwest::Response { 114 | self.api_client 115 | .post(&format!("{}/admin/logout", &self.address)) 116 | .send() 117 | .await 118 | .expect("Failed to execute request.") 119 | } 120 | 121 | pub async fn post_change_password(&self, body: &Body) -> reqwest::Response 122 | where 123 | Body: serde::Serialize, 124 | { 125 | self.api_client 126 | .post(&format!("{}/admin/password", &self.address)) 127 | .form(body) 128 | .send() 129 | .await 130 | .expect("Failed to execute request.") 131 | } 132 | 133 | pub async fn get_publish_newsletter(&self) -> reqwest::Response { 134 | self.api_client 135 | .get(&format!("{}/admin/newsletters", &self.address)) 136 | .send() 137 | .await 138 | .expect("Failed to execute request.") 139 | } 140 | 141 | pub async fn get_publish_newsletter_html(&self) -> String { 142 | self.get_publish_newsletter().await.text().await.unwrap() 143 | } 144 | 145 | pub async fn post_publish_newsletter(&self, body: &Body) -> reqwest::Response 146 | where 147 | Body: serde::Serialize, 148 | { 149 | self.api_client 150 | .post(&format!("{}/admin/newsletters", &self.address)) 151 | .form(body) 152 | .send() 153 | .await 154 | .expect("Failed to execute request.") 155 | } 156 | 157 | /// Extract the confirmation links embedded in the request to the email API. 158 | pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { 159 | let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); 160 | 161 | // Extract the link from one of the request fields. 162 | let get_link = |s: &str| { 163 | let links: Vec<_> = linkify::LinkFinder::new() 164 | .links(s) 165 | .filter(|l| *l.kind() == linkify::LinkKind::Url) 166 | .collect(); 167 | assert_eq!(links.len(), 1); 168 | let raw_link = links[0].as_str().to_owned(); 169 | let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap(); 170 | // Let's make sure we don't call random APIs on the web 171 | assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); 172 | confirmation_link.set_port(Some(self.port)).unwrap(); 173 | confirmation_link 174 | }; 175 | 176 | let html = get_link(body["HtmlBody"].as_str().unwrap()); 177 | let plain_text = get_link(body["TextBody"].as_str().unwrap()); 178 | ConfirmationLinks { html, plain_text } 179 | } 180 | } 181 | 182 | pub async fn spawn_app() -> TestApp { 183 | LazyLock::force(&TRACING); 184 | 185 | // Launch a mock server to stand in for Postmark's API 186 | let email_server = MockServer::start().await; 187 | 188 | // Randomise configuration to ensure test isolation 189 | let configuration = { 190 | let mut c = get_configuration().expect("Failed to read configuration."); 191 | // Use a different database for each test case 192 | c.database.database_name = Uuid::new_v4().to_string(); 193 | // Use a random OS port 194 | c.application.port = 0; 195 | // Use the mock server as email API 196 | c.email_client.base_url = email_server.uri(); 197 | c 198 | }; 199 | 200 | // Create and migrate the database 201 | configure_database(&configuration.database).await; 202 | 203 | // Launch the application as a background task 204 | let application = Application::build(configuration.clone()) 205 | .await 206 | .expect("Failed to build application."); 207 | let application_port = application.port(); 208 | let _ = tokio::spawn(application.run_until_stopped()); 209 | 210 | let client = reqwest::Client::builder() 211 | .redirect(reqwest::redirect::Policy::none()) 212 | .cookie_store(true) 213 | .build() 214 | .unwrap(); 215 | 216 | let test_app = TestApp { 217 | address: format!("http://localhost:{}", application_port), 218 | port: application_port, 219 | db_pool: get_connection_pool(&configuration.database), 220 | email_server, 221 | test_user: TestUser::generate(), 222 | api_client: client, 223 | email_client: configuration.email_client.client(), 224 | }; 225 | 226 | test_app.test_user.store(&test_app.db_pool).await; 227 | 228 | test_app 229 | } 230 | 231 | async fn configure_database(config: &DatabaseSettings) -> PgPool { 232 | // Create database 233 | let maintenance_settings = DatabaseSettings { 234 | database_name: "postgres".to_string(), 235 | username: "postgres".to_string(), 236 | password: Secret::new("password".to_string()), 237 | ..config.clone() 238 | }; 239 | let mut connection = PgConnection::connect_with(&maintenance_settings.connect_options()) 240 | .await 241 | .expect("Failed to connect to Postgres"); 242 | connection 243 | .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) 244 | .await 245 | .expect("Failed to create database."); 246 | 247 | // Migrate database 248 | let connection_pool = PgPool::connect_with(config.connect_options()) 249 | .await 250 | .expect("Failed to connect to Postgres."); 251 | sqlx::migrate!("./migrations") 252 | .run(&connection_pool) 253 | .await 254 | .expect("Failed to migrate the database"); 255 | connection_pool 256 | } 257 | 258 | pub struct TestUser { 259 | user_id: Uuid, 260 | pub username: String, 261 | pub password: String, 262 | } 263 | 264 | impl TestUser { 265 | pub fn generate() -> Self { 266 | Self { 267 | user_id: Uuid::new_v4(), 268 | username: Uuid::new_v4().to_string(), 269 | password: Uuid::new_v4().to_string(), 270 | } 271 | } 272 | 273 | pub async fn login(&self, app: &TestApp) { 274 | app.post_login(&serde_json::json!({ 275 | "username": &self.username, 276 | "password": &self.password 277 | })) 278 | .await; 279 | } 280 | 281 | async fn store(&self, pool: &PgPool) { 282 | let salt = SaltString::generate(&mut rand::thread_rng()); 283 | // Match production parameters 284 | let password_hash = Argon2::new( 285 | Algorithm::Argon2id, 286 | Version::V0x13, 287 | Params::new(15000, 2, 1, None).unwrap(), 288 | ) 289 | .hash_password(self.password.as_bytes(), &salt) 290 | .unwrap() 291 | .to_string(); 292 | sqlx::query!( 293 | "INSERT INTO users (user_id, username, password_hash) 294 | VALUES ($1, $2, $3)", 295 | self.user_id, 296 | self.username, 297 | password_hash, 298 | ) 299 | .execute(pool) 300 | .await 301 | .expect("Failed to store test user."); 302 | } 303 | } 304 | 305 | pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) { 306 | assert_eq!(response.status().as_u16(), 303); 307 | assert_eq!(response.headers().get("Location").unwrap(), location); 308 | } 309 | -------------------------------------------------------------------------------- /tests/api/login.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{assert_is_redirect_to, spawn_app}; 2 | 3 | #[tokio::test] 4 | async fn an_error_flash_message_is_set_on_failure() { 5 | // Arrange 6 | let app = spawn_app().await; 7 | 8 | // Act 9 | let login_body = serde_json::json!({ 10 | "username": "random-username", 11 | "password": "random-password" 12 | }); 13 | let response = app.post_login(&login_body).await; 14 | 15 | // Assert 16 | assert_is_redirect_to(&response, "/login"); 17 | 18 | // Act - Part 2 - Follow the redirect 19 | let html_page = app.get_login_html().await; 20 | assert!(html_page.contains("

Authentication failed

")); 21 | 22 | // Act - Part 3 - Reload the login page 23 | let html_page = app.get_login_html().await; 24 | assert!(!html_page.contains("Authentication failed")); 25 | } 26 | -------------------------------------------------------------------------------- /tests/api/main.rs: -------------------------------------------------------------------------------- 1 | mod admin_dashboard; 2 | mod change_password; 3 | mod health_check; 4 | mod helpers; 5 | mod login; 6 | mod newsletter; 7 | mod subscriptions; 8 | mod subscriptions_confirm; 9 | mod test_user; 10 | -------------------------------------------------------------------------------- /tests/api/newsletter.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{assert_is_redirect_to, spawn_app, ConfirmationLinks, TestApp}; 2 | use fake::faker::internet::en::SafeEmail; 3 | use fake::faker::name::en::Name; 4 | use fake::Fake; 5 | use std::time::Duration; 6 | use wiremock::matchers::{any, method, path}; 7 | use wiremock::{Mock, ResponseTemplate}; 8 | 9 | async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { 10 | // We are working with multiple subscribers now, 11 | // their details must be randomised to avoid conflicts! 12 | let name: String = Name().fake(); 13 | let email: String = SafeEmail().fake(); 14 | let body = serde_urlencoded::to_string(&serde_json::json!({ 15 | "name": name, 16 | "email": email 17 | })) 18 | .unwrap(); 19 | 20 | let _mock_guard = Mock::given(path("/email")) 21 | .and(method("POST")) 22 | .respond_with(ResponseTemplate::new(200)) 23 | .named("Create unconfirmed subscriber") 24 | .expect(1) 25 | .mount_as_scoped(&app.email_server) 26 | .await; 27 | app.post_subscriptions(body.into()) 28 | .await 29 | .error_for_status() 30 | .unwrap(); 31 | 32 | let email_request = &app 33 | .email_server 34 | .received_requests() 35 | .await 36 | .unwrap() 37 | .pop() 38 | .unwrap(); 39 | app.get_confirmation_links(email_request) 40 | } 41 | 42 | async fn create_confirmed_subscriber(app: &TestApp) { 43 | let confirmation_link = create_unconfirmed_subscriber(app).await.html; 44 | reqwest::get(confirmation_link) 45 | .await 46 | .unwrap() 47 | .error_for_status() 48 | .unwrap(); 49 | } 50 | 51 | #[tokio::test] 52 | async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { 53 | // Arrange 54 | let app = spawn_app().await; 55 | create_unconfirmed_subscriber(&app).await; 56 | app.test_user.login(&app).await; 57 | 58 | Mock::given(any()) 59 | .respond_with(ResponseTemplate::new(200)) 60 | .expect(0) 61 | .mount(&app.email_server) 62 | .await; 63 | 64 | // Act - Part 1 - Submit newsletter form 65 | let newsletter_request_body = serde_json::json!({ 66 | "title": "Newsletter title", 67 | "text_content": "Newsletter body as plain text", 68 | "html_content": "

Newsletter body as HTML

", 69 | "idempotency_key": uuid::Uuid::new_v4().to_string() 70 | }); 71 | let response = app.post_publish_newsletter(&newsletter_request_body).await; 72 | assert_is_redirect_to(&response, "/admin/newsletters"); 73 | 74 | // Act - Part 2 - Follow the redirect 75 | let html_page = app.get_publish_newsletter_html().await; 76 | assert!(html_page.contains( 77 | "

The newsletter issue has been accepted - \ 78 | emails will go out shortly.

" 79 | )); 80 | app.dispatch_all_pending_emails().await; 81 | // Mock verifies on Drop that we haven't sent the newsletter email 82 | } 83 | 84 | #[tokio::test] 85 | async fn newsletters_are_delivered_to_confirmed_subscribers() { 86 | // Arrange 87 | let app = spawn_app().await; 88 | create_confirmed_subscriber(&app).await; 89 | app.test_user.login(&app).await; 90 | 91 | Mock::given(path("/email")) 92 | .and(method("POST")) 93 | .respond_with(ResponseTemplate::new(200)) 94 | .expect(1) 95 | .mount(&app.email_server) 96 | .await; 97 | 98 | // Act - Part 1 - Submit newsletter form 99 | let newsletter_request_body = serde_json::json!({ 100 | "title": "Newsletter title", 101 | "text_content": "Newsletter body as plain text", 102 | "html_content": "

Newsletter body as HTML

", 103 | "idempotency_key": uuid::Uuid::new_v4().to_string() 104 | }); 105 | let response = app.post_publish_newsletter(&newsletter_request_body).await; 106 | assert_is_redirect_to(&response, "/admin/newsletters"); 107 | 108 | // Act - Part 2 - Follow the redirect 109 | let html_page = app.get_publish_newsletter_html().await; 110 | assert!(html_page.contains( 111 | "

The newsletter issue has been accepted - \ 112 | emails will go out shortly.

" 113 | )); 114 | app.dispatch_all_pending_emails().await; 115 | // Mock verifies on Drop that we have sent the newsletter email 116 | } 117 | 118 | #[tokio::test] 119 | async fn you_must_be_logged_in_to_see_the_newsletter_form() { 120 | // Arrange 121 | let app = spawn_app().await; 122 | 123 | // Act 124 | let response = app.get_publish_newsletter().await; 125 | 126 | // Assert 127 | assert_is_redirect_to(&response, "/login"); 128 | } 129 | 130 | #[tokio::test] 131 | async fn you_must_be_logged_in_to_publish_a_newsletter() { 132 | // Arrange 133 | let app = spawn_app().await; 134 | 135 | // Act 136 | let newsletter_request_body = serde_json::json!({ 137 | "title": "Newsletter title", 138 | "text_content": "Newsletter body as plain text", 139 | "html_content": "

Newsletter body as HTML

", 140 | "idempotency_key": uuid::Uuid::new_v4().to_string() 141 | }); 142 | let response = app.post_publish_newsletter(&newsletter_request_body).await; 143 | 144 | // Assert 145 | assert_is_redirect_to(&response, "/login"); 146 | } 147 | 148 | #[tokio::test] 149 | async fn newsletter_creation_is_idempotent() { 150 | // Arrange 151 | let app = spawn_app().await; 152 | create_confirmed_subscriber(&app).await; 153 | app.test_user.login(&app).await; 154 | 155 | Mock::given(path("/email")) 156 | .and(method("POST")) 157 | .respond_with(ResponseTemplate::new(200)) 158 | .expect(1) 159 | .mount(&app.email_server) 160 | .await; 161 | 162 | // Act - Part 1 - Submit newsletter form 163 | let newsletter_request_body = serde_json::json!({ 164 | "title": "Newsletter title", 165 | "text_content": "Newsletter body as plain text", 166 | "html_content": "

Newsletter body as HTML

", 167 | // We expect the idempotency key as part of the 168 | // form data, not as an header 169 | "idempotency_key": uuid::Uuid::new_v4().to_string() 170 | }); 171 | let response = app.post_publish_newsletter(&newsletter_request_body).await; 172 | assert_is_redirect_to(&response, "/admin/newsletters"); 173 | 174 | // Act - Part 2 - Follow the redirect 175 | let html_page = app.get_publish_newsletter_html().await; 176 | assert!(html_page.contains( 177 | "

The newsletter issue has been accepted - \ 178 | emails will go out shortly.

" 179 | )); 180 | 181 | // Act - Part 3 - Submit newsletter form **again** 182 | let response = app.post_publish_newsletter(&newsletter_request_body).await; 183 | assert_is_redirect_to(&response, "/admin/newsletters"); 184 | 185 | // Act - Part 4 - Follow the redirect 186 | let html_page = app.get_publish_newsletter_html().await; 187 | assert!(html_page.contains( 188 | "

The newsletter issue has been accepted - \ 189 | emails will go out shortly.

" 190 | )); 191 | 192 | app.dispatch_all_pending_emails().await; 193 | // Mock verifies on Drop that we have sent the newsletter email **once** 194 | } 195 | 196 | #[tokio::test] 197 | async fn concurrent_form_submission_is_handled_gracefully() { 198 | // Arrange 199 | let app = spawn_app().await; 200 | create_confirmed_subscriber(&app).await; 201 | app.test_user.login(&app).await; 202 | 203 | Mock::given(path("/email")) 204 | .and(method("POST")) 205 | // Setting a long delay to ensure that the second request 206 | // arrives before the first one completes 207 | .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2))) 208 | .expect(1) 209 | .mount(&app.email_server) 210 | .await; 211 | 212 | // Act - Submit two newsletter forms concurrently 213 | let newsletter_request_body = serde_json::json!({ 214 | "title": "Newsletter title", 215 | "text_content": "Newsletter body as plain text", 216 | "html_content": "

Newsletter body as HTML

", 217 | "idempotency_key": uuid::Uuid::new_v4().to_string() 218 | }); 219 | let response1 = app.post_publish_newsletter(&newsletter_request_body); 220 | let response2 = app.post_publish_newsletter(&newsletter_request_body); 221 | let (response1, response2) = tokio::join!(response1, response2); 222 | 223 | assert_eq!(response1.status(), response2.status()); 224 | assert_eq!( 225 | response1.text().await.unwrap(), 226 | response2.text().await.unwrap() 227 | ); 228 | app.dispatch_all_pending_emails().await; 229 | // Mock verifies on Drop that we have sent the newsletter email **once** 230 | } 231 | -------------------------------------------------------------------------------- /tests/api/subscriptions.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::spawn_app; 2 | use wiremock::matchers::{method, path}; 3 | use wiremock::{Mock, ResponseTemplate}; 4 | 5 | #[tokio::test] 6 | async fn subscribe_returns_a_200_for_valid_form_data() { 7 | // Arrange 8 | let app = spawn_app().await; 9 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 10 | 11 | Mock::given(path("/email")) 12 | .and(method("POST")) 13 | .respond_with(ResponseTemplate::new(200)) 14 | .mount(&app.email_server) 15 | .await; 16 | 17 | // Act 18 | let response = app.post_subscriptions(body.into()).await; 19 | 20 | // Assert 21 | assert_eq!(200, response.status().as_u16()); 22 | } 23 | 24 | #[tokio::test] 25 | async fn subscribe_persists_the_new_subscriber() { 26 | // Arrange 27 | let app = spawn_app().await; 28 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 29 | 30 | // Act 31 | app.post_subscriptions(body.into()).await; 32 | 33 | // Assert 34 | let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",) 35 | .fetch_one(&app.db_pool) 36 | .await 37 | .expect("Failed to fetch saved subscription."); 38 | 39 | assert_eq!(saved.email, "ursula_le_guin@gmail.com"); 40 | assert_eq!(saved.name, "le guin"); 41 | assert_eq!(saved.status, "pending_confirmation"); 42 | } 43 | 44 | #[tokio::test] 45 | async fn subscribe_fails_if_there_is_a_fatal_database_error() { 46 | // Arrange 47 | let app = spawn_app().await; 48 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 49 | // Sabotage the database 50 | sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email;",) 51 | .execute(&app.db_pool) 52 | .await 53 | .unwrap(); 54 | 55 | // Act 56 | let response = app.post_subscriptions(body.into()).await; 57 | 58 | // Assert 59 | assert_eq!(response.status().as_u16(), 500); 60 | } 61 | 62 | #[tokio::test] 63 | async fn subscribe_sends_a_confirmation_email_for_valid_data() { 64 | // Arrange 65 | let app = spawn_app().await; 66 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 67 | 68 | Mock::given(path("/email")) 69 | .and(method("POST")) 70 | .respond_with(ResponseTemplate::new(200)) 71 | .expect(1) 72 | .mount(&app.email_server) 73 | .await; 74 | 75 | // Act 76 | app.post_subscriptions(body.into()).await; 77 | 78 | // Assert 79 | // Mock asserts on drop 80 | } 81 | 82 | #[tokio::test] 83 | async fn subscribe_sends_a_confirmation_email_with_a_link() { 84 | // Arrange 85 | let app = spawn_app().await; 86 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 87 | 88 | Mock::given(path("/email")) 89 | .and(method("POST")) 90 | .respond_with(ResponseTemplate::new(200)) 91 | .mount(&app.email_server) 92 | .await; 93 | 94 | // Act 95 | app.post_subscriptions(body.into()).await; 96 | 97 | // Assert 98 | let email_request = &app.email_server.received_requests().await.unwrap()[0]; 99 | let confirmation_links = app.get_confirmation_links(email_request); 100 | 101 | // The two links should be identical 102 | assert_eq!(confirmation_links.html, confirmation_links.plain_text); 103 | } 104 | 105 | #[tokio::test] 106 | async fn subscribe_returns_a_400_when_data_is_missing() { 107 | // Arrange 108 | let app = spawn_app().await; 109 | let test_cases = vec![ 110 | ("name=le%20guin", "missing the email"), 111 | ("email=ursula_le_guin%40gmail.com", "missing the name"), 112 | ("", "missing both name and email"), 113 | ]; 114 | 115 | for (invalid_body, error_message) in test_cases { 116 | // Act 117 | let response = app.post_subscriptions(invalid_body.into()).await; 118 | 119 | // Assert 120 | assert_eq!( 121 | 400, 122 | response.status().as_u16(), 123 | // Additional customised error message on test failure 124 | "The API did not fail with 400 Bad Request when the payload was {}.", 125 | error_message 126 | ); 127 | } 128 | } 129 | 130 | #[tokio::test] 131 | async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { 132 | // Arrange 133 | let app = spawn_app().await; 134 | let test_cases = vec![ 135 | ("name=&email=ursula_le_guin%40gmail.com", "empty name"), 136 | ("name=Ursula&email=", "empty email"), 137 | ("name=Ursula&email=definitely-not-an-email", "invalid email"), 138 | ]; 139 | 140 | for (body, description) in test_cases { 141 | // Act 142 | let response = app.post_subscriptions(body.into()).await; 143 | 144 | // Assert 145 | assert_eq!( 146 | 400, 147 | response.status().as_u16(), 148 | "The API did not return a 400 Bad Request when the payload was {}.", 149 | description 150 | ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/api/subscriptions_confirm.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::spawn_app; 2 | use wiremock::matchers::{method, path}; 3 | use wiremock::{Mock, ResponseTemplate}; 4 | 5 | #[tokio::test] 6 | async fn confirmations_without_token_are_rejected_with_a_400() { 7 | // Arrange 8 | let app = spawn_app().await; 9 | 10 | // Act 11 | let response = reqwest::get(&format!("{}/subscriptions/confirm", app.address)) 12 | .await 13 | .unwrap(); 14 | 15 | // Assert 16 | assert_eq!(response.status().as_u16(), 400); 17 | } 18 | 19 | #[tokio::test] 20 | async fn the_link_returned_by_subscribe_returns_a_200_if_called() { 21 | // Arrange 22 | let app = spawn_app().await; 23 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 24 | 25 | Mock::given(path("/email")) 26 | .and(method("POST")) 27 | .respond_with(ResponseTemplate::new(200)) 28 | .mount(&app.email_server) 29 | .await; 30 | 31 | app.post_subscriptions(body.into()).await; 32 | let email_request = &app.email_server.received_requests().await.unwrap()[0]; 33 | let confirmation_links = app.get_confirmation_links(email_request); 34 | 35 | // Act 36 | let response = reqwest::get(confirmation_links.html).await.unwrap(); 37 | 38 | // Assert 39 | assert_eq!(response.status().as_u16(), 200); 40 | } 41 | 42 | #[tokio::test] 43 | async fn clicking_on_the_confirmation_link_confirms_a_subscriber() { 44 | // Arrange 45 | let app = spawn_app().await; 46 | let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; 47 | 48 | Mock::given(path("/email")) 49 | .and(method("POST")) 50 | .respond_with(ResponseTemplate::new(200)) 51 | .mount(&app.email_server) 52 | .await; 53 | 54 | app.post_subscriptions(body.into()).await; 55 | let email_request = &app.email_server.received_requests().await.unwrap()[0]; 56 | let confirmation_links = app.get_confirmation_links(email_request); 57 | 58 | // Act 59 | reqwest::get(confirmation_links.html) 60 | .await 61 | .unwrap() 62 | .error_for_status() 63 | .unwrap(); 64 | 65 | // Assert 66 | let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",) 67 | .fetch_one(&app.db_pool) 68 | .await 69 | .expect("Failed to fetch saved subscription."); 70 | 71 | assert_eq!(saved.email, "ursula_le_guin@gmail.com"); 72 | assert_eq!(saved.name, "le guin"); 73 | assert_eq!(saved.status, "confirmed"); 74 | } 75 | -------------------------------------------------------------------------------- /tests/api/test_user.rs: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------------------------------