Parse data from incoming request

This commit is contained in:
Alphonse Paix
2025-08-23 11:13:57 +02:00
parent 4d049a744a
commit 85ab04f254
9 changed files with 405 additions and 14 deletions

205
Cargo.lock generated
View File

@@ -248,6 +248,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "claims"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -383,6 +389,41 @@ dependencies = [
"typenum", "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]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -403,6 +444,12 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -459,6 +506,16 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -507,6 +564,17 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -1002,6 +1070,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.0.3" version = "1.0.3"
@@ -1253,7 +1327,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@@ -1535,6 +1609,28 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.101" version = "1.0.101"
@@ -1544,6 +1640,28 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@@ -1566,8 +1684,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "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]] [[package]]
@@ -1577,7 +1705,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -1589,6 +1727,15 @@ dependencies = [
"getrandom 0.2.16", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.17" version = "0.5.17"
@@ -1721,7 +1868,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@@ -1992,7 +2139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -2158,7 +2305,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
@@ -2198,7 +2345,7 @@ dependencies = [
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -2254,6 +2401,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2758,6 +2911,36 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -3220,8 +3403,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
"claims",
"config", "config",
"fake",
"once_cell", "once_cell",
"quickcheck",
"quickcheck_macros",
"reqwest", "reqwest",
"secrecy", "secrecy",
"serde", "serde",
@@ -3232,7 +3419,9 @@ dependencies = [
"tracing", "tracing",
"tracing-bunyan-formatter", "tracing-bunyan-formatter",
"tracing-subscriber", "tracing-subscriber",
"unicode-segmentation",
"uuid", "uuid",
"validator",
] ]
[[package]] [[package]]

View File

@@ -23,8 +23,14 @@ tower-http = { version = "0.6.6", features = ["trace"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-bunyan-formatter = "0.3.10" tracing-bunyan-formatter = "0.3.10"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
unicode-segmentation = "1.12.0"
uuid = { version = "1.18.0", features = ["v4"] } uuid = { version = "1.18.0", features = ["v4"] }
validator = { version = "0.20.0", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
claims = "0.8.0"
fake = "4.4.0"
once_cell = "1.21.3" once_cell = "1.21.3"
quickcheck = "1.0.3"
quickcheck_macros = "1.1.0"
reqwest = "0.12.23" reqwest = "0.12.23"

7
src/domain.rs Normal file
View File

@@ -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;

View File

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

View File

@@ -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<Self, String> {
let subscriber_email = SubscriberEmail { email };
subscriber_email
.validate()
.map_err(|_| format!("{} is not a valid email.", subscriber_email.email))?;
Ok(subscriber_email)
}
}
impl AsRef<str> 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()
}
}

View File

@@ -0,0 +1,69 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<Self, String> {
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<str> 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));
}
}

View File

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

View File

@@ -1,3 +1,4 @@
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
use axum::{Form, extract::State, http::StatusCode, response::IntoResponse}; use axum::{Form, extract::State, http::StatusCode, response::IntoResponse};
use chrono::Utc; use chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
@@ -14,9 +15,16 @@ use uuid::Uuid;
)] )]
pub async fn subscribe( pub async fn subscribe(
State(connection): State<PgPool>, State(connection): State<PgPool>,
form: Form<FormData>, Form(form): Form<FormData>,
) -> impl IntoResponse { ) -> 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 StatusCode::INTERNAL_SERVER_ERROR
} else { } else {
StatusCode::OK StatusCode::OK
@@ -25,11 +33,11 @@ pub async fn subscribe(
#[tracing::instrument( #[tracing::instrument(
name = "Saving new subscriber details in the database", name = "Saving new subscriber details in the database",
skip(connection, form) skip(connection, new_subscriber)
)] )]
pub async fn insert_subscriber( pub async fn insert_subscriber(
connection: &PgPool, connection: &PgPool,
form: &Form<FormData>, new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
sqlx::query!( sqlx::query!(
r#" r#"
@@ -37,8 +45,8 @@ pub async fn insert_subscriber(
VALUES ($1, $2, $3, $4); VALUES ($1, $2, $3, $4);
"#, "#,
Uuid::new_v4(), Uuid::new_v4(),
form.email, new_subscriber.email.as_ref(),
form.name, new_subscriber.name.as_ref(),
Utc::now() Utc::now()
) )
.execute(connection) .execute(connection)
@@ -56,3 +64,13 @@ pub struct FormData {
name: String, name: String,
email: String, email: String,
} }
impl TryFrom<FormData> for NewSubscriber {
type Error = String;
fn try_from(value: FormData) -> Result<Self, Self::Error> {
let name = SubscriberName::parse(value.name)?;
let email = SubscriberEmail::parse(value.email)?;
Ok(Self { name, email })
}
}

View File

@@ -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 { async fn spawn_app() -> TestApp {
Lazy::force(&TRACING); Lazy::force(&TRACING);