finished chapter-6

This commit is contained in:
Kristofers Solo 2024-03-25 18:06:26 +02:00
parent 7657373f3b
commit a68c95d51b
8 changed files with 315 additions and 107 deletions

159
Cargo.lock generated
View File

@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"getrandom",
"getrandom 0.2.12",
"once_cell",
"version_check",
"zerocopy",
@ -386,6 +386,16 @@ 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 = "equivalent"
version = "1.0.1"
@ -419,6 +429,15 @@ version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[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 = "fastrand"
version = "2.0.2"
@ -564,6 +583,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.12"
@ -572,7 +602,7 @@ checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
@ -793,6 +823,16 @@ dependencies = [
"cc",
]
[[package]]
name = "idna"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "0.5.0"
@ -956,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
@ -1010,7 +1050,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@ -1256,6 +1296,29 @@ dependencies = [
"unicode-ident",
]
[[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 1.0.109",
]
[[package]]
name = "quote"
version = "1.0.35"
@ -1265,6 +1328,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",
]
[[package]]
name = "rand"
version = "0.8.5"
@ -1272,8 +1348,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[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]]
@ -1283,7 +1369,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[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]]
@ -1292,7 +1387,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.12",
]
[[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]]
@ -1398,7 +1502,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.12",
"libc",
"spin 0.9.8",
"untrusted",
@ -1418,7 +1522,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -1664,7 +1768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -1854,7 +1958,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@ -1895,7 +1999,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@ -2380,7 +2484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
dependencies = [
"form_urlencoded",
"idna",
"idna 0.5.0",
"percent-encoding",
]
@ -2396,10 +2500,25 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
dependencies = [
"getrandom",
"getrandom 0.2.12",
"serde",
]
[[package]]
name = "validator"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd"
dependencies = [
"idna 0.4.0",
"lazy_static",
"regex",
"serde",
"serde_derive",
"serde_json",
"url",
]
[[package]]
name = "valuable"
version = "0.1.0"
@ -2427,6 +2546,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.11.0+wasi-snapshot-preview1"
@ -2721,7 +2846,10 @@ dependencies = [
"chrono",
"claims",
"config",
"fake",
"once_cell",
"quickcheck",
"quickcheck_macros",
"reqwest",
"secrecy",
"serde",
@ -2735,6 +2863,7 @@ dependencies = [
"tracing-subscriber",
"unicode-segmentation",
"uuid",
"validator",
]
[[package]]

View File

@ -38,10 +38,14 @@ secrecy = { version = "0.8", features = ["serde"] }
serde-aux = "4"
unicode-segmentation = "1"
claims = "0.7"
validator = "0.16"
[dev-dependencies]
reqwest = "0.12"
once_cell = "1.19"
fake = "~2.3"
quickcheck = "0.9"
quickcheck_macros = "0.9"
[package.metadata.clippy]
warn = [

View File

@ -1,74 +0,0 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct NewSubscriber {
pub email: String,
pub name: SubscriberName,
}
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<Self, String> {
let is_empty_or_whitespace = s.trim().is_empty();
let is_too_long = s.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|c| forbidden_characters.contains(&c));
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
return Err(format!("{} is not a valid subscriber name.", s));
}
Ok(Self(s))
}
}
impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use claims::{assert_err, assert_ok};
use super::*;
#[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));
}
}

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

@ -0,0 +1,7 @@
pub mod new_subscriber;
pub mod subscriber_email;
pub 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 super::{subscriber_name::SubscriberName, SubscriberEmail};
#[derive(Debug)]
pub struct NewSubscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}

View File

@ -0,0 +1,67 @@
use std::str::FromStr;
use validator::validate_email;
#[derive(Debug)]
pub struct SubscriberEmail(String);
impl TryFrom<String> for SubscriberEmail {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
if !validate_email(&value) {
return Err(format!("{} is not a valid subscriber email.", value));
}
Ok(Self(value))
}
}
impl FromStr for SubscriberEmail {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s.to_owned())
}
}
impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use claims::assert_err;
use fake::{faker::internet::en::SafeEmail, Fake};
use quickcheck::Arbitrary;
#[derive(Debug, Clone)]
struct ValidEmailFixture(pub String);
impl Arbitrary for ValidEmailFixture {
fn arbitrary<G: quickcheck::Gen>(g: &mut G) -> Self {
let email = SafeEmail().fake_with_rng(g);
Self(email)
}
}
#[test]
fn empty_string_is_rejected() {
assert_err!(SubscriberEmail::from_str(""));
}
#[test]
fn email_missing_at_symbol_is_rejected() {
assert_err!(SubscriberEmail::from_str("ursuladomain.com"));
}
#[test]
fn email_missing_subject_is_rejected() {
assert_err!(SubscriberEmail::from_str("@domain.com"));
}
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
SubscriberEmail::try_from(valid_email.0).is_ok()
}
}

View File

@ -0,0 +1,73 @@
use std::str::FromStr;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl TryFrom<String> for SubscriberName {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
let is_empty_or_whitespace = value.trim().is_empty();
let is_too_long = value.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters =
value.chars().any(|c| forbidden_characters.contains(&c));
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
return Err(format!("{} is not a valid subscriber name.", value));
}
Ok(Self(value))
}
}
impl FromStr for SubscriberName {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s.to_owned())
}
}
impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use claims::{assert_err, assert_ok};
use super::*;
#[test]
fn a_256_grapheme_long_name_is_valid() {
assert_ok!(SubscriberName::try_from("ē".repeat(256)));
}
#[test]
fn a_name_longer_than_256_graphemes_is_rejected() {
assert_err!(SubscriberName::try_from("a".repeat(257)));
}
#[test]
fn whitespace_only_names_are_rejected() {
assert_err!(SubscriberName::from_str(" "));
}
#[test]
fn empty_string_is_rejected() {
assert_err!(SubscriberName::from_str(""));
}
#[test]
fn names_containing_an_invalid_character_are_rejected() {
for ch in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
assert_err!(SubscriberName::try_from(ch.to_string()));
}
}
#[test]
fn a_valid_name_is_try_fromd_successfully() {
assert_ok!(SubscriberName::from_str("Ursula Le Guin"));
}
}

View File

@ -3,10 +3,9 @@ use chrono::Utc;
use serde::Deserialize;
use sqlx::PgPool;
use tracing::error;
use unicode_segmentation::UnicodeSegmentation;
use uuid::Uuid;
use crate::domain::{NewSubscriber, SubscriberName};
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
#[derive(Deserialize)]
pub struct FormData {
@ -26,14 +25,11 @@ pub async fn subscribe(
State(pool): State<PgPool>,
Form(form): Form<FormData>,
) -> impl IntoResponse {
if !is_valid_name(&form.name) {
return StatusCode::BAD_REQUEST;
}
let new_subscriber = NewSubscriber {
email: form.email,
name: SubscriberName::parse(form.name).expect("Name validation failed."),
let new_subscriber = match form.try_into() {
Ok(subscriber) => subscriber,
Err(_) => return StatusCode::BAD_REQUEST,
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
@ -54,7 +50,7 @@ pub async fn insert_subscriber(
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
new_subscriber.email,
new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
Utc::now()
)
@ -67,12 +63,11 @@ pub async fn insert_subscriber(
Ok(())
}
/// Returns `true` if the input satisfies all validation constraints
/// on subscriber names, `false` otherwise.
pub fn is_valid_name(s: &str) -> bool {
let is_empty_or_whitespace = s.trim().is_empty();
let is_too_long = s.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|c| forbidden_characters.contains(&c));
!(is_empty_or_whitespace || is_too_long || contains_forbidden_characters)
impl TryFrom<FormData> for NewSubscriber {
type Error = String;
fn try_from(value: FormData) -> Result<Self, Self::Error> {
let name = value.name.try_into()?;
let email = value.email.try_into()?;
Ok(NewSubscriber { email, name })
}
}