Email client, application startup logic and tests

This commit is contained in:
Alphonse Paix
2025-08-24 11:31:03 +02:00
parent 85ab04f254
commit 4389873bf4
14 changed files with 636 additions and 410 deletions

View File

@@ -1,3 +1,4 @@
use crate::domain::SubscriberEmail;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
@@ -58,6 +59,7 @@ impl TryFrom<String> for Environment {
pub struct Settings {
pub application: ApplicationSettings,
pub database: DatabaseSettings,
pub email_client: EmailClientSettings,
}
#[derive(Deserialize)]
@@ -67,6 +69,35 @@ pub struct ApplicationSettings {
pub host: String,
}
#[derive(Deserialize)]
pub struct EmailClientSettings {
pub base_url: String,
sender_email: String,
pub authorization_token: SecretString,
pub timeout_milliseconds: u64,
}
impl EmailClientSettings {
pub fn sender(&self) -> Result<SubscriberEmail, String> {
SubscriberEmail::parse(self.sender_email.clone())
}
pub fn new(
base_url: String,
sender_email: String,
authorization_token: String,
timeout_milliseconds: u64,
) -> Self {
let authorization_token = SecretString::from(authorization_token);
Self {
base_url,
sender_email,
authorization_token,
timeout_milliseconds,
}
}
}
#[derive(Deserialize)]
pub struct DatabaseSettings {
pub username: String,

199
src/email_client.rs Normal file
View File

@@ -0,0 +1,199 @@
use std::time::Duration;
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use crate::{configuration::EmailClientSettings, domain::SubscriberEmail};
pub struct EmailClient {
http_client: Client,
base_url: reqwest::Url,
sender: SubscriberEmail,
authorization_token: SecretString,
}
impl EmailClient {
pub fn new(config: EmailClientSettings) -> Self {
Self {
http_client: Client::builder()
.timeout(Duration::from_millis(config.timeout_milliseconds))
.build()
.unwrap(),
base_url: reqwest::Url::parse(&config.base_url).unwrap(),
sender: config.sender().unwrap(),
authorization_token: config.authorization_token,
}
}
pub async fn send_email(
&self,
recipient: &SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
let url = self.base_url.join("v1/email").unwrap();
let request_body = SendEmailRequest {
from: self.sender.as_ref(),
to: vec![recipient.as_ref()],
subject,
text: text_content,
html: html_content,
};
self.http_client
.post(url)
.header("X-Requested-With", "XMLHttpRequest")
.header(
"Authorization",
format!("Bearer {}", self.authorization_token.expose_secret()),
)
.json(&request_body)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[derive(serde::Serialize)]
struct SendEmailRequest<'a> {
from: &'a str,
to: Vec<&'a str>,
subject: &'a str,
text: &'a str,
html: &'a str,
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crate::{
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
};
use claims::{assert_err, assert_ok};
use fake::{
Fake, Faker,
faker::{
internet::en::SafeEmail,
lorem::en::{Paragraph, Sentence},
},
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{any, header, header_exists, method, path},
};
struct SendEmailBodyMatcher;
impl wiremock::Match for SendEmailBodyMatcher {
fn matches(&self, request: &wiremock::Request) -> bool {
let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);
if let Ok(body) = result {
body.get("from").is_some()
&& body.get("to").is_some()
&& body.get("subject").is_some()
&& body.get("html").is_some()
&& body.get("text").is_some()
} else {
false
}
}
}
fn subject() -> String {
Sentence(1..2).fake()
}
fn content() -> String {
Paragraph(1..10).fake()
}
fn email() -> SubscriberEmail {
SubscriberEmail::parse(SafeEmail().fake()).unwrap()
}
fn email_client(base_url: String) -> EmailClient {
let sender_email = SafeEmail().fake();
let token: String = Faker.fake();
let settings = EmailClientSettings::new(base_url, sender_email, token, 200);
EmailClient::new(settings)
}
#[tokio::test]
async fn send_email_sends_the_expected_request() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(header_exists("Authorization"))
.and(header("Content-Type", "application/json"))
.and(header("X-Requested-With", "XMLHttpRequest"))
.and(path("v1/email"))
.and(method("POST"))
.and(SendEmailBodyMatcher)
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
email_client
.send_email(&email(), &subject(), &content(), &content())
.await
.unwrap();
}
#[tokio::test]
async fn send_email_succeeds_if_the_server_returns_200() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_ok!(response);
}
#[tokio::test]
async fn send_email_fails_if_the_server_retuns_500() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_err!(response);
}
#[tokio::test]
async fn send_email_times_out_if_the_server_takes_too_long() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(180)))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_err!(response);
}
}

View File

@@ -3,3 +3,4 @@ pub mod domain;
pub mod routes;
pub mod startup;
pub mod telemetry;
pub mod email_client;

View File

@@ -1,19 +1,13 @@
use sqlx::postgres::PgPoolOptions;
use tokio::net::TcpListener;
use zero2prod::{configuration::get_configuration, startup::run, telemetry::init_subscriber};
use zero2prod::{
configuration::get_configuration, startup::Application, telemetry::init_subscriber,
};
#[tokio::main]
async fn main() {
async fn main() -> Result<(), std::io::Error> {
init_subscriber(std::io::stdout);
let configuration = get_configuration().expect("Failed to read configuration");
let listener = TcpListener::bind(format!(
"{}:{}",
configuration.application.host, configuration.application.port
))
.await
.unwrap();
tracing::debug!("listening on {}", listener.local_addr().unwrap());
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
run(listener, connection_pool).await
let configuration = get_configuration().expect("Failed to read configuration");
let application = Application::build(configuration).await?;
application.run_until_stopped().await?;
Ok(())
}

View File

@@ -1,20 +1,46 @@
use crate::routes::*;
use crate::{configuration::Settings, email_client::EmailClient, routes::*};
use axum::{
Router,
extract::MatchedPath,
http::Request,
routing::{get, post},
};
use sqlx::PgPool;
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use uuid::Uuid;
pub async fn run(listener: TcpListener, connection_pool: PgPool) {
axum::serve(listener, app(connection_pool)).await.unwrap();
pub struct Application {
listener: TcpListener,
router: Router,
}
pub fn app(connection_pool: PgPool) -> Router {
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
let listener = TcpListener::bind(address).await?;
let connection_pool =
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::new(configuration.email_client);
let router = app(connection_pool, email_client);
Ok(Self { listener, router })
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
tracing::debug!("listening on {}", self.listener.local_addr().unwrap());
axum::serve(self.listener, self.router).await
}
pub fn address(&self) -> String {
self.listener.local_addr().unwrap().to_string()
}
}
pub fn app(connection_pool: PgPool, email_client: EmailClient) -> Router {
Router::new()
.route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe))
@@ -36,4 +62,5 @@ pub fn app(connection_pool: PgPool) -> Router {
}),
)
.with_state(connection_pool)
.with_state(Arc::new(email_client))
}