feat(domain): type driven validation implemented
continuous-integration/drone/push Build is passing Details

main
flavien 2022-12-01 21:31:03 +01:00
parent 5c15ea410d
commit e81e628487
10 changed files with 279 additions and 27 deletions

View File

@ -18,8 +18,6 @@ name = "zero2prod"
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"]}
validator = { version = "0.16", features = ["derive"] }
actix-web-validator = "5.0.1"
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = "0.3"
@ -31,6 +29,9 @@ clap = { version = "4.0.23", features = ["derive"] }
envy = "0.4.2"
secrecy = { version = "0.8", features = ["serde"] }
tracing-actix-web = "0.6"
unicode-segmentation = "1.10.0"
validator = "0.14"
lettre = { version = "0.10", features = ["tokio1-native-tls", "tokio1"] }
[dependencies.sqlx]
version = "0.6"
@ -48,3 +49,7 @@ features = [
[dev-dependencies]
reqwest = "0.11"
once_cell = "1"
claim = "0.5"
fake = "~2.3"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"

View File

@ -1,5 +1,5 @@
POST http://localhost:8000/subscriptions HTTP/1.1
Content-Type: application/x-www-form-urlencoded
name=test
name=tes(t
&email=test@test.com

7
src/domain/mod.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,25 @@
use crate::routes::FormData;
use super::{SubscriberEmail, SubscriberName};
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}
impl TryFrom<FormData> for NewSubscriber {
type Error = String;
fn try_from(form: FormData) -> Result<Self, Self::Error> {
Ok(NewSubscriber {
email: match SubscriberEmail::parse(form.email) {
Ok(value) => value,
Err(message) => return Err(format!("email field: {}", message)),
},
name: match SubscriberName::parse(form.name) {
Ok(value) => value,
Err(message) => return Err(format!("name field: {}", message)),
},
})
}
}

View File

@ -0,0 +1,62 @@
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;
#[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));
}
use fake::faker::internet::en::SafeEmail;
use fake::Fake;
#[derive(Debug, Clone)]
struct ValidEmailFixture(pub String);
impl quickcheck::Arbitrary for ValidEmailFixture {
fn arbitrary<G: quickcheck::Gen>(g: &mut G) -> Self {
let email = SafeEmail().fake_with_rng(g);
Self(email)
}
}
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
SubscriberEmail::parse(valid_email.0).is_ok()
}
}

View File

@ -0,0 +1,67 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
if s.trim().is_empty() {
return Err("empty.".to_owned());
}
if s.graphemes(true).count() > 256 {
return Err("too long > 256.".to_owned());
}
let forbidden_characters = r#"/()"<>\{}"#;
if s.chars().any(|g| forbidden_characters.contains(g)) {
return Err(format!(
"contain forbidden characters {}.",
forbidden_characters
));
}
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_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 = "a".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 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));
}
}

48
src/email_client.rs Normal file
View File

@ -0,0 +1,48 @@
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::domain::SubscriberEmail;
pub struct EmailClient {
sender: SubscriberEmail, // internals
}
// create initializer from config
// test send from tests
impl EmailClient {
// init
pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
) -> Result<(), String> {
let email = Message::builder()
.from(self.sender.as_ref().parse().unwrap())
// todo => create from name + email
.to(recipient.as_ref().parse().unwrap())
.subject(subject)
.body(String::from(html_content))
.unwrap();
// set on init
let creds = Credentials::new(
self.sender.as_ref().to_string(),
"5yfHvyKUYbvvNB".to_string(),
);
// set on init
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay("smtp.coincoinmail.fr")
.unwrap()
.credentials(creds)
.build();
match mailer.send(email).await {
Ok(_) => Ok(()),
Err(e) => Err(format!("Could not send email: {:?}", e)),
}
}
}
#[cfg(test)]
mod tests {
// test sending => return ok
}

View File

@ -1,5 +1,7 @@
//! src/lib.rs
pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod startup;
pub mod telemetry;

View File

@ -1,25 +1,16 @@
use crate::domain::NewSubscriber;
use actix_web::{post, web, HttpResponse, Responder};
use actix_web_validator::Form;
use chrono::Utc;
use serde::Deserialize;
use sqlx::{Error, PgPool};
use uuid::Uuid;
use validator::Validate;
/*
NOTES
- possible to use derive_more crate to easily derive Display
- possible to add custom validation function
*/
#[derive(Debug, Validate, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct FormData {
#[validate(email)]
email: String,
#[validate(length(min = 1, max = 32))]
name: String,
pub email: String,
pub name: String,
}
// TO EXTRACT WITH OTHERS GENERICS DATABASE OR ERRORS RELATED FUNCTIONS
fn query_error_is_duplicate_key(error: Error) -> bool {
match error {
Error::Database(db_error) => match db_error.code() {
@ -32,16 +23,19 @@ fn query_error_is_duplicate_key(error: Error) -> bool {
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(form, pool)
skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(pool: &PgPool, form: &FormData) -> Result<(), sqlx::Error> {
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
match sqlx::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(pool)
@ -64,13 +58,18 @@ pub async fn insert_subscriber(pool: &PgPool, form: &FormData) -> Result<(), sql
subscriber_name = %form.name
)
)]
pub async fn subscriptions(form: Form<FormData>, pool: web::Data<PgPool>) -> impl Responder {
match insert_subscriber(&pool, &form).await {
Ok(_) => HttpResponse::Created(),
pub async fn subscriptions(form: web::Form<FormData>, pool: web::Data<PgPool>) -> impl Responder {
let new_subscriber = match NewSubscriber::try_from(form.0) {
Ok(value) => value,
Err(message) => {
return HttpResponse::BadRequest().body(format!("form data error: {}", message))
}
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Created().finish(),
Err(e) => match query_error_is_duplicate_key(e) {
true => HttpResponse::Conflict(),
false => HttpResponse::InternalServerError(),
true => HttpResponse::Conflict().finish(),
false => HttpResponse::InternalServerError().finish(),
},
}
.await
}

View File

@ -79,7 +79,7 @@ async fn subscribe_returns_a_201_for_valid_form_data() {
}
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
async fn subscribe_returns_a_400_for_invalid_data() {
let test_app = spawn_app().await;
let client = reqwest::Client::new();
@ -87,6 +87,43 @@ async fn subscribe_returns_a_400_when_data_is_missing() {
("name=le%20guin", "missing the email"),
("email=ursula_le_guin%40gmail.com", "missing the name"),
("", "missing both name and email"),
("name=%20%20=ursula_le_guin%40gmail.com", "only whitespaces"),
(
"name=te/st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te\\st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te{st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te}st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te<st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te>st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te(st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
"name=te)st=ursula_le_guin%40gmail.com",
"forbidden character",
),
(
r#"name=te"st=ursula_le_guin%40gmail.com"#,
"forbidden character",
),
];
for (invalid_body, error_message) in test_cases {
let response = client