diff --git a/Cargo.lock b/Cargo.lock index 1f1cd45..bedc67a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,9 +278,9 @@ checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e" [[package]] name = "ahash" -version = "0.5.7" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad4243ec6feddc812c0f442d9765374d250aba94e10ecf8b632e1b1c118547e8" +checksum = "19fac972e53443ba111b1ff866e9d3b6484df5c05030e13bc7c6a1ebc802e983" dependencies = [ "getrandom 0.2.0", "lazy_static", @@ -503,6 +503,15 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "claim" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ad37958d55b29a7088909368968d2fe876a24c203f8441195130f3b15194b9" +dependencies = [ + "autocfg", +] + [[package]] name = "config" version = "0.10.1" @@ -527,9 +536,9 @@ dependencies = [ [[package]] name = "const_fn" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c478836e029dcef17fb47c89023448c64f781a046e0300e257ad8225ae59afab" +checksum = "cd51eab21ab4fd6a3bf889e2d0958c0a6e3a61ad04260325e919e652a2a62826" [[package]] name = "cookie" @@ -1172,9 +1181,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" +checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb" [[package]] name = "linked-hash-map" @@ -1360,9 +1369,9 @@ dependencies = [ [[package]] name = "net2" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cf75f38f16cb05ea017784dc6dbfd354f76c223dba37701734c4f5a9337d02" +checksum = "391630d12b68002ae1e25e8f974306474966550ad82dac6886fb8910c19568ae" dependencies = [ "cfg-if 0.1.10", "libc", @@ -1441,12 +1450,12 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" -version = "0.10.30" +version = "0.10.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4" +checksum = "8d008f51b1acffa0d3450a68606e6a51c123012edaacb0f4e1426bd978869187" dependencies = [ "bitflags", - "cfg-if 0.1.10", + "cfg-if 1.0.0", "foreign-types", "lazy_static", "libc", @@ -1461,9 +1470,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" [[package]] name = "openssl-sys" -version = "0.9.58" +version = "0.9.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a842db4709b604f0fe5d1170ae3565899be2ad3d9cbc72dedc789ac0511f78de" +checksum = "de52d8eabd217311538a39bba130d7dea1f1e118010fee7a033d966845e7d5fe" dependencies = [ "autocfg", "cc", @@ -1867,9 +1876,9 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.117" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" +checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800" dependencies = [ "serde_derive", ] @@ -1887,9 +1896,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.117" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e" +checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df" dependencies = [ "proc-macro2", "quote", @@ -2190,9 +2199,9 @@ checksum = "343f3f510c2915908f155e94f17220b19ccfacf2a64a2a5d8004f2c3e311e7fd" [[package]] name = "syn" -version = "1.0.53" +version = "1.0.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8833e20724c24de12bbaba5ad230ea61c3eafb05b881c7c9d3cfe8638b187e68" +checksum = "9a2af957a63d6bd42255c359c93d9bfdb97076bd3b820897ce55ffbfbf107f44" dependencies = [ "proc-macro2", "quote", @@ -2323,9 +2332,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d7ad61edd59bfcc7e80dababf0f4aed2e6d5e0ba1659356ae889752dfc12ff" +checksum = "099837d3464c16a808060bb3f02263b412f6fafcb5d01c533d309985fbeebe48" dependencies = [ "bytes", "fnv", @@ -2639,9 +2648,9 @@ dependencies = [ [[package]] name = "vcpkg" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6454029bf181f092ad1b853286f23e2c507d8e8194d01d92da4a55c274a5508c" +checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb" [[package]] name = "version_check" @@ -2888,6 +2897,7 @@ dependencies = [ "actix-rt", "actix-web", "chrono", + "claim", "config", "lazy_static", "reqwest", @@ -2901,5 +2911,6 @@ dependencies = [ "tracing-futures", "tracing-log", "tracing-subscriber", + "unicode-segmentation", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index d162c25..6fbf417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,9 @@ tracing-bunyan-formatter = "0.1.6" tracing-log = "0.1.1" tracing-actix-web = "0.2.0" serde-aux = "1.0.1" +unicode-segmentation = "1.7.1" [dev-dependencies] reqwest = { version = "0.10.7", features = ["json"] } lazy_static = "1.4.0" +claim = "0.4.0" diff --git a/README.md b/README.md index 6f79b78..5aa21d2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Zero To Production / Code +# Zero To Production / Code (Chapter 5)
@@ -8,7 +8,7 @@ This repository serves as supplementary material for [the book](https://zero2pro ## Chapter snapshots -The `master` branch (where you are right now!) shows the project at the end of the last published chapter _(Chapter 5, right now)_. +The `master` branch (where you are right now!) shows the project at the end of the last published chapter _(Chapter 6, right now)_. You can browse the project at end of previous chapters by switching to their dedicated branches: @@ -16,6 +16,7 @@ You can browse the project at end of previous chapters by switching to their ded - [Chapter 3, Part 1](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-03-part1) - [Chapter 4](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-04) - [Chapter 5](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-05) +- [Chapter 5](https://github.com/LukeMathWalker/zero-to-production/tree/root-chapter-06) ## Pre-requisite diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..cabb0b2 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,95 @@ +use unicode_segmentation::UnicodeSegmentation; + +pub struct NewSubscriber { + pub email: String, + pub name: SubscriberName, +} + +#[derive(Debug)] +pub struct SubscriberName(String); + +impl SubscriberName { + /// Returns an instance of `SubscriberName` if the input satisfies all + /// our validation constraints on subscriber names. + /// It panics otherwise. + pub fn parse(s: String) -> Result { + // `.trim()` returns a view over the input `s` without trailing + // whitespace-like characters. + // `.is_empty` checks if the view contains any character. + let is_empty_or_whitespace = s.trim().is_empty(); + + // A grapheme is defined by the Unicode standard as a "user-perceived" + // character: `å` is a single grapheme, but it is composed of two characters + // (`a` and `̊`). + // + // `graphemes` returns an iterator over the graphemes in the input `s`. + // `true` specifies that we want to use the extended grapheme definition set, + // the recommended one. + let is_too_long = s.graphemes(true).count() > 256; + + // Iterate over all characters in the input `s` to check if any of them matches + // one of the characters in the forbidden array. + let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; + let contains_forbidden_characters = s + .chars() + .filter(|g| forbidden_characters.contains(g)) + .count() + > 0; + + 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 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_256_grapheme_long_name_is_valid() { + let name = "a̐".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_characters_are_rejected() { + for name in vec!['/', '(', ')', '"', '<', '>', '\\', '{', '}'] { + 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)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5d8e21e..19fce70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ -#![allow(clippy::toplevel_ref_arg)] pub mod configuration; +pub mod domain; pub mod routes; pub mod startup; pub mod telemetry; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index fd801af..7e55663 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,3 +1,4 @@ +use crate::domain::{NewSubscriber, SubscriberName}; use actix_web::{web, HttpResponse}; use chrono::Utc; use sqlx::PgPool; @@ -21,7 +22,13 @@ pub async fn subscribe( form: web::Form, pool: web::Data, ) -> Result { - insert_subscriber(&pool, &form) + let name = + SubscriberName::parse(form.0.name).map_err(|_| HttpResponse::BadRequest().finish())?; + let new_subscriber = NewSubscriber { + email: form.0.email, + name, + }; + insert_subscriber(&pool, &new_subscriber) .await .map_err(|_| HttpResponse::InternalServerError().finish())?; Ok(HttpResponse::Ok().finish()) @@ -29,17 +36,20 @@ pub async fn subscribe( #[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, + new_subscriber.name.as_ref(), Utc::now() ) .execute(pool) diff --git a/tests/health_check.rs b/tests/health_check.rs index 8c5ca8e..b72efb1 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -141,3 +141,34 @@ async fn subscribe_returns_a_400_when_data_is_missing() { ); } } + +#[actix_rt::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 (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 + ); + } +}