From 2f6ce9c53d6acb4f08bd75ad9c7be4486c266e20 Mon Sep 17 00:00:00 2001 From: Yago Iglesias Date: Wed, 6 Sep 2023 20:35:17 +0200 Subject: [PATCH] feat: Email syntax validation --- Cargo.lock | 143 +++++++++++++++++-- Cargo.toml | 16 ++- src/domain/mod.rs | 7 + src/domain/new_subscriber.rs | 8 ++ src/domain/subscriber_email.rs | 60 ++++++++ src/{domain.rs => domain/subscriber_name.rs} | 7 +- src/routes/subscriptions.rs | 22 +-- 7 files changed, 234 insertions(+), 29 deletions(-) create mode 100644 src/domain/mod.rs create mode 100644 src/domain/new_subscriber.rs create mode 100644 src/domain/subscriber_email.rs rename src/{domain.rs => domain/subscriber_name.rs} (94%) diff --git a/Cargo.lock b/Cargo.lock index d84faca..0b9f265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.8.5", "sha1", "smallvec", "tokio", @@ -202,7 +202,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" dependencies = [ - "getrandom", + "getrandom 0.2.10", "once_cell", "version_check", ] @@ -214,7 +214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.2.10", "once_cell", "version_check", ] @@ -606,6 +606,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 = "errno" version = "0.3.3" @@ -633,6 +643,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.0" @@ -751,6 +770,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.10" @@ -759,7 +789,7 @@ checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -1129,7 +1159,7 @@ checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -1374,6 +1404,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.33" @@ -1383,6 +1436,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" @@ -1390,8 +1456,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]] @@ -1401,7 +1477,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]] @@ -1410,7 +1495,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.10", +] + +[[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]] @@ -1437,7 +1531,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ - "getrandom", + "getrandom 0.2.10", "redox_syscall 0.2.16", "thiserror", ] @@ -1849,7 +1943,7 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rand", + "rand 0.8.5", "rustls", "rustls-pemfile", "serde", @@ -2268,7 +2362,22 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ - "getrandom", + "getrandom 0.2.10", +] + +[[package]] +name = "validator" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +dependencies = [ + "idna", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", ] [[package]] @@ -2298,6 +2407,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" @@ -2533,7 +2648,10 @@ dependencies = [ "chrono", "claim", "config", + "fake", "once_cell", + "quickcheck", + "quickcheck_macros", "reqwest", "secrecy", "serde", @@ -2547,6 +2665,7 @@ dependencies = [ "tracing-subscriber", "unicode-segmentation", "uuid", + "validator", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9232f21..2fef215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,15 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } serde = "1.0.115" config = { version = "0.13", default-features = false, features = ["yaml"] } uuid = { version = "1", features = ["v4"] } -sqlx = { version = "0.6", default-features = false, features = ["runtime-actix-rustls", "macros", "postgres", "uuid", "chrono", "migrate", "offline"] } +sqlx = { version = "0.6", default-features = false, features = [ + "runtime-actix-rustls", + "macros", + "postgres", + "uuid", + "chrono", + "migrate", + "offline", +] } chrono = { version = "0.4.22", default-features = false, features = ["clock"] } tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } @@ -30,8 +38,10 @@ tracing-actix-web = "0.7" serde-aux = "4" unicode-segmentation = "1" claim = "0.5.0" +validator = "0.16.1" [dev-dependencies] reqwest = { version = "0.11", features = ["json"] } - - +fake = "~2.3" +quickcheck = "0.9.2" +quickcheck_macros = "0.9.1" 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..d5f3e19 --- /dev/null +++ b/src/domain/new_subscriber.rs @@ -0,0 +1,8 @@ +use crate::domain::subscriber_name::SubscriberName; + +use super::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..549ff67 --- /dev/null +++ b/src/domain/subscriber_email.rs @@ -0,0 +1,60 @@ +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, Fake}; + + #[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 G) -> Self { + let email = SafeEmail().fake_with_rng(g); + Self(email) + } + } + + #[quickcheck_macros::quickcheck] + fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool { + SubscriberEmail::parse(valid_email.0).is_ok() + } +} diff --git a/src/domain.rs b/src/domain/subscriber_name.rs similarity index 94% rename from src/domain.rs rename to src/domain/subscriber_name.rs index 402bf33..0968625 100644 --- a/src/domain.rs +++ b/src/domain/subscriber_name.rs @@ -1,10 +1,5 @@ use unicode_segmentation::UnicodeSegmentation; -pub struct NewSubscriber { - pub email: String, - pub name: SubscriberName, -} - #[derive(Debug)] pub struct SubscriberName(String); @@ -36,7 +31,7 @@ impl AsRef for SubscriberName { #[cfg(test)] mod tests { - use crate::domain::SubscriberName; + use super::*; use claim::{assert_err, assert_ok}; #[test] diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 50dc1c2..26d9534 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -4,7 +4,7 @@ use sqlx::PgPool; use unicode_segmentation::UnicodeSegmentation; use uuid::Uuid; -use crate::domain::{NewSubscriber, SubscriberName}; +use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; #[allow(dead_code)] #[derive(serde::Deserialize)] @@ -13,6 +13,16 @@ pub struct FormData { name: String, } +impl TryFrom for NewSubscriber { + type Error = String; + + fn try_from(value: FormData) -> Result { + let name = SubscriberName::parse(value.name)?; + let email = SubscriberEmail::parse(value.email)?; + Ok(Self { name, email }) + } +} + #[tracing::instrument( name = "Adding a new subscriber", skip(form, pool), @@ -22,14 +32,10 @@ pub struct FormData { ) )] pub async fn subscribe(form: web::Form, pool: web::Data) -> HttpResponse { - let name = match SubscriberName::parse(form.name.clone()) { - Ok(name) => name, + let new_subscriber = match NewSubscriber::try_from(form.0) { + Ok(subscriber) => subscriber, Err(_) => return HttpResponse::BadRequest().finish(), }; - let new_subscriber = NewSubscriber { - email: form.email.clone(), - name, - }; match insert_subscriber(&new_subscriber, &pool).await { Ok(_) => HttpResponse::Ok().finish(), @@ -51,7 +57,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() )