Compare commits

...

10 Commits

28 changed files with 1586 additions and 202 deletions

548
Cargo.lock generated
View File

@ -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]]

View File

@ -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
View 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
View 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`!

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -0,0 +1 @@
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;

View File

@ -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;

View File

@ -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)
);

View File

@ -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
View 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;

View File

@ -0,0 +1,7 @@
use crate::domain::SubscriberName;
use crate::domain::SubscriberEmail;
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}

View 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()
}
}

View 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
View 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);
}
}

View File

@ -1,4 +1,6 @@
pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod startup;
pub mod telemetry;

View File

@ -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(())
}

View File

@ -1,5 +1,7 @@
mod health_check;
mod subscriptions;
mod subscriptions_confirm;
pub use health_check::*;
pub use subscriptions::*;
pub use subscriptions_confirm::*;

View File

@ -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()
}

View 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, &parameters.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))
}

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,4 @@
mod helpers;
mod health_check;
mod subscriptions;
mod subscriptions_confirm;

152
tests/api/subscriptions.rs Normal file
View 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);
}

View 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");
}

View File

@ -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
);
}
}