Validation for user's name and email

Email validation is still primitive but checks if the email could theoretically exist (name@domain.com). Separation of Subscriber type into a module as well as actions that cane be performed on it. Updates to the subscribe functionality to align with new test cases that have been made.
This commit is contained in:
Nick Bland 2022-01-12 17:51:55 +10:00
parent 68d961186c
commit ee6e255b2e
No known key found for this signature in database
GPG Key ID: B46CF88E4DAB4A2C
9 changed files with 352 additions and 22 deletions

167
Cargo.lock generated
View File

@ -50,7 +50,7 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rand",
"rand 0.8.4",
"sha-1 0.10.0",
"smallvec",
"zstd",
@ -194,7 +194,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
dependencies = [
"getrandom",
"getrandom 0.2.3",
"once_cell",
"version_check",
]
@ -349,6 +349,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "claim"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81099d6bb72e1df6d50bb2347224b666a670912bb7f06dbe867a4a070ab3ce8"
dependencies = [
"autocfg",
]
[[package]]
name = "config"
version = "0.11.0"
@ -565,6 +574,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_logger"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
dependencies = [
"log",
"regex",
]
[[package]]
name = "fake"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6479fa2c7e83ddf8be7d435421e093b072ca891b99a49bc84eba098f4044f818"
dependencies = [
"rand 0.7.3",
]
[[package]]
name = "firestorm"
version = "0.4.6"
@ -687,6 +715,17 @@ dependencies = [
"winapi",
]
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.2.3"
@ -695,7 +734,7 @@ checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.10.2+wasi-snapshot-preview1",
]
[[package]]
@ -994,8 +1033,12 @@ version = "0.1.0"
dependencies = [
"actix-web",
"chrono",
"claim",
"config",
"fake",
"once_cell",
"quickcheck",
"quickcheck_macros",
"reqwest",
"secrecy",
"serde",
@ -1007,7 +1050,9 @@ dependencies = [
"tracing-bunyan-formatter",
"tracing-log",
"tracing-subscriber",
"unicode-segmentation",
"uuid",
"validator",
]
[[package]]
@ -1327,6 +1372,29 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "quickcheck"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44883e74aa97ad63db83c4bf8ca490f02b2fc02f92575e720c8551e843c945f"
dependencies = [
"env_logger",
"log",
"rand 0.7.3",
"rand_core 0.5.1",
]
[[package]]
name = "quickcheck_macros"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "608c156fd8e97febc07dc9c2e2c80bf74cfc6ef26893eae3daf8bc2bc94a4b7f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.10"
@ -1336,6 +1404,19 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc 0.2.0",
]
[[package]]
name = "rand"
version = "0.8.4"
@ -1343,9 +1424,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_hc",
"rand_chacha 0.3.1",
"rand_core 0.6.3",
"rand_hc 0.3.1",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
dependencies = [
"ppv-lite86",
"rand_core 0.5.1",
]
[[package]]
@ -1355,7 +1446,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.3",
]
[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
dependencies = [
"getrandom 0.1.16",
]
[[package]]
@ -1364,7 +1464,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
dependencies = [
"getrandom",
"getrandom 0.2.3",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
dependencies = [
"rand_core 0.5.1",
]
[[package]]
@ -1373,7 +1482,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7"
dependencies = [
"rand_core",
"rand_core 0.6.3",
]
[[package]]
@ -1391,7 +1500,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
dependencies = [
"getrandom",
"getrandom 0.2.3",
"redox_syscall",
]
@ -1809,7 +1918,7 @@ dependencies = [
"once_cell",
"parking_lot",
"percent-encoding",
"rand",
"rand 0.8.4",
"rustls",
"serde",
"serde_json",
@ -1961,7 +2070,7 @@ checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22"
dependencies = [
"cfg-if",
"libc",
"rand",
"rand 0.8.4",
"redox_syscall",
"remove_dir_all",
"winapi",
@ -2328,7 +2437,33 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom",
"getrandom 0.2.3",
]
[[package]]
name = "validator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d0f08911ab0fee2c5009580f04615fa868898ee57de10692a45da0c3bcc3e5e"
dependencies = [
"idna",
"lazy_static",
"regex",
"serde",
"serde_derive",
"serde_json",
"url",
"validator_types",
]
[[package]]
name = "validator_types"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ded9d97e1d42327632f5f3bae6403c04886e2de3036261ef42deebd931a6a291"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
@ -2353,6 +2488,12 @@ dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasi"
version = "0.10.2+wasi-snapshot-preview1"

View File

@ -26,7 +26,13 @@ tracing-log = "0.1.1"
serde-aux = "3"
tracing-actix-web = "0.5.0-beta.7"
secrecy = { version = "0.8", features = ["serde"] }
unicode-segmentation = "1"
validator = "0.14"
[dev-dependencies]
reqwest = { version = "0.11", features = ["json"] }
once_cell = "1.7.2"
claim = "0.5"
fake = "~2.3"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"

7
src/domain/mod.rs Normal file
View File

@ -0,0 +1,7 @@
mod subscriber_name;
mod subscriber_email;
mod new_subscriber;
pub use subscriber_name::SubscriberName;
pub use new_subscriber::NewSubscriber;
pub use subscriber_email::SubscriberEmail;

View File

@ -0,0 +1,7 @@
use crate::domain::SubscriberName;
use crate::domain::SubscriberEmail;
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}

View File

@ -0,0 +1,61 @@
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 email address", s))
}
}
}
impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::SubscriberEmail;
use claim::assert_err;
use fake::faker::internet::en::SafeEmail;
use fake::Fake;
#[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));
}
#[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,64 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
let is_empty_or_whitespace = s.trim().is_empty(); // Remove trailing whitespaces and check if containsd any characters
let is_too_long = s.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g)); // Iterate to check if name contains any of the forbidden characters
// Return `false` if any conditions are violated
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
Err(format!("{} is not a valid subscriber name", s))
} else {
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_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_invalid_characters_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

@ -3,3 +3,4 @@ pub mod configuration;
pub mod routes;
pub mod startup;
pub mod telemetry;
pub mod domain;

View File

@ -2,7 +2,8 @@ use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use chrono::Utc;
use uuid::Uuid;
// use tracing_futures::Instrument;
use crate::domain::{NewSubscriber, SubscriberName, SubscriberEmail};
#[derive(serde::Deserialize)]
pub struct FormData {
@ -10,6 +11,16 @@ pub struct FormData {
name: String
}
impl TryFrom<FormData> for NewSubscriber {
type Error = String;
fn try_from(value: FormData) -> Result<Self, Self::Error> {
let name = SubscriberName::parse(value.name)?;
let email = SubscriberEmail::parse(value.email)?;
Ok(Self { email, name })
}
}
#[allow(clippy::async_yields_async)]
#[tracing::instrument(
name = "Adding a new subscriber",
@ -19,9 +30,12 @@ pub struct FormData {
subscriber_name = %form.name
)
)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>,) -> HttpResponse {
match insert_subscriber(&pool, &form).await {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
@ -29,17 +43,17 @@ pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>,) -> H
#[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)

View File

@ -145,3 +145,32 @@ async fn subscribe_returns_400_for_missing_form_data() {
);
}
}
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_empty() {
// 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 (body, description) in test_cases {
// Act
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(400,
response.status().as_u16(),
"The API did not return a 400 Bad Request when the payload was {}.", description
);
}
}