From 6a25c43ce49a6ab4210b1cec6f26d6bf44c9345d Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Sat, 23 Aug 2025 11:13:57 +0200 Subject: [PATCH] Parse data from incoming request --- Cargo.lock | 205 +++++++++++++++++++++++++++++++-- Cargo.toml | 6 + src/domain.rs | 7 ++ src/domain/new_subscriber.rs | 6 + src/domain/subscriber_email.rs | 67 +++++++++++ src/domain/subscriber_name.rs | 69 +++++++++++ src/lib.rs | 1 + src/routes/subscriptions.rs | 30 ++++- tests/health_check.rs | 28 +++++ 9 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 src/domain.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 d6ad881..2497777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "claims" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -383,6 +389,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -403,6 +444,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -459,6 +506,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.2" @@ -507,6 +564,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fake" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b0902eb36fbab51c14eda1c186bda119fcff91e5e4e7fc2dd2077298197ce8" +dependencies = [ + "deunicode", + "either", + "rand 0.9.2", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1002,6 +1070,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -1253,7 +1327,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1535,6 +1609,28 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -1544,6 +1640,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 0.8.5", +] + +[[package]] +name = "quickcheck_macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71ee38b42f8459a88d3362be6f9b841ad2d5421844f61eb1c59c11bff3ac14a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.40" @@ -1566,8 +1684,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" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1577,7 +1705,17 @@ 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_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1589,6 +1727,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -1721,7 +1868,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -1992,7 +2139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2158,7 +2305,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -2198,7 +2345,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -2254,6 +2401,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2758,6 +2911,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3220,8 +3403,12 @@ version = "0.1.0" dependencies = [ "axum", "chrono", + "claims", "config", + "fake", "once_cell", + "quickcheck", + "quickcheck_macros", "reqwest", "secrecy", "serde", @@ -3232,7 +3419,9 @@ dependencies = [ "tracing", "tracing-bunyan-formatter", "tracing-subscriber", + "unicode-segmentation", "uuid", + "validator", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8e6163d..d4b369d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,14 @@ tower-http = { version = "0.6.6", features = ["trace"] } tracing = "0.1.41" tracing-bunyan-formatter = "0.3.10" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +unicode-segmentation = "1.12.0" uuid = { version = "1.18.0", features = ["v4"] } +validator = { version = "0.20.0", features = ["derive"] } [dev-dependencies] +claims = "0.8.0" +fake = "4.4.0" once_cell = "1.21.3" +quickcheck = "1.0.3" +quickcheck_macros = "1.1.0" reqwest = "0.12.23" diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..e771a2e --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,7 @@ +mod new_subscriber; +mod subscriber_email; +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..66c678f --- /dev/null +++ b/src/domain/new_subscriber.rs @@ -0,0 +1,6 @@ +use crate::domain::{SubscriberName, subscriber_email::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..bf34862 --- /dev/null +++ b/src/domain/subscriber_email.rs @@ -0,0 +1,67 @@ +use validator::Validate; + +#[derive(Debug, Validate)] +pub struct SubscriberEmail { + #[validate(email)] + email: String, +} + +impl SubscriberEmail { + pub fn parse(email: String) -> Result { + let subscriber_email = SubscriberEmail { email }; + subscriber_email + .validate() + .map_err(|_| format!("{} is not a valid email.", subscriber_email.email))?; + Ok(subscriber_email) + } +} + +impl AsRef for SubscriberEmail { + fn as_ref(&self) -> &str { + self.email.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::SubscriberEmail; + use claims::assert_err; + use fake::Fake; + use fake::faker::internet::en::SafeEmail; + use fake::rand::SeedableRng; + use fake::rand::rngs::StdRng; + + #[derive(Clone, Debug)] + 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) + } + } + + #[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 = "alphonse.paixoutlook.com".to_string(); + assert_err!(SubscriberEmail::parse(email)); + } + + #[test] + fn email_missing_subject_is_rejected() { + let email = "@outlook.com".to_string(); + assert_err!(SubscriberEmail::parse(email)); + } + + #[quickcheck_macros::quickcheck] + fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool { + SubscriberEmail::parse(dbg!(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..89171e4 --- /dev/null +++ b/src/domain/subscriber_name.rs @@ -0,0 +1,69 @@ +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() > 256; + let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; + let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g)); + + 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.as_str() + } +} + +#[cfg(test)] +mod tests { + use crate::domain::SubscriberName; + use claims::{assert_err, assert_ok}; + + #[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 = "ê".repeat(257); + assert_err!(SubscriberName::parse(name)); + } + + #[test] + fn a_whitespace_only_name_is_rejected() { + let name = "\n \t ".to_string(); + assert_err!(SubscriberName::parse(name)); + } + + #[test] + fn empty_string_is_rejected() { + let name = "".to_string(); + assert_err!(SubscriberName::parse(name)); + } + + #[test] + fn a_name_containing_invalid_character_is_rejected() { + for name in ['/', '(', ')', '"', '<', '>', '\\', '{', '}'] { + let name = name.to_string(); + assert_err!(SubscriberName::parse(name)); + } + } + + #[test] + fn a_valid_name_is_parsed_successfully() { + let name = "Alphonse".to_string(); + assert_ok!(SubscriberName::parse(name)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9079ef9..19fce70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod configuration; +pub mod domain; pub mod routes; pub mod startup; pub mod telemetry; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 57d51fe..0e39020 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,3 +1,4 @@ +use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; use axum::{Form, extract::State, http::StatusCode, response::IntoResponse}; use chrono::Utc; use serde::Deserialize; @@ -14,9 +15,16 @@ use uuid::Uuid; )] pub async fn subscribe( State(connection): State, - form: Form, + Form(form): Form, ) -> impl IntoResponse { - if insert_subscriber(&connection, &form).await.is_err() { + let new_subscriber = match form.try_into() { + Ok(subscriber) => subscriber, + Err(_) => return StatusCode::BAD_REQUEST, + }; + if insert_subscriber(&connection, &new_subscriber) + .await + .is_err() + { StatusCode::INTERNAL_SERVER_ERROR } else { StatusCode::OK @@ -25,11 +33,11 @@ pub async fn subscribe( #[tracing::instrument( name = "Saving new subscriber details in the database", - skip(connection, form) + skip(connection, new_subscriber) )] pub async fn insert_subscriber( connection: &PgPool, - form: &Form, + new_subscriber: &NewSubscriber, ) -> Result<(), sqlx::Error> { sqlx::query!( r#" @@ -37,8 +45,8 @@ pub async fn insert_subscriber( 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) @@ -56,3 +64,13 @@ pub struct FormData { name: String, email: 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 }) + } +} diff --git a/tests/health_check.rs b/tests/health_check.rs index 3515853..d984002 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -88,6 +88,34 @@ async fn subscribe_returns_a_422_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 = [ + ("name=&email=alphonse.paix%40outlook.com", "empty name"), + ("name=Alphonse&email=", "empty email"), + ("name=Alphonse&email=not-an-email", "invalid email"), + ]; + for (body, description) in test_cases { + let response = client + .post(format!("http://{}/subscriptions", app.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .unwrap(); + + assert_eq!( + 400, + response.status().as_u16(), + "the API did not return a 400 Bad Request when the payload had an {}.", + description + ); + } +} + async fn spawn_app() -> TestApp { Lazy::force(&TRACING);