type driven development, checks for email + name

This commit is contained in:
Andre Heber
2024-02-20 23:42:51 +01:00
parent ef4040aa13
commit e457729f61
10 changed files with 327 additions and 134 deletions

239
Cargo.lock generated
View File

@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "autocfg"
version = "1.1.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "backtrace"
@ -419,6 +419,15 @@ dependencies = [
"windows-targets 0.52.0",
]
[[package]]
name = "claim"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81099d6bb72e1df6d50bb2347224b666a670912bb7f06dbe867a4a070ab3ce8"
dependencies = [
"autocfg",
]
[[package]]
name = "config"
version = "0.14.0"
@ -604,6 +613,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "deunicode"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94"
[[package]]
name = "digest"
version = "0.10.7"
@ -649,6 +664,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.1"
@ -682,6 +707,16 @@ version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "fake"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c25829bde82205da46e1823b2259db6273379f626fc211f126f65654a2669be"
dependencies = [
"deunicode",
"rand",
]
[[package]]
name = "fastrand"
version = "2.0.1"
@ -721,21 +756,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@ -1005,16 +1025,17 @@ dependencies = [
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"bytes",
"futures-util",
"http",
"hyper",
"native-tls",
"rustls",
"tokio",
"tokio-native-tls",
"tokio-rustls",
]
[[package]]
@ -1040,6 +1061,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"
@ -1179,11 +1210,10 @@ checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
[[package]]
name = "lock_api"
version = "0.4.11"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b"
dependencies = [
"autocfg",
"scopeguard",
]
@ -1257,24 +1287,6 @@ version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d02c0b00610773bb7fc61d85e13d86c7858cbdf00e1a120bfc41bc055dbaa0e"
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nix"
version = "0.27.1"
@ -1378,50 +1390,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl"
version = "0.10.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-multimap"
version = "0.6.0"
@ -1613,6 +1581,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",
]
[[package]]
name = "quickcheck_macros"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b22a693222d716a9587786f37ac3f6b4faedb5b80c23914e7303ff5a1d8016e9"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quote"
version = "1.0.35"
@ -1720,26 +1710,28 @@ dependencies = [
"http",
"http-body",
"hyper",
"hyper-tls",
"hyper-rustls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"winreg",
]
@ -1833,6 +1825,7 @@ version = "0.21.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba"
dependencies = [
"log",
"ring",
"rustls-webpki",
"sct",
@ -1863,15 +1856,6 @@ version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
[[package]]
name = "schannel"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1898,29 +1882,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.21"
@ -2485,12 +2446,12 @@ dependencies = [
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"native-tls",
"rustls",
"tokio",
]
@ -2727,7 +2688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
dependencies = [
"form_urlencoded",
"idna",
"idna 0.5.0",
"percent-encoding",
]
@ -2746,6 +2707,21 @@ dependencies = [
"getrandom",
]
[[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"
@ -3064,8 +3040,13 @@ version = "0.1.0"
dependencies = [
"actix-web",
"chrono",
"claim",
"config",
"fake",
"once_cell",
"quickcheck",
"quickcheck_macros",
"rand",
"reqwest",
"secrecy",
"serde",
@ -3076,7 +3057,9 @@ dependencies = [
"tracing-bunyan-formatter",
"tracing-log 0.2.0",
"tracing-subscriber",
"unicode-segmentation",
"uuid",
"validator",
]
[[package]]

View File

@ -28,6 +28,15 @@ tracing-log = "0.2"
once_cell = "1"
secrecy = { version = "0.8", features = ["serde"] }
tracing-actix-web = "0.7"
unicode-segmentation = "1"
claim = "0.5"
validator = "0.16"
[dev-dependencies]
fake = "2.9"
quickcheck = "1.0"
quickcheck_macros = "1.0"
rand = "0.8"
[dependencies.sqlx]
version = "0.7.3"

7
src/domain/mod.rs Normal file
View File

@ -0,0 +1,7 @@
mod subscriber_name;
mod subscriber_email;
mod new_subscriber;
pub use subscriber_name::SubscriberName;
pub use subscriber_email::SubscriberEmail;
pub use new_subscriber::NewSubscriber;

View File

@ -0,0 +1,7 @@
use crate::domain::SubscriberName;
use crate::domain::SubscriberEmail;
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}

View File

@ -0,0 +1,65 @@
use validator::validate_email;
#[derive(Debug)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
if validate_email(&s) {
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 claim::assert_err;
use fake::faker::internet::en::SafeEmail;
use fake::Fake;
use rand::rngs::StdRng;
use rand::SeedableRng;
#[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 quickcheck::Gen) -> Self {
let mut rng = StdRng::seed_from_u64(u64::arbitrary(g));
let email = SafeEmail().fake_with_rng(&mut rng);
Self(email)
}
}
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
dbg!(&valid_email.0);
SubscriberEmail::parse(valid_email.0).is_ok()
}
}

View File

@ -0,0 +1,59 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
let is_empty_or_whitespace = s.trim().is_empty();
let is_too_long = s.graphemes(true).count() > 2048;
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
{
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 claim::{assert_err, assert_ok};
#[test]
fn a_very_long_name_is_rejected() {
let too_long_name = "a".repeat(2049);
assert_err!(SubscriberName::parse(too_long_name));
}
#[test]
fn a_name_with_forbidden_characters_is_rejected() {
let forbidden_characters = vec![';', ':', '!', '?', '*', '(', ')', '&', '$', '@', '#', '<', '>', '[', ']', '{', '}', '/', '\\'];
for forbidden_character in forbidden_characters {
let name_with_forbidden_character = format!("name{}", forbidden_character);
assert_err!(SubscriberName::parse(name_with_forbidden_character));
}
}
#[test]
fn a_name_with_allowed_characters_is_accepted() {
let name = "name".to_string();
assert_ok!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
}

17
src/email_client.rs Normal file
View File

@ -0,0 +1,17 @@
use crate::domain::SubscriberEmail;
pub struct EmailClient {
sender: SubscriberEmail,
}
impl EmailClient {
pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), String> {
todo!("send_email not implemented")
}
}

View File

@ -1,4 +1,6 @@
pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod startup;
pub mod telemetry;

View File

@ -4,6 +4,8 @@ use chrono::Utc;
use uuid::Uuid;
use sqlx::{query, Pool, Postgres};
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
#[derive(Deserialize)]
pub struct FormData {
pub email: String,
@ -19,26 +21,39 @@ pub struct FormData {
)
)]
pub async fn subscribe(form: web::Form<FormData>, connection_pool: web::Data<Pool<Postgres>>) -> HttpResponse {
match insert_subscriber(&form, &connection_pool).await
{
let new_subscriber = match form.0.try_into() {
Ok(subscriber) => subscriber,
Err(_) => return HttpResponse::BadRequest().finish(),
};
match insert_subscriber(&new_subscriber, &connection_pool).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
impl TryFrom<FormData> for NewSubscriber {
type Error = String;
fn try_from(form: FormData) -> Result<NewSubscriber, Self::Error> {
let name = SubscriberName::parse(form.name)?;
let email = SubscriberEmail::parse(form.email)?;
Ok(NewSubscriber { email, name })
}
}
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(form, connection_pool)
skip(new_subscriber, connection_pool)
)]
async fn insert_subscriber(form: &FormData, connection_pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
async fn insert_subscriber(new_subscriber: &NewSubscriber, connection_pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
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_pool)

View File

@ -132,3 +132,32 @@ async fn subscribe_returns_a_400_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 = vec![
("name=&email=ursula_le_guin%40gmail.com", "name is empty"),
("name=le%20guin&email=", "email is empty"),
("name=&email=", "name and email are empty"),
("name=Ursula&email=definitely-not-an-email", "invalid email"),
];
for (invalid_body, error_message) in test_cases {
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(invalid_body)
.send()
.await
.expect("Failed to execute request.");
assert_eq!(
400,
response.status().as_u16(),
"The API did not return a 400 Bad Request when the payload was {}.",
error_message
);
}
}