This commit is contained in:
Luca Palmieri 2020-12-11 23:01:55 +00:00
commit 518db87ced
7 changed files with 181 additions and 31 deletions

57
Cargo.lock generated
View file

@ -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",
]

View file

@ -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"

View file

@ -1,4 +1,4 @@
# Zero To Production / Code
# Zero To Production / Code (Chapter 5)
<div align="center"><a href="https://zero2prod.com" target="_blank"><img src="https://static-2.gumroad.com/res/gumroad/3629854790655/asset_previews/bc9026cad3ece1746327c1d70218f602/retina/rsz_zero_to_production_punk.png" /></a></div>
@ -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

95
src/domain.rs Normal file
View file

@ -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<SubscriberName, String> {
// `.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<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_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_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));
}
}

View file

@ -1,5 +1,5 @@
#![allow(clippy::toplevel_ref_arg)]
pub mod configuration;
pub mod domain;
pub mod routes;
pub mod startup;
pub mod telemetry;

View file

@ -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<FormData>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, HttpResponse> {
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)

View file

@ -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
);
}
}