diff --git a/Cargo.lock b/Cargo.lock index 9a4c473..3090bc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -3340,6 +3346,7 @@ name = "zero2prod" version = "0.1.0" dependencies = [ "actix-web", + "anyhow", "chrono", "claims", "config", @@ -3355,6 +3362,7 @@ dependencies = [ "serde-aux", "serde_json", "sqlx", + "thiserror", "tokio", "tracing", "tracing-actix-web", diff --git a/Cargo.toml b/Cargo.toml index 624a796..bffe43c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ name = "zero2prod" [dependencies] actix-web = "4.5.1" +anyhow = "1.0.86" chrono = "0.4.38" config = "0.14.0" rand = { version = "0.8.5", features = ["std_rng"] } @@ -20,6 +21,7 @@ reqwest = { version = "0.12.4", default-features = false, features = ["json", "r secrecy = { version = "0.8.0", features = ["serde"] } serde = { version = "1.0.203", features = ["derive"] } serde-aux = "4.5.0" +thiserror = "1.0.61" tokio = {version = "1.37.0",features = ["macros", "rt-multi-thread"]} tracing = { version = "0.1.40", features = ["log"] } tracing-actix-web = "0.7.11" diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 70a28f0..3fc44c3 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,8 +1,9 @@ use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; use crate::email_client::EmailClient; use crate::startup::ApplicationBaseUrl; -use actix_web::ResponseError; -use actix_web::{web, HttpResponse}; +use actix_web::http::StatusCode; +use actix_web::{web, HttpResponse, ResponseError}; +use anyhow::Context; use rand::distributions::Alphanumeric; use rand::{thread_rng, Rng}; use sqlx::{Executor, PgPool, Postgres, Transaction}; @@ -23,13 +24,27 @@ impl TryFrom for NewSubscriber { Ok(NewSubscriber { email, name }) } } +#[derive(thiserror::Error)] +pub enum SubscribeError { + #[error("{0}")] + ValidationError(String), + #[error(transparent)] + UnexpectedError(#[from] anyhow::Error), +} -fn generate_subscription_token() -> String { - let mut rng = thread_rng(); - std::iter::repeat_with(|| rng.sample(Alphanumeric)) - .map(char::from) - .take(25) - .collect() +impl std::fmt::Debug for SubscribeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + error_chain_fmt(self, f) + } +} + +impl ResponseError for SubscribeError { + fn status_code(&self) -> StatusCode { + match self { + SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST, + SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } } #[tracing::instrument( @@ -46,43 +61,47 @@ pub async fn subscribe( pool: web::Data, email_client: web::Data, base_url: web::Data, -) -> Result { - let new_subscriber = match form.0.try_into() { - Ok(form) => form, - Err(_) => return Ok(HttpResponse::BadRequest().finish()), - }; - - let mut transaction = match pool.begin().await { - Ok(transaction) => transaction, - Err(_) => return Ok(HttpResponse::InternalServerError().finish()), - }; - - let subscriber_id = match insert_subscriber(&mut transaction, &new_subscriber).await { - Ok(subscriber_id) => subscriber_id, - Err(_) => return Ok(HttpResponse::InternalServerError().finish()), - }; +) -> Result { + let new_subscriber = form.0.try_into().map_err(SubscribeError::ValidationError)?; + + let mut transaction = pool + .begin() + .await + .context("Failed to acquire a Postgres connection from the pool")?; + let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber) + .await + .context("Failed to insert new subscriber in the database.")?; let subscription_token = generate_subscription_token(); - store_token(&mut transaction, subscriber_id, &subscription_token).await?; + store_token(&mut transaction, subscriber_id, &subscription_token) + .await + .context("Failed to store the confirmation token for a new subscriber.")?; - if transaction.commit().await.is_err() { - return Ok(HttpResponse::InternalServerError().finish()); - } + transaction + .commit() + .await + .context("Failed to commit SQL transaction to store a new subscriber.")?; - if send_confirmation_email( + send_confirmation_email( &email_client, new_subscriber, &base_url.0, &subscription_token, ) .await - .is_err() - { - return Ok(HttpResponse::InternalServerError().finish()); - } + .context("Failed to send a confirmation email.")?; + Ok(HttpResponse::Ok().finish()) } +fn generate_subscription_token() -> String { + let mut rng = thread_rng(); + std::iter::repeat_with(|| rng.sample(Alphanumeric)) + .map(char::from) + .take(25) + .collect() +} + #[tracing::instrument( name = "Saving new subscriber details in the database", skip(new_subscriber, transaction) @@ -102,13 +121,39 @@ pub async fn insert_subscriber( new_subscriber.name.as_ref(), ); - transaction.execute(query).await.map_err(|e| { - tracing::error!("Failed to execute query: {:?}", e); - e - })?; + transaction.execute(query).await?; Ok(subscriber_id) } +#[tracing::instrument( + name = "Send a confirmation email to a new subscriber", + skip(email_client, new_subscriber, base_url, subscription_token) +)] +pub async fn send_confirmation_email( + email_client: &EmailClient, + new_subscriber: NewSubscriber, + base_url: &str, + subscription_token: &str, +) -> Result<(), reqwest::Error> { + let confirmation_link = format!( + "{}/subscriptions/confirm?subscription_token={}", + base_url, subscription_token + ); + + let plain_body = format!( + "Welcome to our newsletter!\nVisit {} to confirm your subscription.", + confirmation_link + ); + let html_body = format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription.", + confirmation_link, + ); + email_client + .send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body) + .await +} + #[tracing::instrument( name = "Storing subscription token in the database", skip(transaction, subscription_token) @@ -127,21 +172,31 @@ pub async fn store_token( subscriber_id ); - transaction.execute(query).await.map_err(|e| { - tracing::error!("Failed to execute query: {:?}", e); - StoreTokenError(e) - })?; + transaction.execute(query).await.map_err(StoreTokenError)?; Ok(()) } #[allow(unused)] pub struct StoreTokenError(sqlx::Error); +impl std::error::Error for StoreTokenError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + // The compiler transparently casts `&sqlx::Error` into a `&dyn Error` + Some(&self.0) + } +} + +impl std::fmt::Debug for StoreTokenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + error_chain_fmt(self, f) + } +} + impl std::fmt::Display for StoreTokenError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "A database error occurred when storing a subscription token" + "A database failure was encountered while trying to store a subscription token." ) } } @@ -158,47 +213,3 @@ fn error_chain_fmt( } Ok(()) } - -impl std::error::Error for StoreTokenError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - // The compiler transparently casts `&sqlx::Error` into a `&dyn Error` - Some(&self.0) - } -} - -impl std::fmt::Debug for StoreTokenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - error_chain_fmt(self, f) - } -} - -impl ResponseError for StoreTokenError {} - -#[tracing::instrument( - name = "Send a confirmation email to a new subscriber", - skip(email_client, new_subscriber, base_url, subscription_token) -)] -pub async fn send_confirmation_email( - email_client: &EmailClient, - new_subscriber: NewSubscriber, - base_url: &str, - subscription_token: &str, -) -> Result<(), reqwest::Error> { - let confirmation_link = format!( - "{}/subscriptions/confirm?subscription_token={}", - base_url, subscription_token - ); - - let plain_body = format!( - "Welcome to our newsletter!\nVisit {} to confirm your subscription.", - confirmation_link - ); - let html_body = format!( - "Welcome to our newsletter!
\ -Click here to confirm your subscription.", - confirmation_link, - ); - email_client - .send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body) - .await -} diff --git a/src/telemetry.rs b/src/telemetry.rs index 417f975..6e6a805 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -5,16 +5,6 @@ use tracing_log::LogTracer; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry}; -/// Compose multiple layers into a `tracing`'s subscriber. -/// -/// # Implementation Notes -/// -/// We are using `impl Subscriber` as return type to avoid having to -/// spell out the actual type of the returned subscriber, which is -/// indeed quite complex. -/// We need to explicitly call out that the returned subscriber is -/// `Send` and `Sync` to make it possible to pass it to `init_subscriber` -/// later on. pub fn get_subscriber( name: String, env_filter: String, @@ -34,9 +24,6 @@ where .with(formatting_layer) } -/// Register a subscriber as global default to process span data. -/// -/// It should only be called once! pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) { LogTracer::init().expect("Failed to set logger"); set_global_default(subscriber).expect("Failed to set subscriber"); diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index c5e7c04..8ef1842 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -134,19 +134,18 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { #[tokio::test] async fn subscribe_fails_if_there_is_a_fatal_database_error() { -// Arrange -let app = spawn_app().await; -let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; -// Sabotage the database -sqlx::query!("ALTER TABLE subscription_tokens DROP COLUMN subscription_token;",) -.execute(&app.db_pool) -.await -.unwrap(); - -// Act -let response = app.post_subscriptions(body.into()).await; - -// Assert -assert_eq!(response.status().as_u16(), 500); - -} \ No newline at end of file + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + // Sabotage the database + sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email;",) + .execute(&app.db_pool) + .await + .unwrap(); + + // Act + let response = app.post_subscriptions(body.into()).await; + + // Assert + assert_eq!(response.status().as_u16(), 500); +}