feat(api): parse username and email
- Updated cargo deps
This commit is contained in:
parent
d96eae1fec
commit
fda6c7c044
1105
Cargo.lock
generated
1105
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@ -3,7 +3,8 @@ name = "email_newsletter_api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[profile.bench]
|
||||
debug = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
@ -16,7 +17,6 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.5.1"
|
||||
reqwest = "0.12.2"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
||||
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-bunyan-formatter = "0.3.9"
|
||||
tracing-log = "0.2.0"
|
||||
once_cell = "1.19.0"
|
||||
secrecy = { version = "0.8.0", features = ["serde"] }
|
||||
tracing-actix-web = "0.7.10"
|
||||
h2 = "0.3.26"
|
||||
serde-aux = "4.5.0"
|
||||
unicode-segmentation = "1.11.0"
|
||||
validator = { version = "0.18.1", features = ["derive"] }
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.7"
|
||||
@ -43,3 +44,11 @@ features = [
|
||||
"chrono",
|
||||
"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
7
src/domain/mod.rs
Normal 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;
|
7
src/domain/new_subscriber.rs
Normal file
7
src/domain/new_subscriber.rs
Normal 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,
|
||||
}
|
43
src/domain/subscriber_email.rs
Normal file
43
src/domain/subscriber_email.rs
Normal 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()
|
||||
}
|
||||
}
|
69
src/domain/subscriber_name.rs
Normal file
69
src/domain/subscriber_name.rs
Normal 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));
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
pub mod configuration;
|
||||
pub mod domain;
|
||||
pub mod routes;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
|
@ -1,3 +1,4 @@
|
||||
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
|
||||
use actix_web::{web, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
@ -22,7 +23,19 @@ pub async fn subscribe_route(
|
||||
form: web::Form<FormData>,
|
||||
db_conn_pool: web::Data<PgPool>,
|
||||
) -> 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(),
|
||||
Err(_) => HttpResponse::InternalServerError().finish(),
|
||||
}
|
||||
@ -30,27 +43,29 @@ pub async fn subscribe_route(
|
||||
|
||||
#[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> {
|
||||
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)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to execute query: {:?}", e);
|
||||
e
|
||||
// Using the `?` operator to return early
|
||||
// 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(())
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user