Email client, application startup logic and tests
This commit is contained in:
@@ -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
199
src/email_client.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@ pub mod domain;
|
||||
pub mod routes;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
pub mod email_client;
|
||||
|
||||
22
src/main.rs
22
src/main.rs
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user