feat(api): parse username and email

- Updated cargo deps
This commit is contained in:
minhtrannhat 2024-08-21 10:47:02 -04:00
parent d96eae1fec
commit fda6c7c044
Signed by: minhtrannhat
GPG Key ID: E13CFA85C53F8062
9 changed files with 925 additions and 383 deletions

1105
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@ name = "email_newsletter_api"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [profile.bench]
debug = true
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
@ -16,7 +17,6 @@ edition = "2021"
[dependencies] [dependencies]
actix-web = "4.5.1" actix-web = "4.5.1"
reqwest = "0.12.2"
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
tokio = { version = "1.36.0", features = ["full"] } tokio = { version = "1.36.0", features = ["full"] }
config = "0.13" config = "0.13"
@ -26,11 +26,12 @@ tracing = { version = "0.1.40", features = ["log"] }
tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = "0.3.9" tracing-bunyan-formatter = "0.3.9"
tracing-log = "0.2.0" tracing-log = "0.2.0"
once_cell = "1.19.0"
secrecy = { version = "0.8.0", features = ["serde"] } secrecy = { version = "0.8.0", features = ["serde"] }
tracing-actix-web = "0.7.10" tracing-actix-web = "0.7.10"
h2 = "0.3.26" h2 = "0.3.26"
serde-aux = "4.5.0" serde-aux = "4.5.0"
unicode-segmentation = "1.11.0"
validator = { version = "0.18.1", features = ["derive"] }
[dependencies.sqlx] [dependencies.sqlx]
version = "0.7" version = "0.7"
@ -43,3 +44,11 @@ features = [
"chrono", "chrono",
"migrate" "migrate"
] ]
[dev-dependencies]
fake = "~2.3"
claims = "0.7.1"
once_cell = "1.19.0"
reqwest = "0.12.2"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"

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,7 @@
use crate::domain::subscriber_email::SubscriberEmail;
use crate::domain::subscriber_name::SubscriberName;
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}

View File

@ -0,0 +1,43 @@
use validator::ValidateEmail;
#[derive(Debug)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(string_input: String) -> Result<SubscriberEmail, String> {
if string_input.validate_email() {
Ok(Self(string_input))
} else {
Err(format!("{} is not a valid email.", string_input))
}
}
}
impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use crate::domain::subscriber_email::SubscriberEmail;
use claims::assert_err;
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,69 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(string_input: String) -> Result<SubscriberName, String> {
let is_empty_or_whitespace = string_input.trim().is_empty();
let is_too_long = string_input.graphemes(true).count() > 256;
let forbidden_chars = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_fobidden_chars = string_input.chars().any(|g| forbidden_chars.contains(&g));
if is_empty_or_whitespace || is_too_long || contains_fobidden_chars {
Err(format!("{} is not a valid subscriber name.", string_input))
} else {
Ok(Self(string_input))
}
}
}
impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[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 = "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 empty_string_is_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));
}
}

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 actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use chrono::Utc; use chrono::Utc;
use sqlx::PgPool; use sqlx::PgPool;
@ -22,7 +23,19 @@ pub async fn subscribe_route(
form: web::Form<FormData>, form: web::Form<FormData>,
db_conn_pool: web::Data<PgPool>, db_conn_pool: web::Data<PgPool>,
) -> HttpResponse { ) -> HttpResponse {
match insert_subscriber(&db_conn_pool, &form).await { let name = match SubscriberName::parse(form.0.name) {
Ok(name) => name,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let email = match SubscriberEmail::parse(form.0.email) {
Ok(email) => email,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let new_subscriber = NewSubscriber { email, name };
match insert_subscriber(&db_conn_pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(), Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(), Err(_) => HttpResponse::InternalServerError().finish(),
} }
@ -30,27 +43,29 @@ pub async fn subscribe_route(
#[tracing::instrument( #[tracing::instrument(
name = "Saving new subscriber details in the database", 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> {
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at) INSERT INTO subscriptions (id, email, name, subscribed_at)
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(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
// Using the `?` operator to return early // Using the `?` operator to return early
// if the function failed, returning a sqlx::Error // if the function failed, returning a sqlx::Error
// We will talk about error handling in depth later! tracing::error!("Failed to execute query: {:?}", e);
e
})?; })?;
Ok(()) Ok(())
} }

View File

@ -59,3 +59,33 @@ async fn subscribe_returns_a_400_when_data_is_missing() {
) )
} }
} }
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
("name=Ursula&email=", "empty email"),
("name=Ursula&email=definitely-not-an-email", "invalid email"),
];
for (invalid_body, error_message) in test_cases {
// Act
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
);
}
}