Skip to content

Commit

Permalink
code in chapter 7.2.5.0.2
Browse files Browse the repository at this point in the history
Add email client (for verification)
Work In Progress: this code exposes a compiler bug (1.72) encountered incremental compilation error with mir_shims(aa7d6ad7e64c8d82-9f4068ebffc54ad8)
  |
  • Loading branch information
cnoam committed Nov 16, 2023
1 parent 3415e2e commit 2a117ea
Show file tree
Hide file tree
Showing 11 changed files with 431 additions and 161 deletions.
359 changes: 226 additions & 133 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ features = [
#"offline", # sqlx 0.7 does not have 'offline'
]

[dependencies.reqwest]
version = "0.11"
default-features = false
# We need the `json` feature flag to serialize/deserialize JSON payloads
features = ["json", "rustls-tls"]

[lib]
path = "src/lib.rs"
name = "zero2prod"
Expand All @@ -48,9 +54,10 @@ path = "src/main.rs"
name = "zero2prod"

[dev-dependencies]
reqwest = "0.11"
once_cell = "1"
claims = "0.7"
fake = "~2.3"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"
quickcheck_macros = "0.9.1"
# tokio = { version = "1", features = ["rt", "macros"] } it compiles without?!
wiremock = "0.5"
7 changes: 6 additions & 1 deletion configuration/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ database:
port: 5432
username: "postgres"
password: "password" # used locally only. When deploying to DigitalOcean, a strong password is used
database_name: "newsletter"
database_name: "newsletter"

email_client:
base_url: "localhost"
sender_email: "test@gmail.com"
authorization_token: "873fa009-f43b-4494-a791-0d39c68792f4"
8 changes: 7 additions & 1 deletion configuration/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@ application:


database:
require_ssl: true
require_ssl: true

email_client:
# Value retrieved from Postmark's API documentation
base_url: "https://api.postmarkapp.com"
# Use the single sender email you authorised on Postmark!
sender_email: "something@gmail.com"
41 changes: 30 additions & 11 deletions src/configuration.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
//! src/configuration.rs
//!
use secrecy::Secret;
use secrecy::ExposeSecret;
use secrecy::Secret;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::postgres::{PgConnectOptions,PgSslMode};
use sqlx::postgres::{PgConnectOptions, PgSslMode};
use crate::domain::SubscriberEmail;


#[derive(serde::Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
pub email_client: EmailClientSettings,
}


#[derive(serde::Deserialize)]
pub struct EmailClientSettings {
pub base_url: String,
pub sender_email: String,
// New (secret) configuration value!
pub authorization_token: Secret<String>
}

impl EmailClientSettings {
pub fn sender(&self) -> Result<SubscriberEmail, String> {
SubscriberEmail::parse(self.sender_email.clone())
}
}


#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
pub username: String,
Expand All @@ -37,6 +55,7 @@ pub enum Environment {
Local,
Production,
}

impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Expand All @@ -45,6 +64,7 @@ impl Environment {
}
}
}

impl TryFrom<String> for Environment {
type Error = String;

Expand All @@ -62,7 +82,6 @@ impl TryFrom<String> for Environment {
}

pub fn get_configuration() -> Result<Settings, config::ConfigError> {

let base_path = std::env::current_dir()
.expect("Failed to determine the current directory");
let configuration_directory = base_path.join("configuration");
Expand Down Expand Up @@ -96,14 +115,14 @@ impl DatabaseSettings {
options
}

pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
// Try an encrypted connection, fallback to unencrypted if it fails
PgSslMode::Prefer
};
PgConnectOptions::new()
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
// Try an encrypted connection, fallback to unencrypted if it fails
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(&self.password.expose_secret())
Expand Down
2 changes: 1 addition & 1 deletion src/domain/subscriber_email.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! src/domain/subscriber_email.rs
use validator::validate_email;

#[derive(Debug)]
#[derive(Debug,Clone)]
pub struct SubscriberEmail(String);

impl SubscriberEmail {
Expand Down
119 changes: 119 additions & 0 deletions src/email_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! src/email_client.rs
use reqwest::Client;
use secrecy::{ExposeSecret, Secret};

use crate::domain::SubscriberEmail;

#[derive(Clone)]
pub struct EmailClient {
http_client: Client,
base_url: String,
sender: SubscriberEmail,
// We don't want to log this by accident
authorization_token: Secret<String>,
}

impl EmailClient {
pub fn new(base_url: String, sender: SubscriberEmail,
authorization_token: Secret<String>) -> Self {
Self {
http_client: Client::new(),
base_url,
sender,
authorization_token,
}
}


pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
let url = format!("{}/email", self.base_url);
let request_body = SendEmailRequest {
from: self.sender.as_ref().to_owned(),
to: recipient.as_ref().to_owned(),
subject: subject.to_owned(),
html_body: html_content.to_owned(),
text_body: text_content.to_owned(),
};

self
.http_client
.post(&url)
.header(
"X-Postmark-Server-Token",
self.authorization_token.expose_secret(),
)
.json(&request_body)
.send()
.await?;
Ok(())
}
}

#[derive(serde::Serialize)]
struct SendEmailRequest {
from: String,
to: String,
subject: String,
html_body: String,
text_body: String,
}

/* password: KsgAcS!aaJm3WLj
api token:
*/
#[cfg(test)]
mod tests {
use fake::{Fake, Faker};
use fake::faker::internet::en::SafeEmail;
use fake::faker::lorem::en::{Paragraph, Sentence};
use secrecy::Secret;

use wiremock::{Mock, MockServer, Request, ResponseTemplate};
use wiremock::matchers::{header, header_exists, method, path};

use crate::domain::SubscriberEmail;
use crate::email_client::EmailClient;

struct SendEmailBodyMatcher;

impl wiremock::Match for SendEmailBodyMatcher {
fn matches(&self, request: &Request) -> bool {
unimplemented!()
}
}

#[tokio::test]
async fn send_email_fires_a_request_to_base_url() {
// Arrange
let mock_server = MockServer::start().await;
let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
let email_client = EmailClient::new(
mock_server.uri(),
sender,
Secret::new(Faker.fake()),
);
Mock::given(header_exists("X-Postmark-Server-Token"))
.and(header("Content-Type", "application/json"))
.and(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
let subject: String = Sentence(1..2).fake();
let content: String = Paragraph(1..10).fake();
// Act
let _ = email_client
.send_email(subscriber_email, &subject, &content, &content)
.await;
// Assert
// When going out of scope, the Mock::expect() are checked
}
}
5 changes: 1 addition & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
//! lib.rs




pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod startup;
pub mod telemetry;
24 changes: 18 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
//! main.rs
use std::net::TcpListener;

use sqlx::postgres::PgPoolOptions;

use zero2prod::configuration::get_configuration;
use zero2prod::email_client::EmailClient;
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
use sqlx::postgres::PgPoolOptions;

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
Expand All @@ -13,13 +16,22 @@ async fn main() -> Result<(), std::io::Error> {

// read configuration
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPoolOptions::new()
.acquire_timeout(std::time::Duration:: from_secs(2))
let connection_pool = PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_secs(2))
.connect_lazy_with(configuration.database.with_db());

// We have removed the hard-coded `8000` - it's now coming from our settings!
// Build an `EmailClient` using `configuration`
let sender_email = configuration.email_client.sender()
.expect("Invalid sender email address.");
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token
);


let address = format!("{}:{}",
configuration.application.host, configuration.application.port);
configuration.application.host, configuration.application.port);
let listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await
run(listener, connection_pool, email_client)?.await
}
4 changes: 3 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ use std::net::TcpListener;
use sqlx::PgPool;
use crate::routes::{health_check::health_check, subscriptions::subscribe};
use tracing_actix_web::TracingLogger;
use crate::email_client:: EmailClient;

pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> {
pub fn run(listener: TcpListener, db_pool: PgPool,email_client: EmailClient,) -> Result<Server, std::io::Error> {
// Wrap the connection in a smart pointer
let db_pool = web::Data::new(db_pool);
// Capture `connection` from the surrounding environment ---> add "move"
Expand All @@ -18,6 +19,7 @@ pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::E
.route("/subscriptions", web::post().to(subscribe))
// Register the connection as part of the application state
.app_data(db_pool.clone()) // this will be used in src/routes/subscriptions handler
.app_data(email_client.clone())
})
.listen(listener)?
.run();
Expand Down
12 changes: 11 additions & 1 deletion tests/health_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use zero2prod::configuration::{DatabaseSettings, get_configuration};
use zero2prod::telemetry::{get_subscriber, init_subscriber};
use once_cell::sync::Lazy;

use zero2prod::email_client::EmailClient;


// `tokio::test` is the testing equivalent of `tokio::main`.
// It also spares you from having to specify the `#[test]` attribute.
//
Expand Down Expand Up @@ -78,7 +81,13 @@ async fn spawn_app() -> TestApp {
configuration.database.database_name = Uuid::new_v4().to_string(); // random DB name to run tests in isolation
let connection_pool = configure_database(&configuration.database).await;

let server = zero2prod::startup::run(listener, connection_pool.clone()).expect("Failed to bind address");
let sender_email = configuration.email_client.sender().expect("Invalid sender email address.");
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
);
let server = zero2prod::startup::run(listener, connection_pool.clone(), email_client).expect("Failed to bind address");
let _ = tokio::spawn(server);

TestApp {
Expand All @@ -93,6 +102,7 @@ pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
let mut connection = PgConnection::connect_with(&config.without_db())
.await
.expect("Failed to connect to Postgres");

connection
.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
.await
Expand Down

0 comments on commit 2a117ea

Please sign in to comment.