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"
|
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
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 configuration;
|
||||||
|
pub mod domain;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
@ -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