diff --git a/Cargo.lock b/Cargo.lock index 045e822..f672690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,9 +286,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" @@ -419,6 +419,15 @@ dependencies = [ "windows-targets 0.52.0", ] +[[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" @@ -604,6 +613,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" @@ -649,6 +664,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 +707,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" @@ -721,21 +756,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1005,16 +1025,17 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper-rustls" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ - "bytes", + "futures-util", + "http", "hyper", - "native-tls", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", ] [[package]] @@ -1040,6 +1061,16 @@ dependencies = [ "cc", ] +[[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" @@ -1179,11 +1210,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", ] @@ -1257,24 +1287,6 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d02c0b00610773bb7fc61d85e13d86c7858cbdf00e1a120bfc41bc055dbaa0e" -[[package]] -name = "native-tls" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" -dependencies = [ - "lazy_static", - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.27.1" @@ -1378,50 +1390,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "openssl" -version = "0.10.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" -dependencies = [ - "bitflags 2.4.2", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "openssl-sys" -version = "0.9.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-multimap" version = "0.6.0" @@ -1613,6 +1581,28 @@ dependencies = [ "unicode-ident", ] +[[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" @@ -1720,26 +1710,28 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-tls", + "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 +1825,7 @@ version = "0.21.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" dependencies = [ + "log", "ring", "rustls-webpki", "sct", @@ -1863,15 +1856,6 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" -[[package]] -name = "schannel" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1898,29 +1882,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.21" @@ -2485,12 +2446,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 +2688,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 +2707,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" @@ -3064,8 +3040,13 @@ version = "0.1.0" dependencies = [ "actix-web", "chrono", + "claim", "config", + "fake", "once_cell", + "quickcheck", + "quickcheck_macros", + "rand", "reqwest", "secrecy", "serde", @@ -3076,7 +3057,9 @@ dependencies = [ "tracing-bunyan-formatter", "tracing-log 0.2.0", "tracing-subscriber", + "unicode-segmentation", "uuid", + "validator", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8da988d..5f55881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,15 @@ 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" + +[dev-dependencies] +fake = "2.9" +quickcheck = "1.0" +quickcheck_macros = "1.0" +rand = "0.8" [dependencies.sqlx] version = "0.7.3" diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..67ebb73 --- /dev/null +++ b/src/domain/mod.rs @@ -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; diff --git a/src/domain/new_subscriber.rs b/src/domain/new_subscriber.rs new file mode 100644 index 0000000..dceb0b9 --- /dev/null +++ b/src/domain/new_subscriber.rs @@ -0,0 +1,7 @@ +use crate::domain::SubscriberName; +use crate::domain::SubscriberEmail; + +pub struct NewSubscriber { + pub email: SubscriberEmail, + pub name: SubscriberName, +} diff --git a/src/domain/subscriber_email.rs b/src/domain/subscriber_email.rs new file mode 100644 index 0000000..4b72709 --- /dev/null +++ b/src/domain/subscriber_email.rs @@ -0,0 +1,65 @@ +use validator::validate_email; + +#[derive(Debug)] +pub struct SubscriberEmail(String); + +impl SubscriberEmail { + pub fn parse(s: String) -> Result { + if validate_email(&s) { + Ok(Self(s)) + } else { + Err(format!("{} is not a valid subscriber email.", s)) + } + } +} + +impl AsRef 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() + } +} diff --git a/src/domain/subscriber_name.rs b/src/domain/subscriber_name.rs new file mode 100644 index 0000000..c1e8729 --- /dev/null +++ b/src/domain/subscriber_name.rs @@ -0,0 +1,59 @@ +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug)] +pub struct SubscriberName(String); + +impl SubscriberName { + pub fn parse(s: String) -> Result { + 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 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)); + } +} diff --git a/src/email_client.rs b/src/email_client.rs new file mode 100644 index 0000000..a3c3a00 --- /dev/null +++ b/src/email_client.rs @@ -0,0 +1,17 @@ +use crate::domain::SubscriberEmail; + +pub struct EmailClient { + sender: SubscriberEmail, +} + +impl EmailClient { + pub async fn send_email( + &self, + recipient: SubscriberEmail, + subject: &str, + html_content: &str, + text_content: &str, + ) -> Result<(), String> { + todo!("send_email not implemented") + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 9079ef9..66e386a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod configuration; +pub mod domain; +pub mod email_client; pub mod routes; pub mod startup; pub mod telemetry; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 4b27f8f..5eefda4 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -4,6 +4,8 @@ use chrono::Utc; use uuid::Uuid; use sqlx::{query, Pool, Postgres}; +use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; + #[derive(Deserialize)] pub struct FormData { pub email: String, @@ -19,26 +21,39 @@ pub struct FormData { ) )] pub async fn subscribe(form: web::Form, connection_pool: web::Data>) -> HttpResponse { - match insert_subscriber(&form, &connection_pool).await - { + let new_subscriber = match form.0.try_into() { + Ok(subscriber) => subscriber, + Err(_) => return HttpResponse::BadRequest().finish(), + }; + match insert_subscriber(&new_subscriber, &connection_pool).await { Ok(_) => HttpResponse::Ok().finish(), Err(_) => HttpResponse::InternalServerError().finish(), } } +impl TryFrom for NewSubscriber { + type Error = String; + + fn try_from(form: FormData) -> Result { + 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, connection_pool) )] -async fn insert_subscriber(form: &FormData, connection_pool: &Pool) -> Result<(), sqlx::Error> { +async fn insert_subscriber(new_subscriber: &NewSubscriber, connection_pool: &Pool) -> Result<(), sqlx::Error> { query!( r#" INSERT INTO subscriptions (id, email, name, subscribed_at) VALUES ($1, $2, $3, $4) "#, Uuid::new_v4(), - form.email, - form.name, + new_subscriber.email.as_ref(), + new_subscriber.name.as_ref(), Utc::now() ) .execute(connection_pool) diff --git a/tests/health_check.rs b/tests/health_check.rs index 5cee432..4f5b01a 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -132,3 +132,32 @@ async fn subscribe_returns_a_400_when_data_is_missing() { ); } } + +#[tokio::test] +async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { + let app = spawn_app().await; + let client = reqwest::Client::new(); + 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"), + ]; + + 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 return a 400 Bad Request when the payload was {}.", + error_message + ); + } +}