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

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 domain;
pub mod routes;
pub mod startup;
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 chrono::Utc;
use serde::Deserialize;
@@ -14,9 +15,16 @@ use uuid::Uuid;
)]
pub async fn subscribe(
State(connection): State<PgPool>,
form: Form<FormData>,
Form(form): Form<FormData>,
) -> 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<FormData>,
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<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 })
}
}