Skip to content

Commit

Permalink
finish chapter 7
Browse files Browse the repository at this point in the history
  • Loading branch information
josemoura212 committed Jun 21, 2024
1 parent 719f0f1 commit 4310c72
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 69 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ name = "zero2prod"
actix-web = "4.5.1"
chrono = "0.4.38"
config = "0.14.0"
rand = { version = "0.8.5", features = ["std_rng"] }
reqwest = { version = "0.12.4", default-features = false, features = ["json", "rustls-tls"] }
secrecy = { version = "0.8.0", features = ["serde"] }
serde = { version = "1.0.203", features = ["derive"] }
Expand Down
6 changes: 3 additions & 3 deletions configuration/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ database:
password: "password"
database_name: "newsletter"
email_client:
base_url: "localhost"
sender_email: "test@gmail.com"
authorization_token: "my-secret-token"
base_url: "https://api.postmarkapp.com"
sender_email: "support@mangatrix.net"
authorization_token: "4c2aee3d-5374-49c0-9faf-56d98c28ca64"
timeout_milliseconds: 10000
1 change: 1 addition & 0 deletions configuration/local.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
application:
host: 127.0.0.1
base_url: "http://127.0.0.1"
database:
require_ssl: false
2 changes: 1 addition & 1 deletion configuration/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ database:
require_ssl: true
email_client:
base_url: "https://api.postmarkapp.com"
sender_email: "something@gmail.com"
sender_email: "support@mangatrix.net"
1 change: 1 addition & 0 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub base_url: String,
}

#[derive(serde::Deserialize, Clone)]
Expand Down
2 changes: 2 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod health_check;
mod subscriptions;
mod subscriptions_confirm;

pub use health_check::*;
pub use subscriptions::*;
pub use subscriptions_confirm::*;
133 changes: 99 additions & 34 deletions src/routes/subscriptions.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
use crate::email_client::EmailClient;
use crate::startup::ApplicationBaseUrl;
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use sqlx::{Executor, PgPool, Postgres, Transaction};
use uuid::Uuid;

#[derive(serde::Deserialize)]
Expand All @@ -20,9 +23,17 @@ impl TryFrom<FormData> for NewSubscriber {
}
}

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 = "Adding a new subscriber",
skip(form, pool, email_client),
skip(form, pool, email_client,base_url),
fields(
subscriber_email = %form.email,
subscriber_name= %form.name
Expand All @@ -33,34 +44,114 @@ pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
base_url: web::Data<ApplicationBaseUrl>,
) -> HttpResponse {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};

if insert_subscriber(&pool, &new_subscriber).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(_) => return HttpResponse::InternalServerError().finish(),
};

if send_confirmation_email(&email_client, new_subscriber)
let subscriber_id = match insert_subscriber(&mut transaction, &new_subscriber).await {
Ok(subscriber_id) => subscriber_id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};

let subscription_token = generate_subscription_token();
if store_token(&mut transaction, subscriber_id, &subscription_token)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}

if transaction.commit().await.is_err() {
return HttpResponse::InternalServerError().finish();
}

if send_confirmation_email(
&email_client,
new_subscriber,
&base_url.0,
&subscription_token,
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}

#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(new_subscriber, transaction)
)]
pub async fn insert_subscriber(
transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber,
) -> Result<Uuid, sqlx::Error> {
let subscriber_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
INSERT INTO subscriptions (id,email, name,status)
VALUES ($1,$2,$3,'pending_confirmation')
"#,
subscriber_id,
new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
);

transaction.execute(query).await.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(subscriber_id)
}

#[tracing::instrument(
name = "Storing subscription token in the database",
skip(transaction, subscription_token)
)]
pub async fn store_token(
transaction: &mut Transaction<'_, Postgres>,
subscriber_id: Uuid,
subscription_token: &str,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"
INSERT INTO subscription_tokens (subscription_token, subscriber_id)
VALUES ($1,$2)
"#,
subscription_token,
subscriber_id
);

transaction.execute(query).await.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}

#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, 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 = "https://my-api.com/subscriptions/confirm";
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.",
Expand All @@ -75,29 +166,3 @@ Click <a href=\"{}\">here</a> to confirm your subscription.",
.send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body)
.await
}

#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id,email, name,status)
VALUES ($1,$2,$3,'pending_confirmation')
"#,
Uuid::new_v4(),
new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
63 changes: 63 additions & 0 deletions src/routes/subscriptions_confirm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;

#[derive(serde::Deserialize)]
pub struct Parameters {
subscription_token: String,
}

#[tracing::instrument(name = "confirming a pending subscriber", skip(parameters, pool))]
pub async fn confirm(parameters: web::Query<Parameters>, pool: web::Data<PgPool>) -> HttpResponse {
let id = match get_subscriber_id_from_token(&pool, &parameters.subscription_token).await {
Ok(subscriber_id) => subscriber_id,
Err(_) => return HttpResponse::BadRequest().finish(),
};

match id {
None => HttpResponse::Unauthorized().finish(),
Some(subscriber_id) => {
if confirm_subscriber(&pool, subscriber_id).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
}
}

#[tracing::instrument(name = "Marking subscriber as confirmed", skip(subscriber_id, pool))]
pub async fn confirm_subscriber(pool: &PgPool, subscriber_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE subscriptions
SET status = 'confirmed'
WHERE id = $1
"#,
subscriber_id
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}

#[tracing::instrument(name = "Get subscriber_id from token", skip(subscription_token, pool))]
pub async fn get_subscriber_id_from_token(
pool: &PgPool,
subscription_token: &str,
) -> Result<Option<Uuid>, sqlx::Error> {
let result = sqlx::query!(
r#"SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1"#,
subscription_token,
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(result.map(|r| r.subscriber_id))
}
17 changes: 14 additions & 3 deletions src/startup.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::configuration::{DatabaseSettings, Settings};
use crate::email_client::EmailClient;
use crate::routes::{health_check, subscribe};
use crate::routes::{confirm, health_check, subscribe};
use actix_web::web::Data;
use actix_web::{dev::Server, web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
Expand Down Expand Up @@ -36,7 +36,12 @@ impl Application {
);
let listener = TcpListener::bind(address)?;
let port = listener.local_addr().unwrap().port();
let server = run(listener, connection_pool, email_client)?;
let server = run(
listener,
connection_pool,
email_client,
configuration.application.base_url,
)?;

Ok(Self { port, server })
}
Expand All @@ -54,20 +59,26 @@ pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(configuration.with_db())
}

pub fn run(
pub struct ApplicationBaseUrl(pub String);

fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
base_url: String,
) -> Result<Server, std::io::Error> {
let db_pool = Data::new(db_pool);
let email_client = Data::new(email_client);
let base_url = Data::new(ApplicationBaseUrl(base_url));
let server = HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.route("/subscriptions/confirm", web::get().to(confirm))
.app_data(db_pool.clone())
.app_data(email_client.clone())
.app_data(base_url.clone())
})
.listen(listener)?
.run();
Expand Down
27 changes: 27 additions & 0 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub struct TestApp {
pub email_server: MockServer,
}

pub struct ConfirmationLinks {
pub html: reqwest::Url,
pub plain_text: reqwest::Url,
}

impl TestApp {
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
reqwest::Client::new()
Expand All @@ -36,6 +41,28 @@ impl TestApp {
.await
.expect("Failed to execute request.")
}

pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap();

let get_link = |s: &str| {
let links: Vec<_> = linkify::LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
let raw_link = links[0].as_str().to_owned();
let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap();
// Let's make sure we don't call random APIs on the web
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
confirmation_link.set_port(Some(self.port)).unwrap();
confirmation_link
};

let html = get_link(&body["HtmlBody"].as_str().unwrap());
let plain_text = get_link(&body["TextBody"].as_str().unwrap());
ConfirmationLinks { html, plain_text }
}
}

pub async fn spawn_app() -> TestApp {
Expand Down
1 change: 1 addition & 0 deletions tests/api/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod health_check;
mod helpers;
mod subscriptions;
mod subscriptions_confirm;
Loading

0 comments on commit 4310c72

Please sign in to comment.