From 8929f4d0d38434af2627fa729d4d1690d944edcf Mon Sep 17 00:00:00 2001 From: Mustapha Date: Thu, 28 Aug 2025 14:04:15 +0100 Subject: [PATCH 1/2] Refactor: Update dependencies and add domain logic This commit refactors the dependencies and adds domain logic for the subscriber module. This includes the creation of a `Subscriber` struct with `email` and `name` fields. It also introduces email and name validation using the `validator` crate. Additionally, the commit updates the `Cargo.toml` file to include the necessary dependencies, such as `validator`, `claims`, `unicode-segmentation`, and `fake`. The `src/domain` and `src/domain/subscriber` modules have been created to provide a structured organization for the domain logic. --- Cargo.lock | 118 ++++++++++++++++++++++++++++++++- Cargo.toml | 5 ++ src/domain/mod.rs | 3 + src/domain/subscriber/email.rs | 68 +++++++++++++++++++ src/domain/subscriber/mod.rs | 46 +++++++++++++ src/domain/subscriber/name.rs | 83 +++++++++++++++++++++++ src/errors.rs | 15 ++++- src/handlers.rs | 9 ++- src/lib.rs | 1 + src/model.rs | 22 ++---- tests/tests.rs | 35 ++++++++-- 11 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 src/domain/mod.rs create mode 100644 src/domain/subscriber/email.rs create mode 100644 src/domain/subscriber/mod.rs create mode 100644 src/domain/subscriber/name.rs diff --git a/Cargo.lock b/Cargo.lock index 469f376..93bceee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,7 +502,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -511,6 +520,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.9.3" @@ -793,6 +808,12 @@ dependencies = [ "inout", ] +[[package]] +name = "claims" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" + [[package]] name = "clap" version = "4.5.46" @@ -1346,6 +1367,17 @@ dependencies = [ "once_cell", ] +[[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" @@ -2148,7 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" dependencies = [ "ascii-canvas", - "bit-set", + "bit-set 0.5.3", "ena", "itertools 0.11.0", "lalrpop-util", @@ -2909,6 +2941,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags", + "lazy_static", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax 0.8.6", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -2944,6 +2996,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick_cache" version = "0.5.2" @@ -3114,6 +3172,15 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -3570,6 +3637,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" @@ -4004,10 +4083,13 @@ name = "subscriptions" version = "0.1.0" dependencies = [ "axum", + "claims", "config", "dotenvy", + "fake", "http-body-util", "mime", + "proptest", "secrecy", "serde", "surrealdb", @@ -4018,7 +4100,9 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "unicode-segmentation", "url", + "validator", ] [[package]] @@ -4758,6 +4842,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.8.1" @@ -4873,6 +4963,21 @@ 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", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4897,6 +5002,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 3f9ebb6..c7eb251 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" [dependencies] axum = { version = "0.8.4", features = ["tracing"] } +claims = "0.8.0" config = "0.15.14" dotenvy = "0.15.7" secrecy = { version = "0.10.3", features = ["serde"] } @@ -20,8 +21,12 @@ tower = "0.5.2" tower-http = { version = "0.6.6", features = ["trace", "request-id"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +unicode-segmentation = "1.12.0" url = { version = "2.5.7", features = ["serde"] } +validator = "0.20.0" [dev-dependencies] http-body-util = "0.1.3" mime = "0.3.17" +fake = "4.4.0" +proptest = "1.7.0" diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..8d68b6f --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,3 @@ +mod subscriber; + +pub use subscriber::Subscriber; diff --git a/src/domain/subscriber/email.rs b/src/domain/subscriber/email.rs new file mode 100644 index 0000000..5e277e3 --- /dev/null +++ b/src/domain/subscriber/email.rs @@ -0,0 +1,68 @@ +use validator::{ValidateEmail, ValidationError}; + +#[derive(Debug)] +pub struct SubscriberEmail(String); + +impl AsRef for SubscriberEmail { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl TryFrom for SubscriberEmail { + type Error = validator::ValidationError; + + fn try_from(value: String) -> Result { + match value.validate_email() { + true => Ok(Self(value)), + false => { + let mut error = ValidationError::new("Invalid Subscriber Email"); + error.add_param("Invalid Email".into(), &value); + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::SubscriberEmail; + use claims::assert_err; + use claims::assert_ok; + use fake::Fake; + use fake::faker::internet::en::SafeEmail; + use proptest::prelude::*; + + #[test] + fn empty_string_is_rejected() { + let email = "".to_string(); + assert_err!(SubscriberEmail::try_from(email)); + } + + #[test] + fn email_missing_at_symbol_is_rejected() { + let email = "ursuladomain.com".to_string(); + assert_err!(SubscriberEmail::try_from(email)); + } + + #[test] + fn email_missing_subject_is_rejected() { + let email = "@domain.com".to_string(); + assert_err!(SubscriberEmail::try_from(email)); + } + + prop_compose! { + fn valid_email_strategy()(email in any::<()>().prop_map(|_| SafeEmail().fake::())) -> String { + email.to_string() + } + } + + proptest! { + #[test] + fn test_valid_emails_are_accepted(email in valid_email_strategy()) { + dbg!(&email); + + assert_ok!(SubscriberEmail::try_from(email)); + } + } +} diff --git a/src/domain/subscriber/mod.rs b/src/domain/subscriber/mod.rs new file mode 100644 index 0000000..3a1813b --- /dev/null +++ b/src/domain/subscriber/mod.rs @@ -0,0 +1,46 @@ +mod email; +mod name; + +use crate::handlers::FormData; +use email::SubscriberEmail; +use name::SubscriberName; +use validator::ValidationErrors; + +#[derive(Debug)] +pub struct Subscriber { + pub email: SubscriberEmail, + pub name: SubscriberName, +} + +impl TryFrom for Subscriber { + type Error = validator::ValidationErrors; + + fn try_from(value: FormData) -> Result { + let mut errors = ValidationErrors::new(); + + let email: Option = match value.email.try_into() { + Ok(email) => Some(email), + Err(error) => { + errors.add("email", error); + None + } + }; + + let name: Option = match value.name.try_into() { + Ok(name) => Some(name), + Err(error) => { + errors.add("name", error); + None + } + }; + + if let Some(email) = email + && let Some(name) = name + && errors.is_empty() + { + Ok(Self { email, name }) + } else { + Err(errors) + } + } +} diff --git a/src/domain/subscriber/name.rs b/src/domain/subscriber/name.rs new file mode 100644 index 0000000..f68df9f --- /dev/null +++ b/src/domain/subscriber/name.rs @@ -0,0 +1,83 @@ +use unicode_segmentation::UnicodeSegmentation; +use validator::ValidationError; + +#[derive(Debug)] +pub struct SubscriberName(String); + +impl AsRef for SubscriberName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl TryFrom for SubscriberName { + type Error = validator::ValidationError; + + fn try_from(value: String) -> Result { + let is_empty_or_whitespace = value.trim().is_empty(); + if is_empty_or_whitespace { + return Err(ValidationError::new("INVALID_SUBSCRIBER_NAME") + .with_message("subscriber name is empty".into())); + } + + let is_too_long = value.graphemes(true).count() > 256; + if is_too_long { + return Err(ValidationError::new("INVALID_SUBSCRIBER_NAME") + .with_message("subscriber name length is more than 256".into())); + } + + let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; + let contains_forbidden_characters = + value.chars().any(|g| forbidden_characters.contains(&g)); + if contains_forbidden_characters { + return Err(ValidationError::new("INVALID_SUBSCRIBER_NAME") + .with_message("subscriber name contains forbidden characters".into())); + } + + Ok(Self(value)) + } +} + +#[cfg(test)] +mod tests { + use super::SubscriberName; + use claims::{assert_err, assert_ok}; + + #[test] + fn a_256_grapheme_long_name_is_valid() { + let name = "ё".repeat(256); + assert_ok!(SubscriberName::try_from(name)); + } + + #[test] + fn a_name_longer_than_256_graphemes_is_rejected() { + let name = "a".repeat(257); + assert_err!(SubscriberName::try_from(name)); + } + + #[test] + fn whitespace_only_names_are_rejected() { + let name = " ".to_string(); + assert_err!(SubscriberName::try_from(name)); + } + + #[test] + fn empty_string_is_rejected() { + let name = "".to_string(); + assert_err!(SubscriberName::try_from(name)); + } + + #[test] + fn names_containing_an_invalid_character_are_rejected() { + for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] { + let name = name.to_string(); + assert_err!(SubscriberName::try_from(name)); + } + } + + #[test] + fn a_valid_name_is_parsed_successfully() { + let name = "Ursula Le Guin".to_string(); + assert_ok!(SubscriberName::try_from(name)); + } +} diff --git a/src/errors.rs b/src/errors.rs index abec206..8fe2c10 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -14,12 +14,21 @@ pub enum Error { Config(#[from] config::ConfigError), #[error(transparent)] UrlParse(#[from] url::ParseError), + #[error(transparent)] + ValidationError(#[from] validator::ValidationErrors), } impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { - tracing::error!("Internal Server: - {self:?}"); - - StatusCode::INTERNAL_SERVER_ERROR.into_response() + match self { + Self::ValidationError(_) => { + tracing::info!("Bad request: - {self:?}"); + StatusCode::BAD_REQUEST.into_response() + } + _ => { + tracing::error!("Internal Server: - {self:?}"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } } } diff --git a/src/handlers.rs b/src/handlers.rs index 2effae0..9bbf0ed 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,13 +1,13 @@ +use crate::Result; use crate::model::ModelManager; -use crate::{Result, model::Subscription}; use axum::{Form, extract::State, http::StatusCode}; use serde::Deserialize; use std::sync::Arc; #[derive(Debug, Deserialize)] pub struct FormData { - email: String, - name: String, + pub email: String, + pub name: String, } #[tracing::instrument(skip(mm))] @@ -15,8 +15,7 @@ pub async fn subscribe( State(mm): State>, Form(form): Form, ) -> Result { - let subscription = Subscription::new(form.name, form.email); - mm.create_subscription(subscription).await?; + mm.create_subscriber(form.try_into()?).await?; Ok(StatusCode::CREATED) } diff --git a/src/lib.rs b/src/lib.rs index c6b2c1c..d2f0b76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod config; +mod domain; mod errors; mod handlers; mod model; diff --git a/src/model.rs b/src/model.rs index 95b9d29..e09825c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,23 +1,9 @@ +use crate::{Error, Result, config::DatabaseConfig, domain}; use secrecy::ExposeSecret; use surrealdb::{Surreal, engine::any::Any, opt::auth::Database}; use surrealdb_migrations::MigrationRunner; use tokio::sync::OnceCell; -use crate::{Error, Result, config::DatabaseConfig}; -use serde::Serialize; - -#[derive(Debug, Serialize)] -pub struct Subscription { - name: String, - email: String, -} - -impl Subscription { - pub fn new(name: String, email: String) -> Self { - Self { name, email } - } -} - #[derive(Debug, Clone)] pub struct ModelManager { config: DatabaseConfig, @@ -36,12 +22,12 @@ impl ModelManager { self.db.get_or_try_init(async || self.connect().await).await } - pub async fn create_subscription(&self, subscription: Subscription) -> Result<()> { + pub async fn create_subscriber(&self, subscriber: domain::Subscriber) -> Result<()> { self.db() .await? .query("CREATE subscriptions SET name = $name, email = $email") - .bind(("name", subscription.name)) - .bind(("email", subscription.email)) + .bind(("name", subscriber.name.as_ref().to_string())) + .bind(("email", subscriber.email.as_ref().to_string())) .await?; Ok(()) diff --git a/tests/tests.rs b/tests/tests.rs index e106d54..4c9e0f5 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -109,12 +109,35 @@ async fn subscribe_failed() { // Arrange let (app, _) = init().await.expect("Expected App to be initialized!"); let test_cases = [ - ("name=le%20guin", "missing the email"), - ("email=ursula_le_guin%40gmail.com", "missing the name"), - ("", "missing the name and email"), + ( + "name=le%20guin", + "missing the email", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + "email=ursula_le_guin%40gmail.com", + "missing the name", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + "", + "missing the name and email", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + "name=&email=ursula_le_guin%40gmail.com", + "empty name", + StatusCode::BAD_REQUEST, + ), + ("name=Ursula&email=", "empty email", StatusCode::BAD_REQUEST), + ( + "name=Ursula&email=definitely-not-an-email", + "invalid email", + StatusCode::BAD_REQUEST, + ), ]; - for (invalid_body, error_message) in test_cases { + for (invalid_body, error_message, expected_status) in test_cases { // Act let response = app .clone() @@ -135,8 +158,8 @@ async fn subscribe_failed() { // Assert assert_eq!( response.status(), - StatusCode::UNPROCESSABLE_ENTITY, - "The Api did not fail with 400 Bad Request when payload was {error_message}." + expected_status, + "The Api did not fail with status {expected_status} when payload was {error_message}." ); } } From 1e10ca67e669b6d6c3656e3bc3874595a7a4d27e Mon Sep 17 00:00:00 2001 From: Mustapha Date: Thu, 28 Aug 2025 14:33:13 +0100 Subject: [PATCH 2/2] Refactor email validation and add check to insert query This commit refactors the email validation to provide more information and adds a check to the insert query for subscriptions. --- src/domain/subscriber/email.rs | 7 ++----- src/model.rs | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/domain/subscriber/email.rs b/src/domain/subscriber/email.rs index 5e277e3..cbb0492 100644 --- a/src/domain/subscriber/email.rs +++ b/src/domain/subscriber/email.rs @@ -15,11 +15,8 @@ impl TryFrom for SubscriberEmail { fn try_from(value: String) -> Result { match value.validate_email() { true => Ok(Self(value)), - false => { - let mut error = ValidationError::new("Invalid Subscriber Email"); - error.add_param("Invalid Email".into(), &value); - Err(error) - } + false => Err(ValidationError::new("INVALID_SUBSCRIBER_EMAIL") + .with_message(format!("Invalid subscriber email `{value}`").into())), } } } diff --git a/src/model.rs b/src/model.rs index e09825c..7473f44 100644 --- a/src/model.rs +++ b/src/model.rs @@ -28,7 +28,8 @@ impl ModelManager { .query("CREATE subscriptions SET name = $name, email = $email") .bind(("name", subscriber.name.as_ref().to_string())) .bind(("email", subscriber.email.as_ref().to_string())) - .await?; + .await? + .check()?; Ok(()) }