feat(domain): type driven validation implemented
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
5c15ea410d
commit
e81e628487
|
@ -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"
|
|
@ -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
|
|
@ -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;
|
|
@ -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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue