From a68c95d51ba7b116b546eb35823fdddf61a3d9c8 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Mon, 25 Mar 2024 18:06:26 +0200 Subject: [PATCH] finished chapter-6 --- Cargo.lock | 159 +++++++++++++++++++++++++++++---- Cargo.toml | 4 + src/domain.rs | 74 --------------- src/domain/mod.rs | 7 ++ src/domain/new_subscriber.rs | 7 ++ src/domain/subscriber_email.rs | 67 ++++++++++++++ src/domain/subscriber_name.rs | 73 +++++++++++++++ src/routes/subscibtions.rs | 31 +++---- 8 files changed, 315 insertions(+), 107 deletions(-) delete mode 100644 src/domain.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/new_subscriber.rs create mode 100644 src/domain/subscriber_email.rs create mode 100644 src/domain/subscriber_name.rs diff --git a/Cargo.lock b/Cargo.lock index 09acb52..4c861d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.2.12", "once_cell", "version_check", "zerocopy", @@ -386,6 +386,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -419,6 +429,15 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "fake" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6479fa2c7e83ddf8be7d435421e093b072ca891b99a49bc84eba098f4044f818" +dependencies = [ + "rand 0.7.3", +] + [[package]] name = "fastrand" version = "2.0.2" @@ -564,6 +583,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.12" @@ -572,7 +602,7 @@ checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -793,6 +823,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" @@ -956,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -1010,7 +1050,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1256,6 +1296,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quickcheck" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44883e74aa97ad63db83c4bf8ca490f02b2fc02f92575e720c8551e843c945f" +dependencies = [ + "env_logger", + "log", + "rand 0.7.3", + "rand_core 0.5.1", +] + +[[package]] +name = "quickcheck_macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608c156fd8e97febc07dc9c2e2c80bf74cfc6ef26893eae3daf8bc2bc94a4b7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quote" version = "1.0.35" @@ -1265,6 +1328,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.8.5" @@ -1272,8 +1348,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -1283,7 +1369,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", ] [[package]] @@ -1292,7 +1387,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.12", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -1398,7 +1502,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.12", "libc", "spin 0.9.8", "untrusted", @@ -1418,7 +1522,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -1664,7 +1768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1854,7 +1958,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -1895,7 +1999,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -2380,7 +2484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -2396,10 +2500,25 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" dependencies = [ - "getrandom", + "getrandom 0.2.12", "serde", ] +[[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" @@ -2427,6 +2546,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2721,7 +2846,10 @@ dependencies = [ "chrono", "claims", "config", + "fake", "once_cell", + "quickcheck", + "quickcheck_macros", "reqwest", "secrecy", "serde", @@ -2735,6 +2863,7 @@ dependencies = [ "tracing-subscriber", "unicode-segmentation", "uuid", + "validator", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9547c49..0ea349e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,14 @@ secrecy = { version = "0.8", features = ["serde"] } serde-aux = "4" unicode-segmentation = "1" claims = "0.7" +validator = "0.16" [dev-dependencies] reqwest = "0.12" once_cell = "1.19" +fake = "~2.3" +quickcheck = "0.9" +quickcheck_macros = "0.9" [package.metadata.clippy] warn = [ diff --git a/src/domain.rs b/src/domain.rs deleted file mode 100644 index 221e8c6..0000000 --- a/src/domain.rs +++ /dev/null @@ -1,74 +0,0 @@ -use unicode_segmentation::UnicodeSegmentation; - -#[derive(Debug)] -pub struct NewSubscriber { - pub email: String, - pub name: SubscriberName, -} - -#[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() > 256; - 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 { - return Err(format!("{} is not a valid subscriber name.", s)); - } - Ok(Self(s)) - } -} - -impl AsRef for SubscriberName { - fn as_ref(&self) -> &str { - &self.0 - } -} - -#[cfg(test)] -mod tests { - - use claims::{assert_err, assert_ok}; - - use super::*; - #[test] - fn a_256_grapheme_long_name_is_valid() { - let name = "ē".repeat(256); - assert_ok!(SubscriberName::parse(name)); - } - - #[test] - fn a_name_longer_than_256_graphemes_is_rejected() { - let name = "a".repeat(257); - assert_err!(SubscriberName::parse(name)); - } - - #[test] - fn whitespace_only_names_are_rejected() { - let name = " ".to_string(); - assert_err!(SubscriberName::parse(name)); - } - - #[test] - fn empty_string_is_rejected() { - let name = "".to_string(); - assert_err!(SubscriberName::parse(name)); - } - - #[test] - fn names_containing_an_invalid_character_are_rejected() { - for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] { - let name = name.to_string(); - assert_err!(SubscriberName::parse(name)); - } - } - - #[test] - fn a_valid_name_is_parsed_successfully() { - let name = "Ursula Le Guin".to_string(); - assert_ok!(SubscriberName::parse(name)); - } -} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..c16d16f --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,7 @@ +pub mod new_subscriber; +pub mod subscriber_email; +pub mod subscriber_name; + +pub use new_subscriber::NewSubscriber; +pub use subscriber_email::SubscriberEmail; +pub use subscriber_name::SubscriberName; diff --git a/src/domain/new_subscriber.rs b/src/domain/new_subscriber.rs new file mode 100644 index 0000000..90a977e --- /dev/null +++ b/src/domain/new_subscriber.rs @@ -0,0 +1,7 @@ +use super::{subscriber_name::SubscriberName, SubscriberEmail}; + +#[derive(Debug)] +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..d99bcfd --- /dev/null +++ b/src/domain/subscriber_email.rs @@ -0,0 +1,67 @@ +use std::str::FromStr; + +use validator::validate_email; + +#[derive(Debug)] +pub struct SubscriberEmail(String); + +impl TryFrom for SubscriberEmail { + type Error = String; + fn try_from(value: String) -> Result { + if !validate_email(&value) { + return Err(format!("{} is not a valid subscriber email.", value)); + } + Ok(Self(value)) + } +} + +impl FromStr for SubscriberEmail { + type Err = String; + fn from_str(s: &str) -> Result { + Self::try_from(s.to_owned()) + } +} + +impl AsRef for SubscriberEmail { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::assert_err; + use fake::{faker::internet::en::SafeEmail, Fake}; + use quickcheck::Arbitrary; + + #[derive(Debug, Clone)] + struct ValidEmailFixture(pub String); + + impl Arbitrary for ValidEmailFixture { + fn arbitrary(g: &mut G) -> Self { + let email = SafeEmail().fake_with_rng(g); + Self(email) + } + } + + #[test] + fn empty_string_is_rejected() { + assert_err!(SubscriberEmail::from_str("")); + } + + #[test] + fn email_missing_at_symbol_is_rejected() { + assert_err!(SubscriberEmail::from_str("ursuladomain.com")); + } + + #[test] + fn email_missing_subject_is_rejected() { + assert_err!(SubscriberEmail::from_str("@domain.com")); + } + + #[quickcheck_macros::quickcheck] + fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool { + SubscriberEmail::try_from(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..ab9b014 --- /dev/null +++ b/src/domain/subscriber_name.rs @@ -0,0 +1,73 @@ +use std::str::FromStr; + +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug)] +pub struct SubscriberName(String); + +impl TryFrom for SubscriberName { + type Error = String; + fn try_from(value: String) -> Result { + let is_empty_or_whitespace = value.trim().is_empty(); + let is_too_long = value.graphemes(true).count() > 256; + let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; + let contains_forbidden_characters = + value.chars().any(|c| forbidden_characters.contains(&c)); + if is_empty_or_whitespace || is_too_long || contains_forbidden_characters { + return Err(format!("{} is not a valid subscriber name.", value)); + } + Ok(Self(value)) + } +} + +impl FromStr for SubscriberName { + type Err = String; + fn from_str(s: &str) -> Result { + Self::try_from(s.to_owned()) + } +} + +impl AsRef for SubscriberName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + + use claims::{assert_err, assert_ok}; + + use super::*; + #[test] + fn a_256_grapheme_long_name_is_valid() { + assert_ok!(SubscriberName::try_from("ē".repeat(256))); + } + + #[test] + fn a_name_longer_than_256_graphemes_is_rejected() { + assert_err!(SubscriberName::try_from("a".repeat(257))); + } + + #[test] + fn whitespace_only_names_are_rejected() { + assert_err!(SubscriberName::from_str(" ")); + } + + #[test] + fn empty_string_is_rejected() { + assert_err!(SubscriberName::from_str("")); + } + + #[test] + fn names_containing_an_invalid_character_are_rejected() { + for ch in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] { + assert_err!(SubscriberName::try_from(ch.to_string())); + } + } + + #[test] + fn a_valid_name_is_try_fromd_successfully() { + assert_ok!(SubscriberName::from_str("Ursula Le Guin")); + } +} diff --git a/src/routes/subscibtions.rs b/src/routes/subscibtions.rs index ad63037..2e17737 100644 --- a/src/routes/subscibtions.rs +++ b/src/routes/subscibtions.rs @@ -3,10 +3,9 @@ use chrono::Utc; use serde::Deserialize; use sqlx::PgPool; use tracing::error; -use unicode_segmentation::UnicodeSegmentation; use uuid::Uuid; -use crate::domain::{NewSubscriber, SubscriberName}; +use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; #[derive(Deserialize)] pub struct FormData { @@ -26,14 +25,11 @@ pub async fn subscribe( State(pool): State, Form(form): Form, ) -> impl IntoResponse { - if !is_valid_name(&form.name) { - return StatusCode::BAD_REQUEST; - } - - let new_subscriber = NewSubscriber { - email: form.email, - name: SubscriberName::parse(form.name).expect("Name validation failed."), + let new_subscriber = match form.try_into() { + Ok(subscriber) => subscriber, + Err(_) => return StatusCode::BAD_REQUEST, }; + match insert_subscriber(&pool, &new_subscriber).await { Ok(_) => StatusCode::OK, Err(_) => StatusCode::INTERNAL_SERVER_ERROR, @@ -54,7 +50,7 @@ pub async fn insert_subscriber( VALUES ($1, $2, $3, $4) "#, Uuid::new_v4(), - new_subscriber.email, + new_subscriber.email.as_ref(), new_subscriber.name.as_ref(), Utc::now() ) @@ -67,12 +63,11 @@ pub async fn insert_subscriber( Ok(()) } -/// Returns `true` if the input satisfies all validation constraints -/// on subscriber names, `false` otherwise. -pub fn is_valid_name(s: &str) -> bool { - let is_empty_or_whitespace = s.trim().is_empty(); - let is_too_long = s.graphemes(true).count() > 256; - let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; - let contains_forbidden_characters = s.chars().any(|c| forbidden_characters.contains(&c)); - !(is_empty_or_whitespace || is_too_long || contains_forbidden_characters) +impl TryFrom for NewSubscriber { + type Error = String; + fn try_from(value: FormData) -> Result { + let name = value.name.try_into()?; + let email = value.email.try_into()?; + Ok(NewSubscriber { email, name }) + } }