Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ once_cell = "1.21.3"
secrecy = { version = "0.10.3", features = ["serde"] }
tracing-actix-web = "0.7.19"
serde-aux = "4.7.0"
unicode-segmentation = "1.12.0"
validator = "0.20.0"

[dependencies.sqlx]
version = "0.8"
Expand All @@ -38,4 +40,6 @@ features = [
]

[dev-dependencies]
claims = "0.8.0"
fake = "4.4.0"
reqwest = "0.12"
12 changes: 12 additions & 0 deletions src/domain/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! # Domain
//!
//! Contains the core business logic and types for the application.
//! This module enforces invariants (like valid email formats) through the type system.

mod new_subscriber;
mod subscriber_email;
mod subscriber_name;

pub use new_subscriber::NewSubscriber;
pub use subscriber_email::SubscriberEmail;
pub use subscriber_name::SubscriberName;
11 changes: 11 additions & 0 deletions src/domain/new_subscriber.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use crate::domain::SubscriberEmail;
use crate::domain::SubscriberName;

/// A validated subscriber.
///
/// This struct can only be constructed if both the email and name have passed
/// their respective validation rules.
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}
58 changes: 58 additions & 0 deletions src/domain/subscriber_email.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use validator::ValidateEmail;

/// A validated subscriber email address.
///
/// Wraps a string and ensures it conforms to standard email formatting rules.
#[derive(Debug)]
pub struct SubscriberEmail(String);

impl SubscriberEmail {
/// Parses a string into a `SubscriberEmail`.
///
/// Uses the `validator` crate to check if the string is a valid email.
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
if s.validate_email() {
Ok(Self(s))
} else {
Err(format!("{} is not a valid subscriber email.", s))
}
}
}

impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0
}
}

#[cfg(test)]
mod tests {
use super::SubscriberEmail;
use claims::{assert_err, assert_ok};
use fake::Fake;
use fake::faker::internet::en::SafeEmail;

#[test]
fn valid_emails_are_parsed_successfully() {
let email = SafeEmail().fake();
assert_ok!(SubscriberEmail::parse(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 = "jondoe.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));
}
}
80 changes: 80 additions & 0 deletions src/domain/subscriber_name.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use unicode_segmentation::UnicodeSegmentation;

/// A validated subscriber name.
///
/// Enforces the following rules:
/// - Not empty or whitespace only.
/// - Maximum length of 256 graphemes.
/// - Does not contain forbidden characters (e.g., `/`, `(`, `)`, etc.).
#[derive(Debug)]
pub struct SubscriberName(String);

impl SubscriberName {
/// Parses a string into a `SubscriberName`.
///
/// # Returns
/// - `Ok(SubscriberName)` if the input satisfies all validation rules.
/// - `Err(String)` with a description of the violation otherwise.
pub fn parse(s: String) -> Result<SubscriberName, String> {
let is_empty_or_whitespace = s.trim().is_empty();

// A grapheme is what a user perceives as a single character.
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<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}

#[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_grapheme_long_name_is_rejected() {
let name = "ё".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));
}
}
12 changes: 10 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
//! # Velo Library
//!
//! This library provides the core functionality for the Velo newsletter application,
//! including configuration, routes, startup logic, and telemetry.
//! This library provides the core functionality for the Velo newsletter application.
//!
//! ## Modules
//!
//! - [`configuration`]: Handles reading and parsing application configuration from files and environment variables.
//! - [`domain`]: Contains the business logic and type definitions (e.g., `SubscriberEmail`, `SubscriberName`) that enforce domain invariants.
//! - [`routes`]: Defines the HTTP route handlers for the application endpoints.
//! - [`startup`]: Contains logic to bootstrap the application server and database connection.
//! - [`telemetry`]: Provides infrastructure for structured logging and distributed tracing.

pub mod configuration;
pub mod domain;
pub mod routes;
pub mod startup;
pub mod telemetry;
Loading