Compare commits
10 Commits
ef4040aa13
...
6155772be3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6155772be3 | |||
| 859f383822 | |||
| fd4ba6ae31 | |||
| f4f16d621d | |||
| ff0fb28e4b | |||
| c4817e486c | |||
| 2f4895928f | |||
| 518acf03b1 | |||
| 99b5e7fe30 | |||
| e457729f61 |
548
Cargo.lock
generated
548
Cargo.lock
generated
@ -39,8 +39,8 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"h2 0.3.24",
|
||||
"http 0.2.11",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
@ -75,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d22475596539443685426b6bdadb926ad0ecaefdfc5fb05e5e3441f15463c511"
|
||||
dependencies = [
|
||||
"bytestring",
|
||||
"http",
|
||||
"http 0.2.11",
|
||||
"regex",
|
||||
"serde",
|
||||
"tracing",
|
||||
@ -254,6 +254,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.77"
|
||||
@ -286,9 +296,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.1.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
@ -341,6 +351,16 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bollard-stubs"
|
||||
version = "1.42.0-rc.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed59b5c00048f48d7af971b71f800fdf23e858844a6f9e4d32ca72e9399e7864"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "3.4.0"
|
||||
@ -419,6 +439,25 @@ dependencies = [
|
||||
"windows-targets 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.3",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "claim"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f81099d6bb72e1df6d50bb2347224b666a670912bb7f06dbe867a4a070ab3ce8"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.14.0"
|
||||
@ -571,6 +610,59 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"deadpool-runtime",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63dfa964fe2a66f3fde91fc70b267fe193d822c7e603e2a675a49a7f46ad3f49"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.8"
|
||||
@ -604,6 +696,12 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@ -640,6 +738,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.33"
|
||||
@ -649,6 +763,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
@ -682,6 +806,16 @@ version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "fake"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c25829bde82205da46e1823b2259db6273379f626fc211f126f65654a2669be"
|
||||
dependencies = [
|
||||
"deunicode",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.0.1"
|
||||
@ -745,6 +879,21 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.30"
|
||||
@ -789,6 +938,17 @@ version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.48",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.30"
|
||||
@ -807,8 +967,10 @@ version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
@ -865,7 +1027,26 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 0.2.11",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 1.0.0",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
@ -946,6 +1127,17 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"match_cfg",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.11"
|
||||
@ -957,6 +1149,17 @@ dependencies = [
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "0.4.6"
|
||||
@ -964,7 +1167,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http 0.2.11",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41cb79eb393015dadd30fc252023adb0b2400a0caee0fa2a077e6e21a551e840"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.0.0",
|
||||
"http-body 1.0.0",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@ -990,9 +1216,9 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"h2 0.3.24",
|
||||
"http 0.2.11",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
@ -1005,16 +1231,54 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
name = "hyper"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper",
|
||||
"native-tls",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"h2 0.4.2",
|
||||
"http 1.0.0",
|
||||
"http-body 1.0.0",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.11",
|
||||
"hyper 0.14.28",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.0.0",
|
||||
"http-body 1.0.0",
|
||||
"hyper 1.2.0",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1040,6 +1304,22 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.5.0"
|
||||
@ -1125,6 +1405,31 @@ dependencies = [
|
||||
"spin 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357ff5edb6d8326473a64c82cf41ddf78ab116f89668c50c4fac1b321e5e80f4"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chumsky",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna 0.5.0",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.152"
|
||||
@ -1154,6 +1459,15 @@ version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
|
||||
[[package]]
|
||||
name = "linkify"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.13"
|
||||
@ -1179,11 +1493,10 @@ checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.11"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
|
||||
checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
@ -1193,6 +1506,12 @@ version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
|
||||
|
||||
[[package]]
|
||||
name = "match_cfg"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
@ -1380,9 +1699,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.63"
|
||||
version = "0.10.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8"
|
||||
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
|
||||
dependencies = [
|
||||
"bitflags 2.4.2",
|
||||
"cfg-if",
|
||||
@ -1412,9 +1731,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.99"
|
||||
version = "0.9.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae"
|
||||
checksum = "ae94056a791d0e1217d18b6cbdccb02c61e3054fc69893607f4067e3bb0b1fd1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@ -1613,6 +1932,37 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quickcheck"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quickcheck_macros"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b22a693222d716a9587786f37ac3f6b4faedb5b80c23914e7303ff5a1d8016e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.35"
|
||||
@ -1622,6 +1972,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79ec282e887b434b68c18fe5c121d38e72a5cf35119b59e54ec5b992ea9c8eb0"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@ -1716,30 +2072,32 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"h2 0.3.24",
|
||||
"http 0.2.11",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.28",
|
||||
"hyper-rustls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
@ -1833,6 +2191,7 @@ version = "0.21.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-webpki",
|
||||
"sct",
|
||||
@ -1979,6 +2338,28 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_with_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@ -2298,6 +2679,19 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.4"
|
||||
@ -2309,6 +2703,12 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.5.0"
|
||||
@ -2371,6 +2771,32 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "testcontainers"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d2931d7f521af5bae989f716c3fa43a6af9af7ec7a5e21b59ae40878cec00"
|
||||
dependencies = [
|
||||
"bollard-stubs",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"log",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "testcontainers-modules"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c391cd115649a8a14e5638d0606648d5348b216700a31f402987f57e58693766"
|
||||
dependencies = [
|
||||
"testcontainers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.57"
|
||||
@ -2485,12 +2911,12 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@ -2727,7 +3153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"idna 0.5.0",
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
@ -2746,6 +3172,21 @@ dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "validator"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd"
|
||||
dependencies = [
|
||||
"idna 0.4.0",
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.0"
|
||||
@ -3049,6 +3490,30 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec874e1eef0df2dcac546057fe5e29186f09c378181cd7b635b4b7bcc98e9d81"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http 1.0.0",
|
||||
"http-body-util",
|
||||
"hyper 1.2.0",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
@ -3064,19 +3529,32 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"chrono",
|
||||
"claim",
|
||||
"config",
|
||||
"fake",
|
||||
"lettre",
|
||||
"linkify",
|
||||
"once_cell",
|
||||
"quickcheck",
|
||||
"quickcheck_macros",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"testcontainers",
|
||||
"testcontainers-modules",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-actix-web",
|
||||
"tracing-bunyan-formatter",
|
||||
"tracing-log 0.2.0",
|
||||
"tracing-subscriber",
|
||||
"unicode-segmentation",
|
||||
"uuid",
|
||||
"validator",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
18
Cargo.toml
18
Cargo.toml
@ -16,7 +16,7 @@ name = "zero2prod"
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
reqwest = "0.11"
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
config = "0.14"
|
||||
uuid = { version = "1.7.0", features = ["v4"] }
|
||||
@ -28,6 +28,22 @@ tracing-log = "0.2"
|
||||
once_cell = "1"
|
||||
secrecy = { version = "0.8", features = ["serde"] }
|
||||
tracing-actix-web = "0.7"
|
||||
unicode-segmentation = "1"
|
||||
claim = "0.5"
|
||||
validator = "0.16"
|
||||
lettre = "0.11"
|
||||
rand = { version = "0.8", features = ["std_rng"] }
|
||||
|
||||
[dev-dependencies]
|
||||
fake = "2.9"
|
||||
quickcheck = "1.0"
|
||||
quickcheck_macros = "1.0"
|
||||
rand = "0.8"
|
||||
wiremock = "0.6"
|
||||
serde_json = "1"
|
||||
testcontainers = "0.15.0"
|
||||
testcontainers-modules = { version = "0.3.4", features = ["postgres"] }
|
||||
linkify = "0.10.0"
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.7.3"
|
||||
|
||||
25
Makefile.toml
Normal file
25
Makefile.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[tasks.format]
|
||||
install_crate = "rustfmt"
|
||||
command = "cargo"
|
||||
args = ["fmt", "--", "--emit=files"]
|
||||
|
||||
[tasks.lint]
|
||||
install_crate = "rust-clippy"
|
||||
command = "cargo"
|
||||
args = ["clippy"]
|
||||
|
||||
[tasks.fmtclip]
|
||||
dependencies = [
|
||||
"format",
|
||||
"lint"
|
||||
]
|
||||
|
||||
[tasks.machete]
|
||||
install_crate = "cargo-machete"
|
||||
command = "cargo"
|
||||
args = ["machete"]
|
||||
|
||||
[tasks.audit]
|
||||
install_crate = "cargo-audit"
|
||||
command = "cargo"
|
||||
args = ["audit"]
|
||||
8
README.md
Normal file
8
README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Zero2Prod
|
||||
|
||||
I'm going through the ebook "Zero to production in Rust" and this is the code for it!
|
||||
|
||||
## Makefile.toml
|
||||
|
||||
I stumbled over a [Rust tooling article](https://www.shuttle.rs/blog/2024/02/15/best-rust-tooling), therefore I installed, `cargo-make`, `cargo-audit` and other tools. To run the corresponding tool, look at the `Makefiel.toml` and run `cargo make xxx`!
|
||||
|
||||
@ -6,3 +6,8 @@ database:
|
||||
username: "postgres"
|
||||
password: "password"
|
||||
database_name: "newsletter"
|
||||
email_client:
|
||||
base_url: "localhost"
|
||||
sender_email: "test@gmail.com"
|
||||
authorization_token: "my-secret-token"
|
||||
timeout_milliseconds: 10000
|
||||
@ -1,4 +1,5 @@
|
||||
application:
|
||||
host: 127.0.0.1
|
||||
host: "127.0.0.1"
|
||||
base_url: "http://127.0.0.1"
|
||||
database:
|
||||
require_ssl: false
|
||||
|
||||
@ -2,3 +2,6 @@ application:
|
||||
host: 0.0.0.0
|
||||
database:
|
||||
require_ssl: true
|
||||
email_client:
|
||||
base_url: "https://api.postmarkapp.com"
|
||||
sender_email: "andre@futureblog.eu"
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;
|
||||
@ -0,0 +1,8 @@
|
||||
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;
|
||||
@ -0,0 +1,6 @@
|
||||
CREATE TABLE subscription_tokens(
|
||||
subscription_token TEXT NOT NULL,
|
||||
subscriber_id uuid NOT NULL
|
||||
REFERENCES subscriptions (id),
|
||||
PRIMARY KEY (subscription_token)
|
||||
);
|
||||
@ -2,19 +2,23 @@ use config::Config;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use sqlx::{postgres::{PgConnectOptions, PgSslMode}, ConnectOptions};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
use crate::domain::SubscriberEmail;
|
||||
|
||||
#[derive(serde::Deserialize,Clone)]
|
||||
pub struct Settings {
|
||||
pub database: DatabaseSettings,
|
||||
pub application: ApplicationSettings,
|
||||
pub email_client: EmailClientSettings,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize,Clone)]
|
||||
pub struct ApplicationSettings {
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize,Clone)]
|
||||
pub struct DatabaseSettings {
|
||||
pub username: String,
|
||||
pub password: Secret<String>,
|
||||
@ -45,6 +49,23 @@ impl DatabaseSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize,Clone)]
|
||||
pub struct EmailClientSettings {
|
||||
pub base_url: String,
|
||||
pub sender_email: String,
|
||||
pub authorization_token: Secret<String>,
|
||||
pub timeout_milliseconds: u64,
|
||||
}
|
||||
|
||||
impl EmailClientSettings {
|
||||
pub fn sender(&self) -> Result<SubscriberEmail, String> {
|
||||
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<Settings, config::ConfigError> {
|
||||
let run_mode = std::env::var("APP_ENVIRONMENT").unwrap_or("development".into());
|
||||
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
|
||||
|
||||
7
src/domain/mod.rs
Normal file
7
src/domain/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
mod subscriber_name;
|
||||
mod subscriber_email;
|
||||
mod new_subscriber;
|
||||
|
||||
pub use subscriber_name::SubscriberName;
|
||||
pub use subscriber_email::SubscriberEmail;
|
||||
pub use new_subscriber::NewSubscriber;
|
||||
7
src/domain/new_subscriber.rs
Normal file
7
src/domain/new_subscriber.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use crate::domain::SubscriberName;
|
||||
use crate::domain::SubscriberEmail;
|
||||
|
||||
pub struct NewSubscriber {
|
||||
pub email: SubscriberEmail,
|
||||
pub name: SubscriberName,
|
||||
}
|
||||
65
src/domain/subscriber_email.rs
Normal file
65
src/domain/subscriber_email.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use validator::validate_email;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SubscriberEmail(String);
|
||||
|
||||
impl SubscriberEmail {
|
||||
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
|
||||
if validate_email(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(format!("{} is not a valid subscriber email.", s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for SubscriberEmail {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SubscriberEmail;
|
||||
use claim::assert_err;
|
||||
use fake::faker::internet::en::SafeEmail;
|
||||
use fake::Fake;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_rejected() {
|
||||
let email = "".to_string();
|
||||
assert_err!(SubscriberEmail::parse(email));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_missing_at_symbol_is_rejected() {
|
||||
let email = "ursuladomain.com".to_string();
|
||||
assert_err!(SubscriberEmail::parse(email));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_missing_subject_is_rejected() {
|
||||
let email = "@domain.com".to_string();
|
||||
assert_err!(SubscriberEmail::parse(email));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ValidEmailFixture(pub String);
|
||||
|
||||
impl quickcheck::Arbitrary for ValidEmailFixture {
|
||||
fn arbitrary(g: &mut quickcheck::Gen) -> Self {
|
||||
let mut rng = StdRng::seed_from_u64(u64::arbitrary(g));
|
||||
let email = SafeEmail().fake_with_rng(&mut rng);
|
||||
Self(email)
|
||||
}
|
||||
}
|
||||
|
||||
#[quickcheck_macros::quickcheck]
|
||||
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
|
||||
dbg!(&valid_email.0);
|
||||
SubscriberEmail::parse(valid_email.0).is_ok()
|
||||
}
|
||||
}
|
||||
59
src/domain/subscriber_name.rs
Normal file
59
src/domain/subscriber_name.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SubscriberName(String);
|
||||
|
||||
impl SubscriberName {
|
||||
pub fn parse(s: String) -> Result<SubscriberName, String> {
|
||||
let is_empty_or_whitespace = s.trim().is_empty();
|
||||
let is_too_long = s.graphemes(true).count() > 2048;
|
||||
let forbidden_characters = [';', ':', '!', '?', '*', '(', ')', '&', '$', '@', '#', '<', '>', '[', ']', '{', '}', '/', '\\'];
|
||||
let contains_forbidden_characters = s.chars().any(|c| forbidden_characters.contains(&c));
|
||||
|
||||
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters
|
||||
{
|
||||
Err(format!("{} is not a valid subscriber name.", s))
|
||||
} else {
|
||||
Ok(Self(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for SubscriberName {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::domain::SubscriberName;
|
||||
use claim::{assert_err, assert_ok};
|
||||
|
||||
#[test]
|
||||
fn a_very_long_name_is_rejected() {
|
||||
let too_long_name = "a".repeat(2049);
|
||||
assert_err!(SubscriberName::parse(too_long_name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_forbidden_characters_is_rejected() {
|
||||
let forbidden_characters = vec![';', ':', '!', '?', '*', '(', ')', '&', '$', '@', '#', '<', '>', '[', ']', '{', '}', '/', '\\'];
|
||||
for forbidden_character in forbidden_characters {
|
||||
let name_with_forbidden_character = format!("name{}", forbidden_character);
|
||||
assert_err!(SubscriberName::parse(name_with_forbidden_character));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_allowed_characters_is_accepted() {
|
||||
let name = "name".to_string();
|
||||
assert_ok!(SubscriberName::parse(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_rejected() {
|
||||
let name = "".to_string();
|
||||
assert_err!(SubscriberName::parse(name));
|
||||
}
|
||||
}
|
||||
197
src/email_client.rs
Normal file
197
src/email_client.rs
Normal file
@ -0,0 +1,197 @@
|
||||
use reqwest::Client;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use crate::domain::SubscriberEmail;
|
||||
|
||||
pub struct EmailClient {
|
||||
http_client: reqwest::Client,
|
||||
base_url: String,
|
||||
sender: SubscriberEmail,
|
||||
authorization_token: Secret<String>,
|
||||
}
|
||||
|
||||
impl EmailClient {
|
||||
pub fn new(
|
||||
base_url: String,
|
||||
sender: SubscriberEmail,
|
||||
authorization_token: Secret<String>,
|
||||
timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
let http_client = Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.expect("Failed to build reqwest client");
|
||||
Self {
|
||||
http_client,
|
||||
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 _builder = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("X-Postmark-Server-Token", self.authorization_token.expose_secret())
|
||||
.json(&SendEmailRequest {
|
||||
from: self.sender.as_ref(),
|
||||
to: recipient.as_ref(),
|
||||
subject,
|
||||
html_body: html_content,
|
||||
text_body: text_content,
|
||||
})
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct SendEmailRequest<'a> {
|
||||
from: &'a str,
|
||||
to: &'a str,
|
||||
subject: &'a str,
|
||||
html_body: &'a str,
|
||||
text_body: &'a str,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use claim::{assert_err, assert_ok};
|
||||
use fake::{faker::{internet::en::SafeEmail, lorem::en::{Paragraph, Sentence}}, Fake, Faker};
|
||||
use secrecy::Secret;
|
||||
use wiremock::{http::Method, matchers::{any, header, header_exists, method, path}, Mock, MockServer, Request, ResponseTemplate};
|
||||
use crate::domain::SubscriberEmail;
|
||||
|
||||
use super::EmailClient;
|
||||
|
||||
fn subject() -> String {
|
||||
Sentence(1..2).fake()
|
||||
}
|
||||
|
||||
fn content() -> String {
|
||||
Paragraph(1..10).fake()
|
||||
}
|
||||
|
||||
fn email() -> SubscriberEmail {
|
||||
SubscriberEmail::parse(SafeEmail().fake()).unwrap()
|
||||
}
|
||||
|
||||
fn email_client(base_url: String) -> EmailClient {
|
||||
EmailClient::new(
|
||||
base_url,
|
||||
email(),
|
||||
Secret::new(Faker.fake()),
|
||||
std::time::Duration::from_millis(100),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_fires_a_request_to_base_url() {
|
||||
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(Method::POST))
|
||||
.and(SendEmailBodyMatcher)
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let _ = email_client
|
||||
.send_email(email(), &subject(), &content(), &content())
|
||||
.await;
|
||||
}
|
||||
|
||||
struct SendEmailBodyMatcher;
|
||||
|
||||
impl wiremock::Match for SendEmailBodyMatcher {
|
||||
fn matches(&self, request: &Request) -> bool {
|
||||
let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);
|
||||
if let Ok(body) = result {
|
||||
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 {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_succeeds_if_the_server_returns_200() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let email_client = email_client(mock_server.uri());
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let outcome = email_client
|
||||
.send_email(email(), &subject(), &content(), &content())
|
||||
.await;
|
||||
|
||||
assert_ok!(outcome);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_fails_if_the_server_returns_500() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let email_client = email_client(mock_server.uri());
|
||||
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let outcome = email_client
|
||||
.send_email(email(), &subject(), &content(), &content())
|
||||
.await;
|
||||
|
||||
assert_err!(outcome);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_times_out_if_the_server_takes_too_long() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
|
||||
let email_client = EmailClient::new(
|
||||
mock_server.uri(),
|
||||
sender,
|
||||
Secret::new(Faker.fake()),
|
||||
std::time::Duration::from_millis(100),
|
||||
);
|
||||
|
||||
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())
|
||||
.respond_with(ResponseTemplate::new(200)
|
||||
.set_delay(std::time::Duration::from_secs(180)))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let outcome = email_client
|
||||
.send_email(subscriber_email, &subject, &content, &content)
|
||||
.await;
|
||||
|
||||
assert_err!(outcome);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
pub mod configuration;
|
||||
pub mod domain;
|
||||
pub mod email_client;
|
||||
pub mod routes;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@ -1,7 +1,5 @@
|
||||
use std::net::TcpListener;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use zero2prod::configuration::get_configuration;
|
||||
use zero2prod::startup::run;
|
||||
use zero2prod::startup::Application;
|
||||
use zero2prod::telemetry::{get_subscriber, init_subscriber};
|
||||
|
||||
#[tokio::main]
|
||||
@ -10,11 +8,7 @@ async fn main() -> std::io::Result<()> {
|
||||
init_subscriber(subscriber);
|
||||
|
||||
let config = get_configuration().expect("Failed to read configuration");
|
||||
println!("Application configuration: {}", config.application.port);
|
||||
let connection_pool = PgPoolOptions::new().acquire_timeout(std::time::Duration::from_secs(2))
|
||||
.max_connections(10).connect_lazy_with(config.database.with_db());
|
||||
|
||||
let address = format!("{}:{}", config.application.host, config.application.port);
|
||||
let listener = TcpListener::bind(address).expect("Failed to bind random port");
|
||||
run(listener, connection_pool)?.await
|
||||
let app = Application::build(config).await?;
|
||||
app.run_until_stopped().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
mod health_check;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
|
||||
pub use health_check::*;
|
||||
pub use subscriptions::*;
|
||||
pub use subscriptions_confirm::*;
|
||||
|
||||
@ -1,8 +1,25 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use actix_web::{web, HttpResponse, ResponseError};
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
use serde::Deserialize;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
use sqlx::{query, Pool, Postgres};
|
||||
use sqlx::{query, Pool, Postgres, Transaction};
|
||||
use lettre::{
|
||||
address::AddressError, message::{header::ContentType, Mailbox}, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport
|
||||
};
|
||||
|
||||
use crate::{domain::{NewSubscriber, SubscriberEmail, SubscriberName}, email_client::EmailClient, startup::ApplicationBaseUrl};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StoreTokenError(sqlx::Error);
|
||||
|
||||
impl std::fmt::Display for StoreTokenError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "A database error was encountered while trying to store the subscription token.")
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for StoreTokenError {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FormData {
|
||||
@ -12,40 +29,181 @@ pub struct FormData {
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Adding a new subscriber",
|
||||
skip(form, connection_pool),
|
||||
skip(form, connection_pool, email_client, base_url),
|
||||
fields(
|
||||
subscriber_email = %form.email,
|
||||
subscriber_name = %form.name
|
||||
)
|
||||
)]
|
||||
pub async fn subscribe(form: web::Form<FormData>, connection_pool: web::Data<Pool<Postgres>>) -> HttpResponse {
|
||||
match insert_subscriber(&form, &connection_pool).await
|
||||
{
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(_) => HttpResponse::InternalServerError().finish(),
|
||||
pub async fn subscribe(
|
||||
form: web::Form<FormData>,
|
||||
connection_pool: web::Data<Pool<Postgres>>,
|
||||
email_client: web::Data<EmailClient>,
|
||||
base_url: web::Data<ApplicationBaseUrl>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let new_subscriber = match form.0.try_into() {
|
||||
Ok(subscriber) => subscriber,
|
||||
Err(e) => return Err(actix_web::error::ErrorBadRequest(e)),
|
||||
};
|
||||
let mut transaction = match connection_pool.begin().await {
|
||||
Ok(transaction) => transaction,
|
||||
Err(_) => return Err(actix_web::error::ErrorInternalServerError("Failed to acquire a database connection.")),
|
||||
};
|
||||
let subscriber_id = match insert_subscriber(&new_subscriber, &mut transaction).await {
|
||||
Ok(subscriber_id) => subscriber_id,
|
||||
Err(_) => return Err(actix_web::error::ErrorInternalServerError("Failed to save new subscriber details.")),
|
||||
};
|
||||
let subscription_token = generate_confirmation_token();
|
||||
store_token(&mut transaction, &subscriber_id, &subscription_token).await?;
|
||||
if transaction.commit().await.is_err() {
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
if send_confirmation_email(&email_client, new_subscriber, &base_url.0, &subscription_token).await.is_err() {
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Send a confirmation email to the new subscriber",
|
||||
skip(email_client, new_subscriber, base_url)
|
||||
)]
|
||||
pub async fn send_confirmation_email(
|
||||
email_client: &EmailClient,
|
||||
new_subscriber: NewSubscriber,
|
||||
base_url: &str,
|
||||
subscription_token: &str,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
let confirmation_link = format!("{}/subscriptions/confirm?subscription_token={}", base_url, subscription_token);
|
||||
let plain_body = &format!(
|
||||
"Welcome to our newsletter!\n\
|
||||
Visit {} to confirm your subscription.",
|
||||
confirmation_link
|
||||
);
|
||||
let html_body = &format!(
|
||||
"Welcome to our newsletter!<br />\
|
||||
Click <a href=\"{}\">here</a> to confirm your subscription.",
|
||||
confirmation_link
|
||||
);
|
||||
email_client
|
||||
.send_email(
|
||||
new_subscriber.email,
|
||||
"Welcome!",
|
||||
html_body,
|
||||
plain_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Sending confirmation email",
|
||||
skip(new_subscriber)
|
||||
)]
|
||||
fn send_mail(new_subscriber: &NewSubscriber) -> Result<(), String> {
|
||||
let from: Mailbox = "Andre Heber <andre@futureblog.eu>".parse().map_err(|e: AddressError| {
|
||||
tracing::error!("Could not parse email - from: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let reply_to: Mailbox = "noreply@futureblog.eu".parse().map_err(|e: AddressError| {
|
||||
tracing::error!("Could not parse email - reply_to: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let to: Mailbox = new_subscriber.email.as_ref().parse().map_err(|e: AddressError| {
|
||||
tracing::error!("Could not parse email - to: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from)
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject("Rust Email")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(String::from("Hello, this is a test email from Rust!"))
|
||||
.map_err(|e| {
|
||||
tracing::error!("Could not build email: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let creds = Credentials::new("andre@futureblog.eu".to_string(), "d6vanPc4RUeQ".to_string());
|
||||
let mailer = SmtpTransport::relay("mail.futureblog.eu")
|
||||
.map_err(|e| {
|
||||
tracing::error!("Could not connect to server: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
let mailer = mailer.credentials(creds).build();
|
||||
|
||||
mailer.send(&email)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Could not send email: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TryFrom<FormData> for NewSubscriber {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(form: FormData) -> Result<NewSubscriber, Self::Error> {
|
||||
let name = SubscriberName::parse(form.name)?;
|
||||
let email = SubscriberEmail::parse(form.email)?;
|
||||
Ok(NewSubscriber { email, name })
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Saving new subscriber details in the database",
|
||||
skip(form, connection_pool)
|
||||
skip(new_subscriber, transaction)
|
||||
)]
|
||||
async fn insert_subscriber(form: &FormData, connection_pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
|
||||
async fn insert_subscriber(new_subscriber: &NewSubscriber, transaction: &mut Transaction<'_, Postgres>) -> Result<Uuid, sqlx::Error> {
|
||||
let subscriber_id = Uuid::new_v4();
|
||||
query!(
|
||||
r#"
|
||||
INSERT INTO subscriptions (id, email, name, subscribed_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
|
||||
VALUES ($1, $2, $3, $4, 'pending_confirmation')
|
||||
"#,
|
||||
Uuid::new_v4(),
|
||||
form.email,
|
||||
form.name,
|
||||
subscriber_id,
|
||||
new_subscriber.email.as_ref(),
|
||||
new_subscriber.name.as_ref(),
|
||||
Utc::now()
|
||||
)
|
||||
.execute(connection_pool)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to execute query: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
Ok(subscriber_id)
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Storing subscription token in the database",
|
||||
skip(transaction, subscriber_id, subscription_token)
|
||||
)]
|
||||
async fn store_token(transaction: &mut Transaction<'_, Postgres>, subscriber_id: &Uuid, subscription_token: &str) -> Result<(), StoreTokenError> {
|
||||
query!(
|
||||
r#"
|
||||
INSERT INTO subscription_tokens (subscription_token, subscriber_id)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
subscription_token,
|
||||
subscriber_id
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to execute query: {:?}", e);
|
||||
StoreTokenError(e)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_confirmation_token() -> String {
|
||||
let mut rng = thread_rng();
|
||||
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
||||
.map(char::from)
|
||||
.take(25)
|
||||
.collect()
|
||||
}
|
||||
73
src/routes/subscriptions_confirm.rs
Normal file
73
src/routes/subscriptions_confirm.rs
Normal file
@ -0,0 +1,73 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::{pool::Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct Parameters {
|
||||
pub subscription_token: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Confirm a pending subscriber",
|
||||
skip(parameters),
|
||||
)]
|
||||
pub async fn confirm(
|
||||
parameters: web::Query<Parameters>,
|
||||
pool: web::Data<Pool<Postgres>>,
|
||||
) -> HttpResponse {
|
||||
let id = match get_subscriber_id_from_token(&pool, ¶meters.subscription_token).await {
|
||||
Ok(id) => id,
|
||||
Err(_) => return HttpResponse::BadRequest().finish(),
|
||||
};
|
||||
match id {
|
||||
None => HttpResponse::Unauthorized().finish(),
|
||||
Some(subscriber_id) => {
|
||||
if confirm_subscriber(&pool, subscriber_id).await.is_err() {
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Mark a subscriber as confirmed in the database",
|
||||
skip(pool, subscriber_id),
|
||||
)]
|
||||
pub async fn confirm_subscriber(
|
||||
pool: &Pool<Postgres>,
|
||||
subscriber_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"UPDATE subscriptions SET status = 'confirmed' WHERE id = $1",
|
||||
subscriber_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to execute query: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Retrieve subscriber ID by token from the database",
|
||||
skip(pool, subscription_token),
|
||||
)]
|
||||
pub async fn get_subscriber_id_from_token(
|
||||
pool: &Pool<Postgres>,
|
||||
subscription_token: &str,
|
||||
) -> Result<Option<Uuid>, sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
"SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1",
|
||||
subscription_token
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to execute query: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
Ok(result.map(|r| r.subscriber_id))
|
||||
}
|
||||
@ -4,19 +4,71 @@ use sqlx::{Pool, Postgres};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
use std::net::TcpListener;
|
||||
|
||||
use crate::routes::{health_check, subscribe};
|
||||
use crate::email_client::EmailClient;
|
||||
use crate::routes::{confirm, health_check, subscribe};
|
||||
|
||||
pub fn run(listener: TcpListener, connection_pool: Pool<Postgres>) -> Result<Server, std::io::Error> {
|
||||
pub fn run(
|
||||
listener: TcpListener,
|
||||
connection_pool: Pool<Postgres>,
|
||||
email_client: EmailClient,
|
||||
base_url: String,
|
||||
) -> Result<Server, std::io::Error> {
|
||||
let connection_pool = web::Data::new(connection_pool);
|
||||
let email_client = web::Data::new(email_client);
|
||||
let base_url = web::Data::new(ApplicationBaseUrl(base_url));
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(TracingLogger::default())
|
||||
.route("/health_check", web::get().to(health_check))
|
||||
.route("/subscriptions", web::post().to(subscribe))
|
||||
.route("/subscriptions/confirm", web::get().to(confirm))
|
||||
.app_data(connection_pool.clone())
|
||||
.app_data(email_client.clone())
|
||||
.app_data(base_url.clone())
|
||||
})
|
||||
.listen(listener)?
|
||||
.run();
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
pub struct Application {
|
||||
pub port: u16,
|
||||
pub server: Server,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
pub async fn build(
|
||||
config: crate::configuration::Settings,
|
||||
) -> Result<Self, std::io::Error> {
|
||||
let connection_pool: Pool<Postgres> = get_connection_pool(config.database);
|
||||
|
||||
let sender_email = config.email_client.sender().unwrap();
|
||||
let timeout = config.email_client.timeout();
|
||||
let email_client = EmailClient::new(
|
||||
config.email_client.base_url,
|
||||
sender_email,
|
||||
config.email_client.authorization_token,
|
||||
timeout,
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind(format!("{}:{}", config.application.host, config.application.port))?;
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = run(listener, connection_pool, email_client, config.application.base_url)?;
|
||||
Ok(Self { port, server })
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
|
||||
self.server.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApplicationBaseUrl(pub String);
|
||||
|
||||
pub fn get_connection_pool(config: crate::configuration::DatabaseSettings) -> Pool<Postgres> {
|
||||
Pool::connect_lazy_with(config.with_db())
|
||||
}
|
||||
16
tests/api/health_check.rs
Normal file
16
tests/api/health_check.rs
Normal file
@ -0,0 +1,16 @@
|
||||
use crate::helpers::spawn_app;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_works() {
|
||||
let app = spawn_app().await;
|
||||
let health_check_endpoint = format!("{}/health_check", app.address);
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(health_check_endpoint)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(Some(0), response.content_length());
|
||||
}
|
||||
112
tests/api/helpers.rs
Normal file
112
tests/api/helpers.rs
Normal file
@ -0,0 +1,112 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use wiremock::MockServer;
|
||||
use zero2prod::{configuration::{get_configuration, DatabaseSettings}, startup::{get_connection_pool, Application}, telemetry::{get_subscriber, init_subscriber}};
|
||||
|
||||
pub struct ConfirmationLinks {
|
||||
pub html: String,
|
||||
pub plain_text: String,
|
||||
}
|
||||
|
||||
pub struct TestApp {
|
||||
pub address: String,
|
||||
pub connection_pool: Pool<Postgres>,
|
||||
pub email_server: MockServer,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
|
||||
println!("Post Address: {}", &self.address);
|
||||
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();
|
||||
|
||||
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);
|
||||
links[0].as_str().to_owned()
|
||||
};
|
||||
|
||||
let html = get_link(body["HtmlBody"].as_str().unwrap());
|
||||
let plain_text = get_link(body["TextBody"].as_str().unwrap());
|
||||
|
||||
ConfirmationLinks { html, plain_text }
|
||||
}
|
||||
}
|
||||
|
||||
static TRACING: Lazy<()> = Lazy::new(|| {
|
||||
let default_filter_level = "info".to_string();
|
||||
let subscriber_name = "test".to_string();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
pub async fn spawn_app() -> TestApp {
|
||||
Lazy::force(&TRACING);
|
||||
let email_server = MockServer::start().await;
|
||||
|
||||
let config = {
|
||||
let mut c = get_configuration().expect("Failed to read configuration");
|
||||
c.database.database_name = Uuid::new_v4().to_string();
|
||||
c.application.port = 0;
|
||||
c.email_client.base_url = email_server.uri();
|
||||
c
|
||||
};
|
||||
|
||||
configure_database(&config.database).await;
|
||||
|
||||
let app = Application::build(config.clone()).await.expect("Failed to build app.");
|
||||
let port = app.port();
|
||||
let address = format!("http://127.0.0.1:{}", app.port());
|
||||
println!("App Address: {}", address);
|
||||
let _fut = tokio::spawn(app.run_until_stopped());
|
||||
|
||||
TestApp {
|
||||
address,
|
||||
connection_pool: get_connection_pool(config.database),
|
||||
email_server,
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn configure_database(config: &DatabaseSettings) -> Pool<Postgres> {
|
||||
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.");
|
||||
|
||||
let connection_pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect_with(config.with_db())
|
||||
.await
|
||||
.expect("Failed to connect to Postgres.");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&connection_pool)
|
||||
.await
|
||||
.expect("Failed to run migrations.");
|
||||
|
||||
connection_pool
|
||||
}
|
||||
4
tests/api/main.rs
Normal file
4
tests/api/main.rs
Normal file
@ -0,0 +1,4 @@
|
||||
mod helpers;
|
||||
mod health_check;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
152
tests/api/subscriptions.rs
Normal file
152
tests/api/subscriptions.rs
Normal file
@ -0,0 +1,152 @@
|
||||
use crate::helpers::spawn_app;
|
||||
use sqlx::query;
|
||||
use wiremock::{matchers::{path, method}, Mock, ResponseTemplate, http::Method};
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_returns_a_200_for_valid_form_data() {
|
||||
let app = spawn_app().await;
|
||||
Mock::given(path("/email"))
|
||||
.and(method(Method::POST))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
let response = app.post_subscriptions("name=andre&email=andre.heber@gmx.net".to_string()).await;
|
||||
|
||||
assert_eq!(200, response.status().as_u16());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_persists_the_new_subscriber() {
|
||||
let app = spawn_app().await;
|
||||
Mock::given(path("/email"))
|
||||
.and(method(Method::POST))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
let _response = app.post_subscriptions("name=andre&email=andre.heber@gmx.net".to_string()).await;
|
||||
|
||||
let saved = query!("SELECT email, name, status FROM subscriptions",)
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.expect("Failed to fetch saved subscription.");
|
||||
|
||||
assert_eq!(saved.email, "andre.heber@gmx.net");
|
||||
assert_eq!(saved.name, "andre");
|
||||
assert_eq!(saved.status, "pending_confirmation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_returns_a_400_when_data_is_missing() {
|
||||
let test_cases = vec![
|
||||
("name=le%20guin", "missing the email"),
|
||||
("email=ursula_le_guin%40gmail.com", "missing the name"),
|
||||
("", "missing both name and email"),
|
||||
];
|
||||
|
||||
let app = spawn_app().await;
|
||||
for (invalid_body, error_message) in test_cases {
|
||||
let response = app.post_subscriptions(invalid_body.to_string()).await;
|
||||
|
||||
assert_eq!(
|
||||
400,
|
||||
response.status().as_u16(),
|
||||
"The API did not fail with 400 Bad Request when the payload was {}.",
|
||||
error_message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
|
||||
let test_cases = vec![
|
||||
("name=&email=ursula_le_guin%40gmail.com", "name is empty"),
|
||||
("name=le%20guin&email=", "email is empty"),
|
||||
("name=&email=", "name and email are empty"),
|
||||
("name=Ursula&email=definitely-not-an-email", "invalid email"),
|
||||
];
|
||||
|
||||
let app = spawn_app().await;
|
||||
for (invalid_body, error_message) in test_cases {
|
||||
let response = app.post_subscriptions(invalid_body.to_string()).await;
|
||||
|
||||
assert_eq!(
|
||||
400,
|
||||
response.status().as_u16(),
|
||||
"The API did not return a 400 Bad Request when the payload was {}.",
|
||||
error_message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_sends_a_confirmation_email_for_valid_data() {
|
||||
let app = spawn_app().await;
|
||||
|
||||
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
|
||||
|
||||
Mock::given(path("/email"))
|
||||
.and(method(Method::POST))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
|
||||
let _response = app.post_subscriptions(body.to_string()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_sends_a_confirmation_email_with_a_link() {
|
||||
let app = spawn_app().await;
|
||||
|
||||
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
|
||||
|
||||
Mock::given(path("/email"))
|
||||
.and(method(Method::POST))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
|
||||
let response = app.post_subscriptions(body.to_string()).await;
|
||||
|
||||
assert_eq!(200, response.status().as_u16());
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_fails_if_there_is_a_fatal_database_error() {
|
||||
let app = spawn_app().await;
|
||||
let body = "name=Andre%20Heber&email=andre.heber%40gmx.net";
|
||||
|
||||
// Mock::given(path("/email"))
|
||||
// .and(method(Method::POST))
|
||||
// .respond_with(ResponseTemplate::new(200))
|
||||
// .expect(1)
|
||||
// .mount(&app.email_server)
|
||||
// .await;
|
||||
|
||||
// // Let's cause a database error
|
||||
// let (subscribers, pool) = app.get_subscribers().await;
|
||||
sqlx::query!("ALTER TABLE subscription_tokens DROP COLUMN subscription_token;",)
|
||||
.execute(&app.connection_pool)
|
||||
.await
|
||||
.expect("Failed to drop the subscriptions table");
|
||||
|
||||
let response = app.post_subscriptions(body.to_string()).await;
|
||||
assert_eq!(response.status().as_u16(), 500);
|
||||
|
||||
// Bring the table back for other tests
|
||||
// sqlx::query(include_str!("../../../migrations/redo_subscriptions_table.sql"))
|
||||
// .execute(&pool)
|
||||
// .await
|
||||
// .expect("Failed to re-create the subscriptions table");
|
||||
|
||||
// drop(subscribers);
|
||||
}
|
||||
46
tests/api/subscriptions_confirm.rs
Normal file
46
tests/api/subscriptions_confirm.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use reqwest::Url;
|
||||
use wiremock::{matchers::{method, path}, Mock, ResponseTemplate};
|
||||
|
||||
use crate::helpers::spawn_app;
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirmations_without_token_are_rejected_with_a_400() {
|
||||
let app = spawn_app().await;
|
||||
let response = reqwest::get(&format!("{}/subscriptions/confirm", app.address)).await.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
|
||||
let app = spawn_app().await;
|
||||
|
||||
Mock::given(path("/email"))
|
||||
.and(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
|
||||
app.post_subscriptions("name=Andre%20Heber&email=andre.heber%40gmx.net".into()).await;
|
||||
let email_request = &app.email_server.received_requests().await.unwrap()[0];
|
||||
let confirmation_links = app.get_confirmation_links(email_request);
|
||||
|
||||
let mut confirmation_link = Url::parse(&confirmation_links.html).unwrap();
|
||||
|
||||
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
|
||||
confirmation_link.set_port(Some(app.port)).unwrap();
|
||||
|
||||
let _response = reqwest::get(confirmation_link)
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions")
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.expect("Failed to fetch saved subscription");
|
||||
|
||||
assert_eq!(saved.email, "andre.heber@gmx.net");
|
||||
assert_eq!(saved.name, "Andre Heber");
|
||||
assert_eq!(saved.status, "confirmed");
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use sqlx::{postgres::PgPoolOptions, query, Connection, Executor, PgConnection, Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use std:: net::TcpListener;
|
||||
use zero2prod::{configuration::{get_configuration, DatabaseSettings}, telemetry::{get_subscriber, init_subscriber}};
|
||||
|
||||
pub struct TestApp {
|
||||
pub address: String,
|
||||
pub connection_pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
static TRACING: Lazy<()> = Lazy::new(|| {
|
||||
let default_filter_level = "info".to_string();
|
||||
let subscriber_name = "test".to_string();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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 config = get_configuration().expect("Failed to read configuration");
|
||||
config.database.database_name = Uuid::new_v4().to_string();
|
||||
let connection_pool = configure_database(&config.database).await;
|
||||
|
||||
let server = zero2prod::startup::run(listener, connection_pool.clone()).expect("Failed to bind address");
|
||||
let _ = tokio::spawn(server);
|
||||
|
||||
TestApp {
|
||||
address,
|
||||
connection_pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn configure_database(config: &DatabaseSettings) -> Pool<Postgres> {
|
||||
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.");
|
||||
|
||||
let connection_pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect_with(config.with_db())
|
||||
.await
|
||||
.expect("Failed to connect to Postgres.");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&connection_pool)
|
||||
.await
|
||||
.expect("Failed to run migrations.");
|
||||
|
||||
connection_pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_works() {
|
||||
let app = spawn_app().await;
|
||||
let health_check_endpoint = format!("{}/health_check", app.address);
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(health_check_endpoint)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(Some(0), response.content_length());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_returns_a_200_for_valid_form_data() {
|
||||
let app = spawn_app().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
|
||||
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_eq!(200, response.status().as_u16());
|
||||
|
||||
let saved = query!("SELECT email, name FROM subscriptions",)
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.expect("Failed to fetch saved subscription.");
|
||||
|
||||
assert_eq!(saved.email, "ursula_le_guin@gmail.com");
|
||||
assert_eq!(saved.name, "le guin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_returns_a_400_when_data_is_missing() {
|
||||
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 {
|
||||
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_eq!(
|
||||
400,
|
||||
response.status().as_u16(),
|
||||
"The API did not fail with 400 Bad Request when the payload was {}.",
|
||||
error_message
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user