diff --git a/- b/- new file mode 100644 index 0000000..a366bd7 --- /dev/null +++ b/- @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -x +set -eo pipefail + +if ! [ -x "$(command -v psql)" ]; then + echo >&2 "Error: psql is not installed." + exit 1 +fi + +if ! [ -x "$(command -v sqlx)" ]; then + echo >&2 "Error: sqlx is not installed." + echo >&2 "Use:" + echo >&2 " cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres" + echo >&2 "to install it." + exit 1 +fi + +# Check if a custom user has been set, otherwise default to 'postgres' +DB_USER=${POSTGRES_USER:=postgres} +# Check if a custom password has been set, otherwise default to 'password' +DB_PASSWORD="${POSTGRES_PASSWORD:=password}" +# Check if a custom database name has been set, otherwise default to 'newsletter' +DB_NAME="${POSTGRES_DB:=newsletter}" +# Check if a custom port has been set, otherwise default to '5432' +DB_PORT="${POSTGRES_PORT:=5432}" + + +# Allow to skip Docker if a dockerized Postgres database is already running +if [[ -z "${SKIP_DOCKER}" ]] +then + docker run \ + -e POSTGRES_USER=${DB_USER} \ + -e POSTGRES_PASSWORD=${DB_PASSWORD} \ + -e POSTGRES_DB=${DB_NAME} \ + -p "${DB_PORT}":5432 \ + -d postgres \ + postgres -N 1000 +fi + +export PGPASSWORD="${DB_PASSWORD}" +until psql -h "localhost" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do + >&2 echo "Postgres is still unavailable - sleeping" + sleep 1 +done + +>&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations now!" + +export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME} +sqlx database create +sqlx migrate run + +>&2 echo "Postgres has been migrated, ready to go!" diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 6a1d763..3c82331 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -57,6 +57,7 @@ jobs: - name: Install sqlx-cli uses: actions-rs/cargo@v1 + # TODO(This fails, and should perhaps just check if the binary is installed) if: steps.cache-sqlx.outputs.cache-hit == false with: command: install diff --git a/Cargo.lock b/Cargo.lock index d074b8a..b6307f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.0.4" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5885cb81a0d4d0d322864bea1bb6c2a8144626b4fdc625d4c51eba197e7797a" +checksum = "bd2e9f6794b5826aff6df65e3a0d0127b271d1c03629c774238f3582e903d4e4" dependencies = [ "actix-codec", "actix-rt", @@ -46,13 +46,13 @@ dependencies = [ "itoa", "language-tags", "local-channel", - "log", "mime", "percent-encoding", "pin-project-lite", "rand 0.8.5", - "sha-1", + "sha1", "smallvec", + "tracing", "zstd", ] @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.0.1" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e5ebffd51d50df56a3ae0de0e59487340ca456f05dd0b90c0a7a6dd6a74d31" +checksum = "a27e8fe9ba4ae613c21f677c2cfaf0696c3744030c6f485b34634e502d6bb379" dependencies = [ "actix-codec", "actix-http", @@ -166,15 +166,15 @@ dependencies = [ "serde_urlencoded", "smallvec", "socket2", - "time 0.3.9", + "time 0.3.10", "url", ] [[package]] name = "actix-web-codegen" -version = "4.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7525bedf54704abb1d469e88d7e7e9226df73778798a69cea5022d53b2ae91bc" +checksum = "5f270541caec49c15673b0af0e9a00143421ad4f118d2df7edcb68b627632f56" dependencies = [ "actix-router", "proc-macro2", @@ -194,7 +194,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", "once_cell", "version_check", ] @@ -232,12 +232,50 @@ dependencies = [ "winapi", ] +[[package]] +name = "anyhow" +version = "1.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" + [[package]] name = "arrayvec" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "assert-json-diff" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f1c3703dd33532d7f0ca049168930e9099ecac238e23cf932f3a69c42f06da" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-channel" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-trait" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "0.4.0" @@ -315,13 +353,19 @@ checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" [[package]] name = "bytestring" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90706ba19e97b90786e19dc0d5e2abd80008d99d4c0c5d1ad0b5e72cec7c494d" +checksum = "86b6a75fd3048808ef06af5cd79712be8111960adaf89d90250974b38fc3928a" dependencies = [ "bytes", ] +[[package]] +name = "cache-padded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c" + [[package]] name = "cc" version = "1.0.73" @@ -346,7 +390,7 @@ dependencies = [ "libc", "num-integer", "num-traits", - "time 0.1.43", + "time 0.1.44", "winapi", ] @@ -359,6 +403,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "concurrent-queue" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3" +dependencies = [ + "cache-padded", +] + [[package]] name = "config" version = "0.11.0" @@ -384,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" dependencies = [ "percent-encoding", - "time 0.3.9", + "time 0.3.10", "version_check", ] @@ -400,7 +453,7 @@ dependencies = [ "publicsuffix", "serde", "serde_json", - "time 0.3.9", + "time 0.3.10", "url", ] @@ -449,12 +502,12 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +checksum = "8ff1f980957787286a554052d03c7aee98d99cc32e09f6d45f0a814133c87978" dependencies = [ "cfg-if", - "lazy_static", + "once_cell", ] [[package]] @@ -467,6 +520,25 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "retain_mut", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1" + [[package]] name = "derive_more" version = "0.99.17" @@ -560,6 +632,15 @@ dependencies = [ "rand 0.7.3", ] +[[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + [[package]] name = "firestorm" version = "0.5.1" @@ -592,6 +673,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.21" @@ -608,6 +704,17 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-intrusive" version = "0.4.0" @@ -619,6 +726,38 @@ dependencies = [ "parking_lot 0.11.2", ] +[[package]] +name = "futures-io" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" + +[[package]] +name = "futures-lite" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.21" @@ -631,17 +770,28 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" + [[package]] name = "futures-util" version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -677,13 +827,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi 0.10.2+wasi-snapshot-preview1", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -714,13 +864,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" + [[package]] name = "hashlink" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" dependencies = [ - "hashbrown", + "hashbrown 0.11.2", ] [[package]] @@ -787,6 +943,27 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64", + "futures-lite", + "http", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + [[package]] name = "httparse" version = "1.7.1" @@ -849,14 +1026,20 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" +checksum = "6c6392766afd7964e2531940894cffe4bd8d7d17dbc3c1c4857040fd4b33bdb3" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.1", ] +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + [[package]] name = "instant" version = "0.1.12" @@ -898,9 +1081,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.57" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" +checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27" dependencies = [ "wasm-bindgen", ] @@ -942,6 +1125,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +[[package]] +name = "linkify" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d9967eb7d0bc31c39c6f52e8fce42991c0cd1f7a2078326f0b7a399a584c8d" +dependencies = [ + "memchr", +] + [[package]] name = "local-channel" version = "0.1.3" @@ -1031,9 +1223,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", @@ -1106,6 +1298,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225" +[[package]] +name = "parking" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1212,9 +1410,9 @@ checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" [[package]] name = "proc-macro2" -version = "1.0.39" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" dependencies = [ "unicode-ident", ] @@ -1232,7 +1430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "292972edad6bbecc137ab84c5e36421a4a6c979ea31d3cc73540dd04315b33e1" dependencies = [ "byteorder", - "hashbrown", + "hashbrown 0.11.2", "idna", "psl-types", ] @@ -1262,9 +1460,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" dependencies = [ "proc-macro2", ] @@ -1328,7 +1526,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", ] [[package]] @@ -1355,7 +1553,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", "redox_syscall", "thiserror", ] @@ -1428,6 +1626,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "retain_mut" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" + [[package]] name = "ring" version = "0.16.20" @@ -1520,9 +1724,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb243bdfdb5936c8dc3c45762a19d12ab4550cdc753bc247637d4ec35a040fd" +checksum = "a41d061efea015927ac527063765e73601444cdc344ba855bc7bd44578b25e1c" [[package]] name = "serde" @@ -1566,6 +1770,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1589,6 +1804,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.2" @@ -1779,9 +2005,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.96" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" dependencies = [ "proc-macro2", "quote", @@ -1819,19 +2045,20 @@ dependencies = [ [[package]] name = "time" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" dependencies = [ "libc", + "wasi 0.10.0+wasi-snapshot-preview1", "winapi", ] [[package]] name = "time" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd" +checksum = "82501a4c1c0330d640a6e176a3d6a204f5ec5237aca029029d21864a902e27b0" dependencies = [ "itoa", "libc", @@ -1940,9 +2167,9 @@ dependencies = [ [[package]] name = "tower-service" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" @@ -1991,7 +2218,7 @@ dependencies = [ "log", "serde", "serde_json", - "time 0.3.9", + "time 0.3.10", "tracing", "tracing-core", "tracing-log", @@ -2067,9 +2294,9 @@ checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" [[package]] name = "unicode-ident" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" +checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" [[package]] name = "unicode-normalization" @@ -2108,6 +2335,7 @@ dependencies = [ "idna", "matches", "percent-encoding", + "serde", ] [[package]] @@ -2116,7 +2344,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", "serde", ] @@ -2147,6 +2375,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + [[package]] name = "want" version = "0.3.0" @@ -2165,9 +2399,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +version = "0.10.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "wasi" @@ -2177,9 +2411,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" +checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -2187,9 +2421,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4" +checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a" dependencies = [ "bumpalo", "lazy_static", @@ -2202,9 +2436,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f741de44b75e14c35df886aff5f1eb73aa114fa5d4d00dcd37b5e01259bf3b2" +checksum = "de9a9cec1733468a8c657e57fa2413d2ae2c0129b95e87c5b72b8ace4d13f31f" dependencies = [ "cfg-if", "js-sys", @@ -2214,9 +2448,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5" +checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2224,9 +2458,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" +checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048" dependencies = [ "proc-macro2", "quote", @@ -2237,15 +2471,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" +checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be" [[package]] name = "web-sys" -version = "0.3.57" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283" +checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" dependencies = [ "js-sys", "wasm-bindgen", @@ -2373,6 +2607,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "wiremock" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b12f508bdca434a55d43614d26f02e6b3e98ebeecfbc5a1614e0a0c8bf3e315" +dependencies = [ + "assert-json-diff", + "async-trait", + "deadpool", + "futures", + "futures-timer", + "http-types", + "hyper", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -2392,6 +2647,7 @@ dependencies = [ "claim", "config", "fake", + "linkify", "log", "once_cell", "quickcheck", @@ -2399,6 +2655,7 @@ dependencies = [ "reqwest", "serde", "serde-aux", + "serde_json", "sqlx", "tokio", "tracing", @@ -2410,22 +2667,23 @@ dependencies = [ "unicode-segmentation", "uuid", "validator", + "wiremock", ] [[package]] name = "zstd" -version = "0.10.2+zstd.1.5.2" +version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4a6bd64f22b5e3e94b4e238669ff9f10815c27a5180108b849d24174a83847" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "4.1.6+zstd.1.5.2" +version = "5.0.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b61c51bb270702d6167b8ce67340d2754b088d0c091b06e593aa772c3ee9bb" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" dependencies = [ "libc", "zstd-sys", @@ -2433,9 +2691,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "1.6.3+zstd.1.5.2" +version = "2.0.1+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc49afa5c8d634e75761feda8c592051e7eeb4683ba827211eb0d731d3402ea8" +checksum = "9fd07cbbc53846d9145dbffdf6dd09a7a0aa52be46741825f5c97bdd4f73f12b" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index ca38527..c978f0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,23 +9,27 @@ edition = "2021" path = "src/lib.rs" [dependencies] -actix-web = "4" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -serde = "1.0.115" +actix-web = "4.1.0" +tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] } +serde = "1.0.137" +# TODO(Config breaks TryFrom between 0.11 -> 0.13, violating semantic versioning) config = { version = "0.11", default-features = false, features = ["yaml"] } uuid = { version = "0.8.1", features = ["v4", "serde"] } -chrono = "0.4.15" -log = "0.4" -tracing = "0.1.19" -tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } -tracing-bunyan-formatter = "0.3" +chrono = "0.4.19" +log = "0.4.17" +tracing = "0.1.35" +tracing-subscriber = { version = "0.3.11", features = ["registry", "env-filter"] } +tracing-bunyan-formatter = "0.3.2" tracing-futures = "0.2.5" tracing-log = "0.1.3" tracing-actix-web = "0.5.1" +reqwest = { version = "0.11.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } serde-aux = "3.0.1" unicode-segmentation = "1.9.0" validator = "0.15.0" +wiremock = "0.5.13" +# TODO(sqlx breaks connect_timeout on minor version upgrade, violating semantic versioning) [dependencies.sqlx] version = "0.5.7" default-features = false @@ -42,8 +46,11 @@ features = [ [dev-dependencies] actix-rt = "2.7.0" once_cell = "1.12.0" -reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } claim = "0.5.0" quickcheck = "0.9.2" quickcheck_macros = "0.9.1" -fake = "~2.3.0" \ No newline at end of file +# TODO(Fake breaks API from 2.3 -> 2.5, violating semantic versioning) +fake = "~2.3.0" +serde_json = "1.0.81" +# TODO(The cargo add command for this in section 7.6.2 was incorrect) +linkify = "0.8.0" diff --git a/README.md b/README.md index 7acb754..ece50b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # zero2prod My workthrough of Zero to Production in Rust + +## Notes + +- `ulimit -n 10000` if you get stuck section in 7.6 \ No newline at end of file diff --git a/configuration/base.yaml b/configuration/base.yaml index 82b4342..65151de 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -5,4 +5,9 @@ database: port: 5432 username: "postgres" password: "password" - database_name: "newsletter" \ No newline at end of file + database_name: "newsletter" +email_client: + base_url: "localhost" + sender_email: "test@gmail.com" + authorization_token: "mysecrettoken" + timeout_milliseconds: 10000 \ No newline at end of file diff --git a/configuration/local.yaml b/configuration/local.yaml index 3b77405..974d53d 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -1,4 +1,5 @@ application: host: 127.0.0.1 + base_url: "http://127.0.0.1" database: require_ssl: false \ No newline at end of file diff --git a/configuration/production.yaml b/configuration/production.yaml index f3ac210..3754c90 100644 --- a/configuration/production.yaml +++ b/configuration/production.yaml @@ -1,4 +1,9 @@ application: host: 0.0.0.0 database: - require_ssl: true \ No newline at end of file + require_ssl: true +email_client: + # Value retrieved from Postmark's API documentation + base_url: "https://api.postmarkapp.com" + # Use the single sender email you authorised on Postmark! + sender_email: "something@gmail.com" diff --git a/migrations/20220621192541_add_status_to_subscriptions.sql b/migrations/20220621192541_add_status_to_subscriptions.sql new file mode 100644 index 0000000..98c44b9 --- /dev/null +++ b/migrations/20220621192541_add_status_to_subscriptions.sql @@ -0,0 +1 @@ +ALTER TABLE subscriptions ADD COLUMN status TEXT NULL; \ No newline at end of file diff --git a/migrations/20220621194051_make_status_not_null_in_subscriptions.sql b/migrations/20220621194051_make_status_not_null_in_subscriptions.sql new file mode 100644 index 0000000..1b8527f --- /dev/null +++ b/migrations/20220621194051_make_status_not_null_in_subscriptions.sql @@ -0,0 +1,9 @@ +-- Wrap the whole migration in one transaction +BEGIN; + -- Backfill `status` for historical entries + UPDATE subscriptions + SET status = 'confirmed' + WHERE status IS NULL; + -- Make `status` mandatory + ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL; +COMMIT; diff --git a/migrations/20220621194705_create_subscription_tokens_table.sql b/migrations/20220621194705_create_subscription_tokens_table.sql new file mode 100644 index 0000000..42450e8 --- /dev/null +++ b/migrations/20220621194705_create_subscription_tokens_table.sql @@ -0,0 +1,7 @@ +-- Create Subscriptions Tokens Table +CREATE TABLE subscription_tokens( + subscription_token TEXT NOT NULL, + subscriber_id uuid NOT NULL + REFERENCES subscriptions (id), + PRIMARY KEY (subscription_token) +); \ No newline at end of file diff --git a/scripts/init_db.sh b/scripts/init_db.sh index 1c50f9f..a366bd7 100755 --- a/scripts/init_db.sh +++ b/scripts/init_db.sh @@ -1,4 +1,4 @@ -s#!/usr/bin/env bash +#!/usr/bin/env bash set -x set -eo pipefail @@ -49,4 +49,4 @@ export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${ sqlx database create sqlx migrate run ->&2 echo "Postgres has been migrated, ready to go!" \ No newline at end of file +>&2 echo "Postgres has been migrated, ready to go!" diff --git a/spec.yaml b/spec.yaml index f561fe2..b436b08 100644 --- a/spec.yaml +++ b/spec.yaml @@ -53,6 +53,11 @@ services: - key: APP_DATABASE__DATABASE_NAME scope: RUN_TIME value: ${newsletter.DATABASE} + # We use DO's APP_URL to inject the dynamically + # provisioned base url as an environment variable + - key: APP_APPLICATION__BASE_URL + scope: RUN_TIME + value: ${APP_URL} databases: # PG = Postgres - engine: PG diff --git a/src/configuration.rs b/src/configuration.rs index 9ceeea4..fdce823 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -1,3 +1,4 @@ +use crate::domain::SubscriberEmail; use serde_aux::field_attributes::deserialize_number_from_string; use sqlx::postgres::{PgConnectOptions, PgSslMode}; use sqlx::ConnectOptions; @@ -5,7 +6,7 @@ use std::convert::{TryFrom, TryInto}; // All this seems a bit much rather than just using a few environment variables. -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct DatabaseSettings { pub username: String, pub password: String, @@ -41,17 +42,37 @@ impl DatabaseSettings { } } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, + pub base_url: String, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct Settings { pub database: DatabaseSettings, pub application: ApplicationSettings, + pub email_client: EmailClientSettings, +} + +#[derive(serde::Deserialize, Clone)] +pub struct EmailClientSettings { + pub base_url: String, + pub sender_email: String, + pub authorization_token: String, + pub timeout_milliseconds: u64, +} + +impl EmailClientSettings { + pub fn sender(&self) -> Result { + SubscriberEmail::parse(self.sender_email.clone()) + } + + pub fn timeout(&self) -> std::time::Duration { + std::time::Duration::from_millis(self.timeout_milliseconds) + } } pub fn get_configuration() -> Result { diff --git a/src/email_client.rs b/src/email_client.rs new file mode 100644 index 0000000..a9f3bd1 --- /dev/null +++ b/src/email_client.rs @@ -0,0 +1,225 @@ +use crate::domain::SubscriberEmail; +use reqwest::Client; + +#[derive(Debug)] +pub struct EmailClient { + http_client: Client, + base_url: String, + sender: SubscriberEmail, + authorization_token: String, +} + +impl EmailClient { + pub fn new( + base_url: String, + sender: SubscriberEmail, + authorization_token: String, + timeout: std::time::Duration, + ) -> Self { + Self { + http_client: Client::builder().timeout(timeout).build().unwrap(), + base_url, + sender, + authorization_token, + } + } + + pub async fn send_email( + &self, + recipient: SubscriberEmail, + subject: &str, + html_content: &str, + text_content: &str, + ) -> Result<(), reqwest::Error> { + let url = format!("{}/email", self.base_url); + let request_body = SendEmailRequest { + from: self.sender.as_ref().to_owned(), + to: recipient.as_ref().to_owned(), + subject: subject.to_owned(), + html_body: html_content.to_owned(), + text_body: text_content.to_owned(), + }; + self.http_client + .post(&url) + .header("X-Postmark-Server-Token", &self.authorization_token) + .json(&request_body) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "PascalCase")] +struct SendEmailRequest { + from: String, + to: String, + subject: String, + html_body: String, + text_body: String, +} + +#[cfg(test)] +mod tests { + use crate::domain::SubscriberEmail; + use crate::email_client::EmailClient; + use claim::{assert_err, assert_ok}; + use fake::faker::internet::en::SafeEmail; + use fake::faker::lorem::en::{Paragraph, Sentence}; + use fake::{Fake, Faker}; + use wiremock::matchers::{any, header, header_exists, method, path}; + use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + + struct SendEmailBodyMatcher; + + impl wiremock::Match for SendEmailBodyMatcher { + fn matches(&self, request: &Request) -> bool { + // Try to parse the body as a JSON value + let result: Result = serde_json::from_slice(&request.body); + if let Ok(body) = result { + // Check that all the mandatory fields are populated + // without inspecting the field values + body.get("From").is_some() + && body.get("To").is_some() + && body.get("Subject").is_some() + && body.get("HtmlBody").is_some() + && body.get("TextBody").is_some() + } else { + // If parsing failed, do not match the request + false + } + } + } + + /// Generate a random email subject + fn subject() -> String { + Sentence(1..2).fake() + } + + /// Generate random email content + fn content() -> String { + Paragraph(1..10).fake() + } + + /// Generate a random subscriber email + fn email() -> SubscriberEmail { + SubscriberEmail::parse(SafeEmail().fake()).unwrap() + } + + /// Get a test instance of `EmailClient`. + fn email_client(base_url: String) -> EmailClient { + EmailClient::new( + base_url, + email(), + Faker.fake(), + std::time::Duration::from_millis(200), + ) + } + + #[tokio::test] + async fn send_email_succeeds_if_the_server_returns_200() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + // We do not copy in all the matchers we have in the other test. + // The purpose of this test is not to assert on the request we + // are sending out! + // We add the bare minimum needed to trigger the path we want + // to test in `send_email`. + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert + assert_ok!(outcome); + } + + #[tokio::test] + async fn send_email_fails_if_the_server_returns_500() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + Mock::given(any()) + // Not a 200 anymore! + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert + assert_err!(outcome); + } + + #[tokio::test] + async fn send_email_times_out_if_the_server_takes_too_long() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + let response = ResponseTemplate::new(200).set_delay(std::time::Duration::from_millis(300)); + Mock::given(any()) + .respond_with(response) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert + assert_err!(outcome); + } + + #[tokio::test] + async fn send_email_sends_the_expected_request() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(header_exists("X-Postmark-Server-Token")) + .and(header("Content-Type", "application/json")) + .and(path("/email")) + .and(method("POST")) + .and(SendEmailBodyMatcher) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let _ = email_client + .send_email(email(), &subject(), &content(), &content()) + .await; + + // Assert + } +} diff --git a/src/lib.rs b/src/lib.rs index 19fce70..66e386a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod configuration; pub mod domain; +pub mod email_client; pub mod routes; pub mod startup; pub mod telemetry; diff --git a/src/main.rs b/src/main.rs index 8a06ed6..038dee9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,15 @@ -use sqlx::postgres::PgPoolOptions; -use std::net::TcpListener; +//! src/main.rs use zero2prod::configuration::get_configuration; -use zero2prod::startup::run; +use zero2prod::startup::Application; use zero2prod::telemetry::{get_subscriber, init_subscriber}; #[actix_web::main] async fn main() -> std::io::Result<()> { let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); init_subscriber(subscriber); + let configuration = get_configuration().expect("Failed to read configuration."); - let address = format!( - "{}:{}", - configuration.application.host, configuration.application.port - ); - let connection_pool = PgPoolOptions::new() - .connect_timeout(std::time::Duration::from_secs(2)) - // `connect_lazy_with` instead of `connect_lazy` - .connect_lazy_with(configuration.database.with_db()); - let listener = TcpListener::bind(address).expect("Failed to bind web service port."); - run(listener, connection_pool)?.await + let application = Application::build(configuration).await?; + application.run_until_stopped().await?; + Ok(()) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 90ffeed..d0ddba0 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,7 @@ mod health_check; mod subscriptions; +mod subscriptions_confirm; pub use health_check::*; pub use subscriptions::*; +pub use subscriptions_confirm::*; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 041ec5c..dbcb143 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,7 +1,8 @@ use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; +use crate::email_client::EmailClient; +use crate::startup::ApplicationBaseUrl; use actix_web::{post, web, HttpResponse}; use chrono::Utc; -use sqlx::types::uuid; use sqlx::PgPool; use std::convert::{TryFrom, TryInto}; use uuid::Uuid; @@ -31,16 +32,55 @@ subscriber_email = %form.email, subscriber_name= %form.name ) )] -pub async fn subscribe(form: web::Form, pool: web::Data) -> HttpResponse { - // The return info here in chapter 6 was unclear, why match on email rather than subscriber? +pub async fn subscribe( + form: web::Form, + pool: web::Data, + email_client: web::Data, + base_url: web::Data, +) -> HttpResponse { let new_subscriber = match form.0.try_into() { Ok(form) => form, Err(_) => return HttpResponse::BadRequest().finish(), }; - match insert_subscriber(&pool, &new_subscriber).await { - Ok(_) => HttpResponse::Ok().finish(), - Err(_) => HttpResponse::InternalServerError().finish(), + if insert_subscriber(&pool, &new_subscriber).await.is_err() { + return HttpResponse::InternalServerError().finish(); } + // TODO(This is accessed incorrectly in section 7.6.5.2) + if send_confirmation_email(&email_client, new_subscriber, base_url.get_ref()) + .await + .is_err() + { + return HttpResponse::InternalServerError().finish(); + } + HttpResponse::Ok().finish() +} + +#[tracing::instrument( + name = "Send a confirmation email to a new subscriber", + skip(email_client, new_subscriber, base_url) +)] +pub async fn send_confirmation_email( + email_client: &EmailClient, + new_subscriber: NewSubscriber, + base_url: &ApplicationBaseUrl, +) -> Result<(), reqwest::Error> { + // TODO(Token here) + let confirmation_link = format!( + "{}/subscriptions/confirm?subscription_token=mytoken", + base_url.0 + ); + let plain_body = format!( + "Welcome to our newsletter!\nVisit {} to confirm your subscription.", + confirmation_link + ); + let html_body = format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription.", + confirmation_link + ); + email_client + .send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body) + .await } #[tracing::instrument( @@ -52,10 +92,8 @@ pub async fn insert_subscriber( new_subscriber: &NewSubscriber, ) -> Result<(), sqlx::Error> { sqlx::query!( - r#" - INSERT INTO subscriptions (id, email, name, subscribed_at) - VALUES ($1, $2, $3, $4) - "#, + r#"INSERT INTO subscriptions (id, email, name, subscribed_at, status) + VALUES ($1, $2, $3, $4, 'pending_confirmation')"#, Uuid::new_v4(), new_subscriber.email.as_ref(), new_subscriber.name.as_ref(), @@ -71,4 +109,6 @@ pub async fn insert_subscriber( // We will talk about error handling in depth later! })?; Ok(()) + + // license } diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs new file mode 100644 index 0000000..b0af3f5 --- /dev/null +++ b/src/routes/subscriptions_confirm.rs @@ -0,0 +1,12 @@ +use actix_web::{get, web, HttpResponse}; + +#[derive(serde::Deserialize)] +pub struct Parameters { + subscription_token: String, +} + +#[get("/subscriptions/confirm")] +#[tracing::instrument(name = "Confirm a pending subscriber", skip(_parameters))] +pub async fn confirm(_parameters: web::Query) -> HttpResponse { + HttpResponse::Ok().finish() +} diff --git a/src/startup.rs b/src/startup.rs index 7e87a57..ab5c089 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,4 +1,6 @@ +use crate::email_client::EmailClient; use actix_web::dev::Server; +use actix_web::web::Data; use actix_web::{web, App, HttpServer}; use sqlx::PgPool; use std::net::TcpListener; @@ -6,14 +8,90 @@ use tracing_actix_web::TracingLogger; use crate::routes::*; -pub fn run(listener: TcpListener, db_pool: PgPool) -> Result { +use crate::configuration::{DatabaseSettings, Settings}; +use sqlx::postgres::PgPoolOptions; + +// A new type to hold the newly built server and its port +pub struct Application { + port: u16, + server: Server, +} + +impl Application { + // We have converted the `build` function into a constructor for + // `Application`. + pub async fn build(configuration: Settings) -> Result { + let connection_pool = get_connection_pool(&configuration.database); + + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + // TODO(This was missing from the book in section 7.2.14.1) + let timeout = configuration.email_client.timeout(); + let email_client = EmailClient::new( + configuration.email_client.base_url, + sender_email, + configuration.email_client.authorization_token, + timeout, + ); + + let address = format!( + "{}:{}", + configuration.application.host, configuration.application.port + ); + let listener = TcpListener::bind(&address)?; + let port = listener.local_addr().unwrap().port(); + let server = run( + listener, + connection_pool, + email_client, + configuration.application.base_url, + )?; + + // We "save" the bound port in one of `Application`'s fields + Ok(Self { port, server }) + } + + pub fn port(&self) -> u16 { + self.port + } + + // A more expressive name that makes it clear that + // this function only returns when the application is stopped. + pub async fn run_until_stopped(self) -> Result<(), std::io::Error> { + self.server.await + } +} + +pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool { + PgPoolOptions::new() + .connect_timeout(std::time::Duration::from_secs(2)) + .connect_lazy_with(configuration.with_db()) +} + +// Workaround for type based data retrieval +#[derive(Debug)] +pub struct ApplicationBaseUrl(pub String); + +pub fn run( + listener: TcpListener, + db_pool: PgPool, + email_client: EmailClient, + base_url: String, +) -> Result { let db_pool = web::Data::new(db_pool); + let email_client = web::Data::new(email_client); + let base_url = Data::new(ApplicationBaseUrl(base_url)); let server = HttpServer::new(move || { App::new() .wrap(TracingLogger::default()) .service(health_check) .service(subscribe) + .service(confirm) + .app_data(email_client.clone()) .app_data(db_pool.clone()) + .app_data(base_url.clone()) }) .listen(listener)? .run(); diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..3327665 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,28 @@ +use crate::helpers::spawn_app; + +// `actix_rt::test` is the testing equivalent of `actix_web::main`. +// It also spares you from having to specify the `#[test]` attribute. +// +// Use `cargo add actix-rt --dev --vers 2` to add `actix-rt` +// under `[dev-dependencies]` in Cargo.toml +// +// You can inspect what code gets generated using +// `cargo expand --test health_check` (<- name of the test file) +#[actix_rt::test] +async fn health_check_works() { + // Arrange + let app = spawn_app().await; + let client = reqwest::Client::new(); + + // Act + let response = client + // Use the returned application address + .get(&format!("{}/health_check", &app.address)) + .send() + .await + .expect("Failed to execute request."); + + // Assert + assert!(response.status().is_success()); + assert_eq!(Some(0), response.content_length()); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs new file mode 100644 index 0000000..c730659 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,131 @@ +use once_cell::sync::Lazy; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use uuid::Uuid; +use wiremock::MockServer; +use zero2prod::configuration::{get_configuration, DatabaseSettings}; +use zero2prod::startup::{get_connection_pool, Application}; +use zero2prod::telemetry::{get_subscriber, init_subscriber}; + +// Ensure that the `tracing` stack is only initialised once using `once_cell` +static TRACING: Lazy<()> = Lazy::new(|| { + let default_filter_level = "info".to_string(); + let subscriber_name = "test".to_string(); + // We cannot assign the output of `get_subscriber` to a variable based on the value of `TEST_LOG` + // because the sink is part of the type returned by `get_subscriber`, therefore they are not the + // same type. We could work around it, but this is the most straight-forward way of moving forward. + if std::env::var("TEST_LOG").is_ok() { + let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); + init_subscriber(subscriber); + } else { + let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); + init_subscriber(subscriber); + }; +}); + +/// Confirmation links embedded in the request to the email API. +pub struct ConfirmationLinks { + pub html: reqwest::Url, + pub plain_text: reqwest::Url, +} + +pub struct TestApp { + pub port: u16, + pub address: String, + pub db_pool: PgPool, + pub email_server: MockServer, +} + +impl TestApp { + pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { + reqwest::Client::new() + .post(&format!("{}/subscriptions", &self.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request.") + } + + pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + + // Extract the link from one of the request fields. + let get_link = |s: &str| { + let links: Vec<_> = linkify::LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect(); + assert_eq!(links.len(), 1); + let raw_link = links[0].as_str().to_owned(); + let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap(); + // Let's make sure we don't call random APIs on the web + assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); + confirmation_link.set_port(Some(self.port)).unwrap(); + confirmation_link + }; + + let html = get_link(body["HtmlBody"].as_str().unwrap()); + let plain_text = get_link(body["TextBody"].as_str().unwrap()); + ConfirmationLinks { html, plain_text } + } +} + +pub async fn spawn_app() -> TestApp { + Lazy::force(&TRACING); + + let email_server = MockServer::start().await; + + // TODO(Error here in section 7.6.1.1) + // Randomise configuration to ensure test isolation + let configuration = { + let mut c = get_configuration().expect("Failed to read configuration."); + // Use a different database for each test case + c.database.database_name = Uuid::new_v4().to_string(); + // Use a random OS port + c.application.port = 0; + // Use the mock server as email API + c.email_client.base_url = email_server.uri(); + c + }; + + // Create and migrate the database + configure_database(&configuration.database).await; + + // Launch the application as a background task + let application = Application::build(configuration.clone()) + .await + .expect("Failed to build application."); + let port = application.port(); + // Get the port before spawning the application + let address = format!("http://127.0.0.1:{}", application.port()); + let _ = tokio::spawn(application.run_until_stopped()); + + TestApp { + port, + address, + db_pool: get_connection_pool(&configuration.database), + email_server, + } +} + +async fn configure_database(config: &DatabaseSettings) -> PgPool { + // Create database + let mut connection = PgConnection::connect_with(&config.without_db()) + .await + .expect("Failed to connect to Postgres"); + connection + .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) + .await + .expect("Failed to create database."); + + // Migrate database + let connection_pool = PgPool::connect_with(config.with_db()) + .await + .expect("Failed to connect to Postgres."); + sqlx::migrate!("./migrations") + .run(&connection_pool) + .await + .expect("Failed to migrate the database"); + + connection_pool +} diff --git a/tests/api/main.rs b/tests/api/main.rs new file mode 100644 index 0000000..177847a --- /dev/null +++ b/tests/api/main.rs @@ -0,0 +1,4 @@ +mod health_check; +mod helpers; +mod subscriptions; +mod subscriptions_confirm; diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs new file mode 100644 index 0000000..0e197cc --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,135 @@ +use crate::helpers::spawn_app; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[actix_rt::test] +async fn subscribe_sends_a_confirmation_email_for_valid_data() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + // Act + app.post_subscriptions(body.into()).await; + + // Assert + // Mock asserts on drop +} + +#[actix_rt::test] +async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { + // Arrange + let app = spawn_app().await; + let test_cases = vec![ + ("name=&email=ursula_le_guin%40gmail.com", "empty name"), + ("name=Ursula&email=", "empty email"), + ("name=Ursula&email=definitely-not-an-email", "invalid email"), + ]; + + for (body, description) in test_cases { + // Act + let response = app.post_subscriptions(body.into()).await; + + // Assert + assert_eq!( + 400, + response.status().as_u16(), + "The API did not return a 400 Bad Request when the payload was {}.", + description + ); + } +} + +#[actix_rt::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + // TODO(This was missing in section 7.6.3.1) + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + // Act + let response = app.post_subscriptions(body.into()).await; + + // Assert + assert_eq!(200, response.status().as_u16()); +} + +#[actix_rt::test] +async fn subscribe_persists_the_new_subscriber() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + // Act + app.post_subscriptions(body.into()).await; + + // Assert + let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",) + .fetch_one(&app.db_pool) + .await + .expect("Failed to fetch saved subscription."); + + assert_eq!(saved.email, "ursula_le_guin@gmail.com"); + assert_eq!(saved.name, "le guin"); + assert_eq!(saved.status, "pending_confirmation"); +} + +#[actix_rt::test] +async fn subscribe_returns_a_400_when_data_is_missing() { + // Arrange + let app = spawn_app().await; + let test_cases = vec![ + ("name=le%20guin", "missing the email"), + ("email=ursula_le_guin%40gmail.com", "missing the name"), + ("", "missing both name and email"), + ]; + + for (invalid_body, error_message) in test_cases { + // Act + let response = app.post_subscriptions(invalid_body.into()).await; + + // Assert + assert_eq!( + 400, + response.status().as_u16(), + // Additional customised error message on test failure + "The API did not fail with 400 Bad Request when the payload was {}.", + error_message + ); + } +} + +#[actix_rt::test] +async fn subscribe_sends_a_confirmation_email_with_a_link() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + // Act + app.post_subscriptions(body.into()).await; + + // Assert + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = app.get_confirmation_links(&email_request); + + // The two links should be identical + assert_eq!(confirmation_links.html, confirmation_links.plain_text); +} diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs new file mode 100644 index 0000000..fef68d6 --- /dev/null +++ b/tests/api/subscriptions_confirm.rs @@ -0,0 +1,41 @@ +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::helpers::spawn_app; + +#[actix_rt::test] +async fn confirmations_without_token_are_rejected_with_a_400() { + // Arrange + let app = spawn_app().await; + + // Act + let response = reqwest::get(&format!("{}/subscriptions/confirm", app.address)) + .await + .unwrap(); + + // Assert + assert_eq!(response.status().as_u16(), 400); +} + +#[actix_rt::test] +async fn the_link_returned_by_subscribe_returns_a_200_if_called() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + app.post_subscriptions(body.into()).await; + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = app.get_confirmation_links(&email_request); + + // Act + let response = reqwest::get(confirmation_links.html).await.unwrap(); + + // Assert + assert_eq!(response.status().as_u16(), 200); +} diff --git a/tests/health_check.rs b/tests/health_check.rs deleted file mode 100644 index 5bc8379..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,193 +0,0 @@ -use once_cell::sync::Lazy; -use sqlx::{Connection, Executor, PgConnection, PgPool}; -use std::net::TcpListener; -use uuid::Uuid; -use zero2prod::configuration::{get_configuration, DatabaseSettings}; -use zero2prod::startup::run; -use zero2prod::telemetry::{get_subscriber, init_subscriber}; - -// Ensure that the `tracing` stack is only initialised once using `once_cell` -static TRACING: Lazy<()> = Lazy::new(|| { - let default_filter_level = "info".to_string(); - let subscriber_name = "test".to_string(); - // We cannot assign the output of `get_subscriber` to a variable based on the value of `TEST_LOG` - // because the sink is part of the type returned by `get_subscriber`, therefore they are not the - // same type. We could work around it, but this is the most straight-forward way of moving forward. - if std::env::var("TEST_LOG").is_ok() { - let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); - init_subscriber(subscriber); - } else { - let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); - init_subscriber(subscriber); - }; -}); - -// `actix_rt::test` is the testing equivalent of `actix_web::main`. -// It also spares you from having to specify the `#[test]` attribute. -// -// Use `cargo add actix-rt --dev --vers 2` to add `actix-rt` -// under `[dev-dependencies]` in Cargo.toml -// -// You can inspect what code gets generated using -// `cargo expand --test health_check` (<- name of the test file) -#[actix_rt::test] -async fn health_check_works() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - - // Act - let response = client - // Use the returned application address - .get(&format!("{}/health_check", &app.address)) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert!(response.status().is_success()); - assert_eq!(Some(0), response.content_length()); -} - -#[actix_rt::test] -async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let test_cases = vec![ - ("name=&email=ursula_le_guin%40gmail.com", "empty name"), - ("name=Ursula&email=", "empty email"), - ("name=Ursula&email=definitely-not-an-email", "invalid email"), - ]; - - for (body, description) in test_cases { - // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!( - 400, - response.status().as_u16(), - "The API did not return a 400 Bad Request when the payload was {}.", - description - ); - } -} - -#[actix_rt::test] -async fn subscribe_returns_a_200_for_valid_form_data() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; - - // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); - - let mut connection = app - .db_pool - .acquire() - .await - .expect("Failed to connect to DB"); - - let saved = sqlx::query!("SELECT email, name FROM subscriptions",) - .fetch_one(&mut connection) - .await - .expect("Failed to fetch saved subscription."); - - assert_eq!(saved.email, "ursula_le_guin@gmail.com"); - assert_eq!(saved.name, "le guin"); - - // Assert - assert_eq!(200, response.status().as_u16()); -} - -#[actix_rt::test] -async fn subscribe_returns_a_400_when_data_is_missing() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let test_cases = vec![ - ("name=le%20guin", "missing the email"), - ("email=ursula_le_guin%40gmail.com", "missing the name"), - ("", "missing both name and email"), - ]; - - for (invalid_body, error_message) in test_cases { - // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(invalid_body) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!( - 400, - response.status().as_u16(), - // Additional customised error message on test failure - "The API did not fail with 400 Bad Request when the payload was {}.", - error_message - ); - } -} - -pub struct TestApp { - pub address: String, - pub db_pool: PgPool, -} - -// The function is asynchronous now! -async fn spawn_app() -> TestApp { - Lazy::force(&TRACING); - let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); - let port = listener.local_addr().unwrap().port(); - let address = format!("http://127.0.0.1:{}", port); - - let mut configuration = get_configuration().expect("Failed to read configuration."); - // A hack to make sure each test has a clean database - configuration.database.database_name = Uuid::new_v4().to_string(); - let connection_pool = configure_database(&configuration.database).await; - let server = run(listener, connection_pool.clone()).expect("Failed to bind address"); - let _ = tokio::spawn(server); - TestApp { - address, - db_pool: connection_pool, - } -} - -pub async fn configure_database(config: &DatabaseSettings) -> PgPool { - // Create database - let mut connection = PgConnection::connect_with(&config.without_db()) - .await - .expect("Failed to connect to Postgres"); - connection - .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) - .await - .expect("Failed to create database."); - - // Migrate database - let connection_pool = PgPool::connect_with(config.with_db()) - .await - .expect("Failed to connect to Postgres."); - sqlx::migrate!("./migrations") - .run(&connection_pool) - .await - .expect("Failed to migrate the database"); - - connection_pool -}